Perbaikan grt_sales_business_category

This commit is contained in:
2026-04-23 10:24:08 +07:00
parent a23f42c364
commit dd24ec9690
13 changed files with 515 additions and 31 deletions
+63
View File
@@ -875,3 +875,66 @@ Langkah deploy:
2. upgrade modul `grt_sales_business_category`
3. uji user non-super-admin dengan menghapus filter list default dan pastikan data lintas category tidak muncul
4. uji akses langsung record lintas category dan pastikan muncul peringatan akses
## Update Sales Order Type (2026-04-23)
Perubahan baru ini menambahkan pemisahan `Tipe Sales Order` untuk business category tertentu tanpa memaksa semua category memakai field yang sama.
Ringkasan perubahan:
- ditambahkan toggle `crm.business.category.use_sale_order_type`
- ditambahkan field `sale.order.sale_order_type`
- field `sale_order_type` hanya muncul jika business category mengaktifkan fitur tersebut
- jika business category aktif memakai tipe Sales Order, maka `sale_order_type` wajib diisi
- nilai `sale_order_type` dibawa ke `account.move.sale_order_type`
- ditambahkan field related stored `account.move.line.sale_order_type` agar filter laporan piutang atau journal item bisa dilakukan langsung dari data accounting
Nilai `sale_order_type` yang tersedia:
- `reguler`
- `kering`
- `partus`
- `peralatan`
- `silase`
Perubahan endpoint frontend:
- endpoint draft order sekarang menerima field `sale_order_type`
- jika business category yang dipakai frontend mengaktifkan tipe Sales Order, field `sale_order_type` wajib dikirim
- endpoint `/api/sales/draft-order/bon-kering` mewajibkan `sale_order_type = kering`
- endpoint `/api/sales/draft-order/bon-partus` mewajibkan `sale_order_type = partus`
- endpoint `/api/sales/draft-order/bon-reguler` mewajibkan `sale_order_type = reguler`
- endpoint `/api/sales/draft-order/non-ongkir` tidak mengunci satu type tertentu, selama nilainya valid
- response create draft order dan histori order sekarang mengembalikan `sale_order_type` dan `sale_order_type_label`
Catatan operasional:
- untuk business category yang tidak mengaktifkan fitur ini, field `sale_order_type` akan tetap kosong
- setelah deploy, lakukan upgrade module agar field baru muncul di database
- bila ada report piutang custom yang membaca `account.move.line`, field `sale_order_type` sudah siap dipakai sebagai filter tambahan
## Update Default Ongkir dan Commission per Type (2026-04-23)
Behavior default frontend order sekarang mengikuti `sale_order_type`.
Default ongkir:
- `reguler` -> memakai ongkir sesuai wilayah
- `kering` -> memakai ongkir sesuai wilayah
- `partus` -> memakai ongkir sesuai wilayah
- `peralatan` -> default tanpa ongkir
- `silase` -> memakai ongkir sesuai wilayah
Default sales commission:
- `reguler` -> kena fee sales commission
- `kering` -> kena fee sales commission
- `partus` -> kena fee sales commission
- `peralatan` -> tidak kena fee sales commission
- `silase` -> tidak kena fee sales commission
Catatan integrasi endpoint:
- endpoint draft order generic akan mengikuti default di atas berdasarkan `sale_order_type`
- endpoint `bon-kering`, `bon-partus`, dan `bon-reguler` tetap memaksa type masing-masing dan otomatis mengikuti default ongkir/commission yang sesuai
- endpoint `non-ongkir` tetap memaksa transaksi tanpa ongkir, tetapi response sekarang tetap menampilkan `sale_order_type` dan status default commission jika modul commission terpasang
+1 -1
View File
@@ -9,7 +9,7 @@ plus a two-step approval flow: Sales Team Leader then Accounting Manager.
"author": "PT Gagak Rimang Teknologi",
"website": "https://rimang.id",
"category": "Sales/Sales",
"version": "14.0.1.3.0",
"version": "14.0.1.5.0",
"depends": [
"sale_management",
"sale_crm",
+95 -23
View File
@@ -8,6 +8,13 @@ _logger = logging.getLogger(__name__)
class SalesBusinessCategoryApiController(http.Controller):
_SALE_ORDER_TYPE_LABELS = {
"reguler": "Reguler",
"kering": "Kering",
"partus": "Partus",
"peralatan": "Peralatan",
"silase": "Silase",
}
_SALE_ORDER_TERMS_BY_KIND = {
"bon_kering": "sale order ini jenis bon kering",
"bon_partus": "sale order ini jenis bon partus",
@@ -183,11 +190,57 @@ class SalesBusinessCategoryApiController(http.Controller):
return default_note
return "%s\n\n%s" % (default_note, payload_note)
def _normalize_sale_order_type(self, sale_order_type):
value = (sale_order_type or "").strip().lower()
if not value:
return False
if value not in self._SALE_ORDER_TYPE_LABELS:
raise ValueError(
"sale_order_type must be one of: %s"
% ", ".join(sorted(self._SALE_ORDER_TYPE_LABELS.keys()))
)
return value
def _resolve_business_category_from_payload(self, payload):
category_id = int(payload.get("business_category_id") or 0)
if category_id:
category = request.env["crm.business.category"].browse(category_id).exists()
if not category:
raise ValueError(_("Business category %s was not found.") % category_id)
return category
team_id = int(payload.get("team_id") or 0)
if team_id:
team = request.env["crm.team"].browse(team_id).exists()
if not team:
raise ValueError(_("Sales team %s was not found.") % team_id)
return team.business_category_id
return request.env["crm.business.category"]
def _resolve_sale_order_type(self, payload, expected_sale_order_type=False):
sale_order_type = self._normalize_sale_order_type(payload.get("sale_order_type"))
business_category = self._resolve_business_category_from_payload(payload)
if expected_sale_order_type and sale_order_type and sale_order_type != expected_sale_order_type:
raise ValueError(
_("sale_order_type must be '%s' for this endpoint.") % expected_sale_order_type
)
if business_category and business_category.use_sale_order_type and not sale_order_type:
raise ValueError(
_("sale_order_type is required for business category '%s'.")
% business_category.display_name
)
return sale_order_type
def _create_draft_order_from_payload(
self,
payload,
default_note=False,
skip_frontend_shipping=False,
expected_sale_order_type=False,
):
customer_qr_ref = payload.get("customer_qr_ref")
partner_id = int(payload.get("partner_id") or 0)
@@ -215,6 +268,10 @@ class SalesBusinessCategoryApiController(http.Controller):
if not order_line_payloads:
raise ValueError("order_line must contain at least one item")
sale_order_type = self._resolve_sale_order_type(
payload,
expected_sale_order_type=expected_sale_order_type,
)
order_vals = {
"partner_id": partner.id,
"partner_invoice_id": partner.address_get(["invoice"]).get("invoice") or partner.id,
@@ -224,8 +281,10 @@ class SalesBusinessCategoryApiController(http.Controller):
"commitment_date": commitment_date,
"note": self._compose_terms_and_conditions(payload.get("note"), default_note),
"is_frontend_order": True,
"skip_frontend_shipping": skip_frontend_shipping,
"sale_order_type": sale_order_type or False,
}
if skip_frontend_shipping:
order_vals["skip_frontend_shipping"] = True
optional_int_fields = ["team_id", "business_category_id"]
for field_name in optional_int_fields:
if payload.get(field_name):
@@ -246,24 +305,28 @@ class SalesBusinessCategoryApiController(http.Controller):
limit=1,
)
return self._success(
{
"sale_order_id": order.id,
"name": order.name,
"state": order.state,
"amount_total": order.amount_total,
"line_count": len(order.order_line),
"terms_and_conditions": order.note,
"is_frontend_order": order.is_frontend_order,
"skip_frontend_shipping": order.skip_frontend_shipping,
"wilayah_id": order.frontend_kecamatan_id.id,
"wilayah_name": order.frontend_kecamatan_id.name,
"shipping_product_id": shipping_rule.shipping_product_id.id if shipping_rule else False,
"shipping_product_name": shipping_rule.shipping_product_id.display_name if shipping_rule else False,
"shipping_price_per_kg": shipping_rule.shipping_price_per_kg if shipping_rule else False,
},
"Draft sales order created",
)
response_data = {
"sale_order_id": order.id,
"name": order.name,
"state": order.state,
"amount_total": order.amount_total,
"line_count": len(order.order_line),
"terms_and_conditions": order.note,
"is_frontend_order": order.is_frontend_order,
"skip_frontend_shipping": order.skip_frontend_shipping,
"sale_order_type": order.sale_order_type,
"sale_order_type_label": self._SALE_ORDER_TYPE_LABELS.get(order.sale_order_type, False),
"wilayah_id": order.frontend_kecamatan_id.id,
"wilayah_name": order.frontend_kecamatan_id.name,
"shipping_product_id": shipping_rule.shipping_product_id.id if shipping_rule else False,
"shipping_product_name": shipping_rule.shipping_product_id.display_name if shipping_rule else False,
"shipping_price_per_kg": shipping_rule.shipping_price_per_kg if shipping_rule else False,
}
if "sales_commission_enabled" in order._fields:
response_data["sales_commission_enabled"] = order.sales_commission_enabled
if "sales_commission_method" in order._fields:
response_data["sales_commission_method"] = order.sales_commission_method
return self._success(response_data, "Draft sales order created")
@http.route("/api/sales/authenticate", type="json", auth="public", methods=["POST"], csrf=False)
def authenticate(self, **kwargs):
@@ -456,8 +519,9 @@ class SalesBusinessCategoryApiController(http.Controller):
domain = [("partner_id", "child_of", partner.id)]
orders = request.env["sale.order"].search(domain, limit=limit, offset=offset, order="date_order desc, id desc")
total = request.env["sale.order"].search_count(domain)
items = [
{
items = []
for order in orders:
item = {
"sale_order_id": order.id,
"name": order.name,
"date_order": fields.Datetime.to_string(order.date_order) if order.date_order else False,
@@ -465,9 +529,14 @@ class SalesBusinessCategoryApiController(http.Controller):
"amount_total": order.amount_total,
"state": order.state,
"approval_state": order.approval_state,
"sale_order_type": order.sale_order_type,
"sale_order_type_label": self._SALE_ORDER_TYPE_LABELS.get(order.sale_order_type, False),
}
for order in orders
]
if "sales_commission_enabled" in order._fields:
item["sales_commission_enabled"] = order.sales_commission_enabled
if "sales_commission_method" in order._fields:
item["sales_commission_method"] = order.sales_commission_method
items.append(item)
return self._success({"items": items, "count": total})
except Exception as exc:
_logger.exception("Failed to fetch orders by QR")
@@ -489,6 +558,7 @@ class SalesBusinessCategoryApiController(http.Controller):
return self._create_draft_order_from_payload(
payload,
default_note=self._SALE_ORDER_TERMS_BY_KIND["bon_kering"],
expected_sale_order_type="kering",
)
except Exception as exc:
_logger.exception("Failed to create bon kering draft sales order")
@@ -501,6 +571,7 @@ class SalesBusinessCategoryApiController(http.Controller):
return self._create_draft_order_from_payload(
payload,
default_note=self._SALE_ORDER_TERMS_BY_KIND["bon_partus"],
expected_sale_order_type="partus",
)
except Exception as exc:
_logger.exception("Failed to create bon partus draft sales order")
@@ -513,6 +584,7 @@ class SalesBusinessCategoryApiController(http.Controller):
return self._create_draft_order_from_payload(
payload,
default_note=self._SALE_ORDER_TERMS_BY_KIND["bon_reguler"],
expected_sale_order_type="reguler",
)
except Exception as exc:
_logger.exception("Failed to create bon reguler draft sales order")
@@ -966,3 +966,99 @@ Sudah tersedia:
- sales order model: `grt_sales_business_category/models/sale_order.py`
- accounting move model: `grt_sales_business_category/models/account_move.py`
- sequence QR ref: `grt_sales_business_category/data/res_partner_sequence.xml`
## Update API Sales Order Type (2026-04-23)
Mulai update ini, frontend dapat diminta mengirim `sale_order_type` saat membuat draft Sales Order.
Kapan wajib:
- wajib jika `business_category_id` atau `team_id` yang dipakai mengarah ke business category dengan setting `Gunakan Tipe Sales Order`
- tidak wajib untuk business category yang tidak mengaktifkan fitur tersebut
Nilai yang valid:
- `reguler`
- `kering`
- `partus`
- `peralatan`
- `silase`
Contoh request `POST /api/sales/draft-order`:
```json
{
"params": {
"partner_id": 45,
"commitment_date": "2026-03-15 10:00:00",
"payment_term_id": 4,
"team_id": 3,
"business_category_id": 2,
"sale_order_type": "kering",
"note": "Kirim pagi",
"order_line": [
{
"product_id": 1001,
"product_uom_qty": 25,
"price_unit": 12000
}
]
}
}
```
Aturan untuk endpoint khusus:
- `/api/sales/draft-order/bon-kering` -> `sale_order_type` harus `kering`
- `/api/sales/draft-order/bon-partus` -> `sale_order_type` harus `partus`
- `/api/sales/draft-order/bon-reguler` -> `sale_order_type` harus `reguler`
- `/api/sales/draft-order/non-ongkir` -> `sale_order_type` boleh salah satu nilai valid yang dibutuhkan frontend
Tambahan response:
- `sale_order_type`
- `sale_order_type_label`
Contoh potongan response:
```json
{
"status": "success",
"message": "Draft sales order created",
"data": {
"sale_order_id": 120,
"name": "S000120",
"sale_order_type": "kering",
"sale_order_type_label": "Kering"
}
}
```
## Update Default Ongkir dan Commission per Type (2026-04-23)
Payload `sale_order_type` sekarang juga menentukan default ongkir dan fee sales commission.
Default per type:
- `reguler` -> ongkir aktif, sales commission aktif
- `kering` -> ongkir aktif, sales commission aktif
- `partus` -> ongkir aktif, sales commission aktif
- `peralatan` -> ongkir nonaktif, sales commission nonaktif
- `silase` -> ongkir aktif, sales commission nonaktif
Default commission method:
- jika commission aktif, method default adalah `weight`
Catatan endpoint:
- endpoint `/api/sales/draft-order` akan mengikuti default di atas berdasarkan `sale_order_type`
- endpoint `/api/sales/draft-order/bon-kering` akan memaksa `sale_order_type = "kering"`
- endpoint `/api/sales/draft-order/bon-partus` akan memaksa `sale_order_type = "partus"`
- endpoint `/api/sales/draft-order/bon-reguler` akan memaksa `sale_order_type = "reguler"`
- endpoint `/api/sales/draft-order/non-ongkir` tetap memaksa tanpa ongkir walaupun type tertentu biasanya memakai ongkir
Tambahan response draft order jika modul `grt_sales_commission` terpasang:
- `sales_commission_enabled`
- `sales_commission_method`
@@ -1,6 +1,14 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
SALE_ORDER_TYPE_SELECTION = [
("reguler", "Reguler"),
("kering", "Kering"),
("partus", "Partus"),
("peralatan", "Peralatan"),
("silase", "Silase"),
]
class AccountMove(models.Model):
_inherit = "account.move"
@@ -19,13 +27,31 @@ class AccountMove(models.Model):
ondelete="restrict",
copy=False,
)
show_sale_order_type = fields.Boolean(
string="Show Sale Order Type",
compute="_compute_show_sale_order_type",
)
sale_order_type = fields.Selection(
SALE_ORDER_TYPE_SELECTION,
string="Tipe Sales Order",
copy=False,
)
@api.depends("business_category_id", "business_category_id.use_sale_order_type")
def _compute_show_sale_order_type(self):
for move in self:
move.show_sale_order_type = bool(
move.business_category_id and move.business_category_id.use_sale_order_type
)
@api.onchange("business_category_id")
def _onchange_business_category_id_analytic(self):
for move in self:
move.analytic_account_id = move.business_category_id.analytic_account_id
if not move.show_sale_order_type:
move.sale_order_type = False
@api.constrains("company_id", "business_category_id", "analytic_account_id")
@api.constrains("company_id", "business_category_id", "analytic_account_id", "sale_order_type")
def _check_business_category_analytic_company(self):
for move in self:
if move.business_category_id and move.business_category_id.company_id != move.company_id:
@@ -48,11 +74,39 @@ class AccountMove(models.Model):
)
% (move.display_name,)
)
if move.sale_order_type and not move.show_sale_order_type:
raise ValidationError(
_(
"Invoice '%s' cannot use Sales Order Type because the selected Business Category does not enable it."
)
% (move.display_name,)
)
if (
move.move_type in ("out_invoice", "out_refund")
and move.business_category_id
and move.business_category_id.use_sale_order_type
and not move.sale_order_type
):
raise ValidationError(
_(
"Invoice '%s' requires Sales Order Type because the selected Business Category enables it."
)
% (move.display_name,)
)
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
sale_order_type = fields.Selection(
related="move_id.sale_order_type",
selection=SALE_ORDER_TYPE_SELECTION,
string="Tipe Sales Order",
store=True,
readonly=True,
index=True,
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
@@ -10,6 +10,11 @@ class CrmBusinessCategory(models.Model):
default=True,
help="Jika dinonaktifkan, field Wilayah Ongkir pada Sales Order akan disembunyikan dan perhitungan ongkir otomatis tidak dijalankan untuk kategori ini.",
)
use_sale_order_type = fields.Boolean(
string="Gunakan Tipe Sales Order",
default=False,
help="Jika diaktifkan, Sales Order pada business category ini wajib mengisi tipe sales order agar transaksi dan piutang bisa difilter per tipe.",
)
analytic_account_id = fields.Many2one(
"account.analytic.account",
string="Analytic Account",
@@ -2,6 +2,21 @@ from odoo import SUPERUSER_ID, _, api, fields, models
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.osv import expression
SALE_ORDER_TYPE_SELECTION = [
("reguler", "Reguler"),
("kering", "Kering"),
("partus", "Partus"),
("peralatan", "Peralatan"),
("silase", "Silase"),
]
SALE_ORDER_TYPE_DEFAULTS = {
"reguler": {"skip_frontend_shipping": False},
"kering": {"skip_frontend_shipping": False},
"partus": {"skip_frontend_shipping": False},
"peralatan": {"skip_frontend_shipping": True},
"silase": {"skip_frontend_shipping": False},
}
class SaleOrder(models.Model):
_inherit = "sale.order"
@@ -12,6 +27,7 @@ class SaleOrder(models.Model):
"is_frontend_order",
"skip_frontend_shipping",
"business_category_id",
"sale_order_type",
}
_FRONTEND_SHIPPING_RECALC_SKIP_CONTEXT_KEYS = {
"skip_frontend_shipping_recalc",
@@ -52,6 +68,16 @@ class SaleOrder(models.Model):
string="Show Sale Order Shipping",
compute="_compute_show_sale_order_shipping",
)
show_sale_order_type = fields.Boolean(
string="Show Sale Order Type",
compute="_compute_show_sale_order_type",
)
sale_order_type = fields.Selection(
SALE_ORDER_TYPE_SELECTION,
string="Tipe Sales Order",
tracking=True,
copy=False,
)
payment_status = fields.Selection(
[
("no_invoice", "Belum Ada Invoice"),
@@ -159,6 +185,13 @@ class SaleOrder(models.Model):
not order.business_category_id or order.business_category_id.use_sale_order_shipping
)
@api.depends("business_category_id", "business_category_id.use_sale_order_type")
def _compute_show_sale_order_type(self):
for order in self:
order.show_sale_order_type = bool(
order.business_category_id and order.business_category_id.use_sale_order_type
)
@api.model
def _resolve_payment_status_from_moves(self, moves):
payment_states = {
@@ -217,10 +250,25 @@ class SaleOrder(models.Model):
for order in self:
if order.team_id and order.team_id.business_category_id != order.business_category_id:
order.team_id = False
if not order.show_sale_order_type:
order.sale_order_type = False
if not order.show_sale_order_shipping:
order.frontend_kecamatan_id = False
order.order_line -= order.order_line.filtered("is_frontend_shipping_line")
@api.onchange("sale_order_type")
def _onchange_sale_order_type_defaults(self):
for order in self:
behavior = order._get_sale_order_type_defaults(order.sale_order_type)
if not behavior:
continue
order.skip_frontend_shipping = behavior.get("skip_frontend_shipping", False)
if order.skip_frontend_shipping:
order.frontend_kecamatan_id = False
order.order_line -= order.order_line.filtered("is_frontend_shipping_line")
else:
order._sync_frontend_kecamatan_from_partner()
@api.onchange("team_id")
def _onchange_team_id(self):
for order in self:
@@ -229,7 +277,7 @@ class SaleOrder(models.Model):
def _sync_frontend_kecamatan_from_partner(self):
for order in self:
if not order.show_sale_order_shipping:
if not order.show_sale_order_shipping or order.skip_frontend_shipping:
order.frontend_kecamatan_id = False
continue
partner = order.partner_id.commercial_partner_id
@@ -248,6 +296,7 @@ class SaleOrder(models.Model):
order.order_line -= shipping_lines
continue
if not order.is_frontend_order or order.skip_frontend_shipping:
order.frontend_kecamatan_id = False
order.order_line -= shipping_lines
continue
@@ -261,12 +310,16 @@ class SaleOrder(models.Model):
@api.model_create_multi
def create(self, vals_list):
vals_list = [self._prepare_business_category_vals(vals) for vals in vals_list]
vals_list = [self._prepare_sale_order_type_vals(vals) for vals in vals_list]
vals_list = [self._prepare_sale_order_type_behavior_vals(vals) for vals in vals_list]
vals_list = [self._prepare_frontend_kecamatan_vals(vals) for vals in vals_list]
orders = super().create(vals_list)
return orders
def write(self, vals):
vals = self._prepare_business_category_vals(vals)
vals = self._prepare_sale_order_type_vals(vals)
vals = self._prepare_sale_order_type_behavior_vals(vals)
vals = self._prepare_frontend_kecamatan_vals(vals)
result = super().write(vals)
@@ -343,6 +396,10 @@ class SaleOrder(models.Model):
def _prepare_frontend_kecamatan_vals(self, vals):
vals = dict(vals)
if vals.get("skip_frontend_shipping"):
vals["frontend_kecamatan_id"] = False
return vals
category_id = vals.get("business_category_id")
if category_id:
category = self.env["crm.business.category"].browse(category_id)
@@ -357,10 +414,42 @@ class SaleOrder(models.Model):
vals["frontend_kecamatan_id"] = partner.shipping_wilayah_kecamatan_id.id or False
return vals
def _prepare_sale_order_type_vals(self, vals):
vals = dict(vals)
category_id = vals.get("business_category_id")
if not category_id:
return vals
category = self.env["crm.business.category"].browse(category_id)
if not category.use_sale_order_type:
vals["sale_order_type"] = False
return vals
@api.model
def _get_sale_order_type_defaults(self, sale_order_type):
return SALE_ORDER_TYPE_DEFAULTS.get(sale_order_type or "", {})
def _prepare_sale_order_type_behavior_vals(self, vals):
vals = dict(vals)
sale_order_type = vals.get("sale_order_type")
if not sale_order_type:
return vals
behavior = self._get_sale_order_type_defaults(sale_order_type)
if not behavior:
return vals
if "skip_frontend_shipping" not in vals:
vals["skip_frontend_shipping"] = behavior.get("skip_frontend_shipping", False)
if vals.get("skip_frontend_shipping"):
vals["frontend_kecamatan_id"] = False
return vals
def _prepare_invoice(self):
vals = super()._prepare_invoice()
vals["business_category_id"] = self.business_category_id.id
vals["analytic_account_id"] = self.analytic_account_id.id
vals["sale_order_type"] = self.sale_order_type or False
return vals
def _get_frontend_shipping_total_units(self):
@@ -383,6 +472,7 @@ class SaleOrder(models.Model):
existing_lines.with_context(skip_frontend_shipping_recalc=True).unlink()
continue
if order.skip_frontend_shipping:
order.frontend_kecamatan_id = False
if existing_lines:
existing_lines.with_context(skip_frontend_shipping_recalc=True).unlink()
continue
@@ -486,6 +576,33 @@ class SaleOrder(models.Model):
% (order.team_id.name, order.team_id.business_category_id.name)
)
@api.constrains("business_category_id", "sale_order_type")
def _check_sale_order_type_scope(self):
for order in self:
if order.sale_order_type and not order.business_category_id:
raise ValidationError(
_("Sales Order '%s' cannot use Sales Order Type without Business Category.")
% order.name
)
if order.sale_order_type and not order.show_sale_order_type:
raise ValidationError(
_(
"Sales Order '%s' cannot use Sales Order Type because the selected Business Category does not enable it."
)
% order.name
)
if (
order.business_category_id
and order.business_category_id.use_sale_order_type
and not order.sale_order_type
):
raise ValidationError(
_(
"Sales Order '%s' requires Sales Order Type because the selected Business Category enables it."
)
% order.name
)
@api.constrains("opportunity_id", "business_category_id")
def _check_opportunity_business_category(self):
for order in self:
@@ -6,7 +6,9 @@
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='invoice_payment_term_id']" position="after">
<field name="show_sale_order_type" invisible="1"/>
<field name="business_category_id" attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}"/>
<field name="sale_order_type" attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('show_sale_order_type', '=', False)]}"/>
<field name="analytic_account_id" attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}"/>
</xpath>
</field>
@@ -8,6 +8,7 @@
<xpath expr="//group[field[@name='analytic_account_id']]" position="inside">
<field name="company_id" invisible="1"/>
<field name="use_sale_order_shipping"/>
<field name="use_sale_order_type"/>
</xpath>
</field>
</record>
@@ -34,7 +34,9 @@
<xpath expr="//field[@name='partner_id']" position="after">
<field name="company_id" invisible="1"/>
<field name="show_sale_order_shipping" invisible="1"/>
<field name="show_sale_order_type" invisible="1"/>
<field name="business_category_id"/>
<field name="sale_order_type" attrs="{'invisible': [('show_sale_order_type', '=', False)]}"/>
<field name="analytic_account_id" readonly="1"/>
<field name="payment_status" widget="badge" readonly="1"/>
<field name="is_frontend_order" readonly="1"/>
@@ -69,12 +71,14 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='user_id']" position="after">
<field name="business_category_id"/>
<field name="sale_order_type"/>
<field name="payment_status"/>
<field name="customer_wilayah_kecamatan_id"/>
<field name="customer_ref"/>
</xpath>
<xpath expr="//filter[@name='salesperson']" position="after">
<filter string="Business Category" name="group_by_business_category" context="{'group_by': 'business_category_id'}"/>
<filter string="Group by Tipe SO" name="group_by_sale_order_type" context="{'group_by': 'sale_order_type'}"/>
<filter string="Terbayar" name="payment_status_paid" domain="[('payment_status', '=', 'paid')]"/>
<filter string="Bayar Sebagian" name="payment_status_partial" domain="[('payment_status', '=', 'partial')]"/>
<filter string="Belum Bayar" name="payment_status_not_paid" domain="[('payment_status', '=', 'not_paid')]"/>
@@ -96,6 +100,7 @@
<xpath expr="//field[@name='partner_id']" position="after">
<field name="customer_wilayah_kecamatan_id" optional="show"/>
<field name="customer_ref" optional="show"/>
<field name="sale_order_type" optional="show"/>
<field name="payment_status" widget="badge" optional="show"/>
</xpath>
</field>
@@ -107,6 +112,7 @@
<field name="inherit_id" ref="sale.view_order_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='invoice_status']" position="after">
<field name="sale_order_type" optional="show"/>
<field name="payment_status" widget="badge" optional="show"/>
</xpath>
</field>
+21
View File
@@ -208,3 +208,24 @@ Formula:
- Updated commission payment journal entry to inherit Business Category and Analytic Account from Sales Order
- Added server-side authorization guard for commission payment (secure for external RPC)
- Added row-lock protection to prevent duplicate commission payment journals from concurrent requests
## Update Default Type Behavior (2026-04-23)
Perubahan terbaru menyelaraskan default komisi dengan `sale_order_type` dari modul `grt_sales_business_category`.
Default method:
- Sales Commission Method default untuk order baru sekarang `By Weight (Kg)`
Default berdasarkan `sale_order_type`:
- `reguler` -> commission aktif, method `By Weight (Kg)`
- `kering` -> commission aktif, method `By Weight (Kg)`
- `partus` -> commission aktif, method `By Weight (Kg)`
- `peralatan` -> commission nonaktif, method tetap default `By Weight (Kg)`
- `silase` -> commission nonaktif, method tetap default `By Weight (Kg)`
Catatan:
- default ini diterapkan saat create order dan saat `sale_order_type` diganti
- user masih bisa melakukan penyesuaian manual setelah default terisi jika proses bisnis memerlukannya
+1 -1
View File
@@ -9,7 +9,7 @@ Commission is calculated from sold quantity x configured commission weight per u
"author": "PT Gagak Rimang Teknologi",
"website": "https://rimang.id",
"category": "Sales/Sales",
"version": "14.0.1.0.0",
"version": "14.0.1.1.0",
"depends": [
"sale_management",
"account",
+51 -4
View File
@@ -5,9 +5,31 @@ from odoo.exceptions import AccessError, ValidationError
class SaleOrder(models.Model):
_inherit = "sale.order"
_SALE_ORDER_TYPE_COMMISSION_DEFAULTS = {
"reguler": {
"sales_commission_enabled": True,
"sales_commission_method": "weight",
},
"kering": {
"sales_commission_enabled": True,
"sales_commission_method": "weight",
},
"partus": {
"sales_commission_enabled": True,
"sales_commission_method": "weight",
},
"peralatan": {
"sales_commission_enabled": False,
"sales_commission_method": "weight",
},
"silase": {
"sales_commission_enabled": False,
"sales_commission_method": "weight",
},
}
def _default_sales_commission_method(self):
company_method = self.env.company.sales_commission_calculation_method or "weight"
return company_method if company_method in ("weight", "unit") else "weight"
return "weight"
customer_qr_ref = fields.Char(
string="Customer QR Reference",
@@ -175,6 +197,9 @@ class SaleOrder(models.Model):
)
if not order.sales_commission_method:
order.sales_commission_method = order._default_sales_commission_method()
default_vals = order._prepare_sale_order_type_commission_default_vals()
if default_vals:
order.with_context(skip_sale_order_type_commission_defaults=True).write(default_vals)
return orders
def write(self, vals):
@@ -183,12 +208,34 @@ class SaleOrder(models.Model):
for order in self:
if not order.sales_commission_payment_journal_id:
order.sales_commission_payment_journal_id = (
order.company_id.sales_commission_payment_journal_id
)
order.company_id.sales_commission_payment_journal_id
)
if order.sales_commission_method not in ("weight", "unit"):
order.sales_commission_method = order._default_sales_commission_method()
if not self.env.context.get("skip_sale_order_type_commission_defaults") and "sale_order_type" in vals:
for order in self:
default_vals = order._prepare_sale_order_type_commission_default_vals()
if default_vals:
order.with_context(skip_sale_order_type_commission_defaults=True).write(default_vals)
return res
@api.onchange("sale_order_type")
def _onchange_sale_order_type_commission_defaults(self):
for order in self:
default_vals = order._prepare_sale_order_type_commission_default_vals()
for field_name, value in default_vals.items():
order[field_name] = value
def _prepare_sale_order_type_commission_default_vals(self):
self.ensure_one()
defaults = self._SALE_ORDER_TYPE_COMMISSION_DEFAULTS.get(self.sale_order_type or "", {})
if not defaults:
return {}
return {
"sales_commission_enabled": defaults["sales_commission_enabled"],
"sales_commission_method": defaults.get("sales_commission_method", "weight"),
}
@api.depends(
"sales_commission_enabled",
"sales_commission_method",