Perbaikan grt_sales_business_category
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user