perubahan wilayah berdasarkan customer

This commit is contained in:
2026-04-07 20:32:51 +07:00
parent a8df8484b7
commit fad17dc403
12 changed files with 206 additions and 163 deletions
BIN
View File
Binary file not shown.
+13 -34
View File
@@ -102,11 +102,6 @@ class SalesBusinessCategoryApiController(http.Controller):
offset = 0
return limit, offset
def _get_sale_order_type_items(self):
field = request.env["sale.order"]._fields.get("sale_order_type")
selection = field.selection if field else []
return [{"value": value, "label": label} for value, label in selection]
def _get_product_price(self, order, product, qty):
pricelist = order.pricelist_id
if pricelist:
@@ -187,16 +182,6 @@ class SalesBusinessCategoryApiController(http.Controller):
return default_note
return "%s\n\n%s" % (default_note, payload_note)
def _get_frontend_kecamatan(self, payload):
wilayah_id = int(payload.get("wilayah_id") or payload.get("kecamatan_id") or 0)
if not wilayah_id:
raise ValueError("wilayah_id is required")
kecamatan = request.env["wilayah.kecamatan"].browse(wilayah_id).exists()
if not kecamatan:
raise ValueError(_("Wilayah %s was not found.") % wilayah_id)
return kecamatan
def _create_draft_order_from_payload(self, payload, default_note=False):
customer_qr_ref = payload.get("customer_qr_ref")
partner_id = int(payload.get("partner_id") or 0)
@@ -211,20 +196,14 @@ class SalesBusinessCategoryApiController(http.Controller):
commitment_date = payload.get("commitment_date")
payment_term_id = int(payload.get("payment_term_id") or 0)
sale_order_type = payload.get("sale_order_type")
if not commitment_date:
raise ValueError("commitment_date is required")
if not payment_term_id:
raise ValueError("payment_term_id is required")
if sale_order_type:
allowed_types = {item["value"] for item in self._get_sale_order_type_items()}
if sale_order_type not in allowed_types:
raise ValueError("sale_order_type must be one of: %s" % ", ".join(sorted(allowed_types)))
payment_term = request.env["account.payment.term"].browse(payment_term_id).exists()
if not payment_term:
raise ValueError(_("Payment term %s was not found.") % payment_term_id)
frontend_kecamatan = self._get_frontend_kecamatan(payload)
order_line_payloads = payload.get("order_line") or []
if not order_line_payloads:
@@ -239,11 +218,7 @@ class SalesBusinessCategoryApiController(http.Controller):
"commitment_date": commitment_date,
"note": self._compose_terms_and_conditions(payload.get("note"), default_note),
"is_frontend_order": True,
"frontend_kecamatan_id": frontend_kecamatan.id,
}
if sale_order_type:
order_vals["sale_order_type"] = sale_order_type
optional_int_fields = ["team_id", "business_category_id"]
for field_name in optional_int_fields:
if payload.get(field_name):
@@ -253,6 +228,14 @@ class SalesBusinessCategoryApiController(http.Controller):
line_commands = [self._prepare_order_line_command(order, line) for line in order_line_payloads]
order.write({"order_line": line_commands})
order._apply_frontend_shipping_rule()
shipping_rule = request.env["sale.frontend.shipping.rule"].search(
[
("active", "=", True),
("company_id", "=", order.company_id.id),
("wilayah_kecamatan_id", "=", order.frontend_kecamatan_id.id),
],
limit=1,
)
return self._success(
{
@@ -265,6 +248,9 @@ class SalesBusinessCategoryApiController(http.Controller):
"is_frontend_order": order.is_frontend_order,
"wilayah_id": order.frontend_kecamatan_id.id,
"wilayah_name": order.frontend_kecamatan_id.name,
"shipping_product_id": shipping_rule.shipping_product_id.id,
"shipping_product_name": shipping_rule.shipping_product_id.display_name,
"shipping_price_per_kg": shipping_rule.shipping_price_per_kg,
},
"Draft sales order created",
)
@@ -330,14 +316,6 @@ class SalesBusinessCategoryApiController(http.Controller):
_logger.exception("Failed to fetch payment terms")
return self._error(str(exc))
@http.route("/api/sales/order-types", type="json", auth="user", methods=["GET"], csrf=False)
def get_order_types(self, **kwargs):
try:
return self._success({"items": self._get_sale_order_type_items()})
except Exception as exc:
_logger.exception("Failed to fetch sale order types")
return self._error(str(exc))
@http.route("/api/sales/customer-qr-by-id", type="json", auth="user", methods=["POST"], csrf=False)
def get_customer_qr_by_id(self, **kwargs):
try:
@@ -415,6 +393,8 @@ class SalesBusinessCategoryApiController(http.Controller):
"phone": partner.phone,
"mobile": partner.mobile,
"email": partner.email,
"shipping_wilayah_id": partner.shipping_wilayah_kecamatan_id.id,
"shipping_wilayah_name": partner.shipping_wilayah_kecamatan_id.name,
"payment_term_id": partner.property_payment_term_id.id,
"payment_term_name": partner.property_payment_term_id.name,
}
@@ -475,7 +455,6 @@ class SalesBusinessCategoryApiController(http.Controller):
"amount_total": order.amount_total,
"state": order.state,
"approval_state": order.approval_state,
"sale_order_type": order.sale_order_type,
}
for order in orders
]
+38 -49
View File
@@ -20,7 +20,6 @@ Fokus dokumen ini:
| `/api/sales/authenticate` | `POST` | public | login dan membuat session Odoo |
| `/api/sales/products` | `POST` | user | list product dan price |
| `/api/sales/payment-terms` | `POST` | user | list Payment Terms |
| `/api/sales/order-types` | `GET` | user | list type Sales Order |
| `/api/sales/customer-qr-by-id` | `POST` | user | ambil `customer_qr_ref` dari `customer_id` |
| `/api/sales/customer-qr-payload-by-id` | `POST` | user | ambil payload QR siap render dari `customer_id` |
| `/api/sales/customer-qr-payload-by-ref` | `POST` | user | ambil payload QR siap render dari `customer_qr_ref` |
@@ -450,6 +449,8 @@ Mengambil detail customer berdasarkan `customer_qr_ref`.
"phone": "08123456789",
"mobile": "08123456789",
"email": "customer@example.com",
"shipping_wilayah_id": 15,
"shipping_wilayah_name": "Kecamatan A",
"payment_term_id": 2,
"payment_term_name": "30 Days"
}
@@ -501,30 +502,6 @@ Mengambil nilai dan aging hutang/piutang customer berdasarkan `customer_qr_ref`.
}
```
### `GET /api/sales/order-types`
Mengambil daftar type Sales Order dari backend.
#### Response
```json
{
"status": "success",
"data": {
"items": [
{
"value": "kering",
"label": "Kering"
},
{
"value": "basah",
"label": "Basah"
}
]
}
}
```
## Konfigurasi Rule Ongkir Frontend
Sebelum frontend membuat Sales Order yang memakai auto biaya pengiriman, backend harus menyiapkan rule ongkir terlebih dahulu.
@@ -535,15 +512,15 @@ Setup dilakukan di menu:
Setiap rule minimal berisi:
- `Team Sales`
- `Wilayah` pada level `wilayah.kecamatan`
- `Shipping Product`
- `Produk Ongkir Wilayah`
- `Tarif per Kg`
Perilaku backend:
- backend hanya menambahkan ongkir otomatis untuk Sales Order yang dibuat dari endpoint frontend
- backend mencari rule berdasarkan kombinasi `team_id + wilayah_id + company`
- backend mengambil `Wilayah Ongkir` dari customer
- backend mencari rule berdasarkan kombinasi `wilayah_id + company`
- jika rule ditemukan, backend menambahkan 1 line produk ongkir otomatis
- backend menghitung total berat dari semua line produk: `qty x berat produk`
- nominal ongkir dihitung dengan rumus `total_kg x tarif_per_kg`
@@ -553,9 +530,24 @@ Perilaku backend:
Catatan untuk tim frontend:
- `wilayah_id` bukan diambil dari alamat customer
- `wilayah_id` merepresentasikan wilayah ketua kelompok petani/customer yang sedang membuat transaksi dari frontend
- frontend harus mengirim `wilayah_id` secara eksplisit pada semua endpoint create order
- frontend tidak perlu mengirim `wilayah_id`
- backend membaca wilayah ongkir langsung dari customer
- customer harus sudah dilengkapi field `Wilayah Ongkir`
## Monitoring Admin Sales
Pada halaman list view Sales Order, admin sales sekarang dapat memakai informasi customer berikut untuk validasi prioritas:
- `Wilayah Customer`
- `Referensi Customer`
Kegunaan operasional:
- gunakan `Group By Wilayah Customer` untuk melihat konsentrasi order per wilayah
- gunakan kolom `Referensi Customer` untuk sorting manual
- referensi customer di atas `4000` dipakai sebagai penanda pelanggan mitra, bukan perorangan
Field ini ditarik langsung dari data customer pada Sales Order, sehingga admin sales tidak perlu membuka form customer satu per satu saat melakukan validasi prioritas.
### `POST /api/sales/orders-by-qr`
@@ -587,8 +579,7 @@ Mengambil list Sales Order berdasarkan `customer_qr_ref`.
"commitment_date": "2026-03-15 10:00:00",
"amount_total": 3000000.0,
"state": "sale",
"approval_state": "approved",
"sale_order_type": "kering"
"approval_state": "approved"
}
],
"count": 1
@@ -614,11 +605,9 @@ Minimal kirim `partner_id` atau `customer_qr_ref`.
"partner_id": 45,
"customer_qr_ref": "CUSTQR2603-000001",
"commitment_date": "2026-03-15 10:00:00",
"sale_order_type": "kering",
"payment_term_id": 4,
"team_id": 3,
"business_category_id": 2,
"wilayah_id": 15,
"note": "Kirim pagi",
"order_line": [
{
@@ -664,7 +653,10 @@ Minimal kirim `partner_id` atau `customer_qr_ref`.
"terms_and_conditions": "Kirim pagi",
"is_frontend_order": true,
"wilayah_id": 15,
"wilayah_name": "Kecamatan A"
"wilayah_name": "Kecamatan A",
"shipping_product_id": 2001,
"shipping_product_name": "Biaya Angkutan Kecamatan A",
"shipping_price_per_kg": 1500.0
}
}
```
@@ -673,12 +665,11 @@ Minimal kirim `partner_id` atau `customer_qr_ref`.
- `partner_id` atau `customer_qr_ref` wajib ada
- jika keduanya dikirim, nilainya harus saling cocok
- `sale_order_type` bila dikirim hanya boleh `kering` atau `basah`
- `payment_term_id` harus valid
- `wilayah_id` wajib ada dan harus valid pada master `wilayah.kecamatan`
- customer wajib punya `Wilayah Ongkir`
- `order_line` wajib minimal 1 item
- setiap line wajib punya `product_id` dan `product_uom_qty > 0`
- backend otomatis menambahkan line biaya pengiriman untuk order frontend berdasarkan rule `team_id + wilayah_id`
- backend otomatis menambahkan line biaya pengiriman untuk order frontend berdasarkan rule wilayah customer
- backend menghitung nominal line ongkir dari `total berat produk x tarif per kg` pada rule
- jika rule ongkir frontend tidak ditemukan, pembuatan order akan ditolak
@@ -709,11 +700,9 @@ Panduan pemakaian:
"partner_id": 45,
"customer_qr_ref": "CUSTQR2603-000001",
"commitment_date": "2026-03-15 10:00:00",
"sale_order_type": "kering",
"payment_term_id": 4,
"team_id": 3,
"business_category_id": 2,
"wilayah_id": 15,
"note": "Kirim pagi",
"order_line": [
{
@@ -741,7 +730,10 @@ Panduan pemakaian:
"terms_and_conditions": "sale order ini jenis bon kering\n\nKirim pagi",
"is_frontend_order": true,
"wilayah_id": 15,
"wilayah_name": "Kecamatan A"
"wilayah_name": "Kecamatan A",
"shipping_product_id": 2001,
"shipping_product_name": "Biaya Angkutan Kecamatan A",
"shipping_price_per_kg": 1500.0
}
}
```
@@ -751,7 +743,7 @@ Panduan pemakaian:
Urutan implementasi yang disarankan di Vue:
1. login ke `/api/sales/authenticate`
2. ambil master data: `products`, `payment-terms`, `order-types`
2. ambil master data: `products`, `payment-terms`
3. jika frontend punya `customer_id`, panggil `customer-qr-payload-by-id` untuk membentuk payload QR
4. jika frontend sudah punya `customer_qr_ref`, panggil `customer-qr-payload-by-ref`
5. pilih `format="ref"` jika QR hanya menyimpan reference string
@@ -764,7 +756,7 @@ Urutan implementasi yang disarankan di Vue:
12. gunakan `/api/sales/draft-order/bon-kering` untuk bon kering
13. gunakan `/api/sales/draft-order/bon-partus` untuk bon partus
14. gunakan `/api/sales/draft-order/bon-reguler` untuk reguler
15. kirim `wilayah_id` untuk menentukan rule ongkir frontend
15. pastikan customer yang dipilih sudah memiliki `Wilayah Ongkir`
16. gunakan `/api/sales/draft-order` jika frontend ingin mengirim Terms and Conditions sendiri tanpa default jenis bon
## Contoh Alur Request Lengkap
@@ -784,8 +776,6 @@ await postJsonRpc(`${baseUrl}/api/sales/products`, {
await postJsonRpc(`${baseUrl}/api/sales/payment-terms`, {});
await getJsonSession(`${baseUrl}/api/sales/order-types`);
await postJsonRpc(`${baseUrl}/api/sales/customer-qr-payload-by-id`, {
customer_id: 45,
format: "ref",
@@ -872,8 +862,8 @@ export async function getJsonSession(url) {
- form order sebaiknya memakai dynamic rows agar multi-item nyaman dipakai
- tampilkan warning jika `receivable_total` atau aging customer tinggi
- pilih endpoint draft order sesuai jenis transaksi yang dipilih user di frontend
- `wilayah_id` wajib dikirim untuk semua endpoint create draft order frontend
- backend akan lookup rule ongkir frontend berdasarkan kombinasi `team_id` dan `wilayah_id`
- frontend tidak perlu mengirim `wilayah_id` untuk create draft order
- backend akan lookup rule ongkir frontend berdasarkan wilayah customer
- backend menghitung total berat dari seluruh line produk non-ongkir menggunakan field `weight` pada produk
- jika rule cocok, backend otomatis menambah 1 line produk ongkir dengan nominal `total_kg x tarif_per_kg`
- semua produk yang dipakai untuk perhitungan ongkir berbasis kilogram harus memiliki field `weight` yang terisi benar
@@ -894,7 +884,6 @@ Sudah tersedia:
- `POST /api/sales/authenticate`
- `POST /api/sales/products`
- `POST /api/sales/payment-terms`
- `GET /api/sales/order-types`
- `POST /api/sales/customer-qr-by-id`
- `POST /api/sales/customer-qr-payload-by-id`
- `POST /api/sales/customer-qr-payload-by-ref`
@@ -8,9 +8,10 @@ Pastikan data berikut sudah tersedia:
- modul `grt_sales_business_category` sudah di-upgrade
- ada `Team Sales` yang valid
- ada `wilayah.kecamatan` yang akan dipakai sebagai wilayah transaksi frontend
- ada `wilayah.kecamatan` yang akan dipakai sebagai wilayah customer
- ada produk ongkir yang `sale_ok = True`
- ada customer yang bisa dipakai membuat Sales Order
- field `Wilayah Ongkir` pada customer sudah diisi
## Setup Rule
@@ -21,11 +22,14 @@ Masuk ke menu:
Buat 1 rule contoh:
- `Company`: company aktif
- `Team Sales`: team yang dipakai frontend
- `Wilayah`: kecamatan yang mewakili kelompok petani/customer
- `Shipping Product`: produk biaya pengiriman
- `Wilayah`: kecamatan customer
- `Produk Ongkir Wilayah`: produk biaya pengiriman
- `Tarif per Kg`: misalnya `1500`
Lalu pada customer yang akan dipakai transaksi:
- isi `Wilayah Ongkir` dengan kecamatan yang sama seperti pada rule
## Payload Contoh
Endpoint reguler:
@@ -36,11 +40,9 @@ Endpoint reguler:
"partner_id": 45,
"customer_qr_ref": "CUSTQR2603-000001",
"commitment_date": "2026-04-04 10:00:00",
"sale_order_type": "kering",
"payment_term_id": 4,
"team_id": 3,
"business_category_id": 2,
"wilayah_id": 15,
"note": "Uji auto ongkir frontend",
"order_line": [
{
@@ -64,11 +66,9 @@ Endpoint bon kering:
"params": {
"partner_id": 45,
"commitment_date": "2026-04-04 10:00:00",
"sale_order_type": "kering",
"payment_term_id": 4,
"team_id": 3,
"business_category_id": 2,
"wilayah_id": 15,
"note": "Uji bon kering dengan ongkir",
"order_line": [
{
@@ -87,7 +87,7 @@ Endpoint bon kering:
curl -X POST http://localhost:8070/api/sales/draft-order/bon-kering ^
-H "Content-Type: application/json" ^
-b "session_id=ISI_SESSION_ID" ^
-d "{\"params\":{\"partner_id\":45,\"commitment_date\":\"2026-04-04 10:00:00\",\"sale_order_type\":\"kering\",\"payment_term_id\":4,\"team_id\":3,\"business_category_id\":2,\"wilayah_id\":15,\"note\":\"Uji bon kering dengan ongkir\",\"order_line\":[{\"product_id\":1001,\"product_uom_qty\":1,\"price_unit\":12000}]}}"
-d "{\"params\":{\"partner_id\":45,\"commitment_date\":\"2026-04-04 10:00:00\",\"payment_term_id\":4,\"team_id\":3,\"business_category_id\":2,\"note\":\"Uji bon kering dengan ongkir\",\"order_line\":[{\"product_id\":1001,\"product_uom_qty\":1,\"price_unit\":12000}]}}"
```
## Script Test Otomatis
@@ -109,7 +109,6 @@ python test_frontend_shipping_rule_api.py ^
--payment-term-id 4 ^
--team-id 3 ^
--business-category-id 2 ^
--wilayah-id 15 ^
--product-id 1001
```
@@ -119,7 +118,7 @@ Saat request berhasil:
- Sales Order dibuat dalam state `draft`
- field `is_frontend_order` bernilai `True`
- field `frontend_kecamatan_id` terisi sesuai `wilayah_id`
- field `frontend_kecamatan_id` terisi sesuai `Wilayah Ongkir` pada customer
- order line asli dari frontend tetap ada
- sistem otomatis menambahkan 1 line produk ongkir
- nominal ongkir dihitung dari `total berat produk x tarif per kg`
@@ -127,9 +126,8 @@ Saat request berhasil:
## Kasus Error Yang Perlu Diuji
- `wilayah_id` tidak dikirim
- `wilayah_id` tidak valid
- kombinasi `team_id + wilayah_id` belum punya rule
- customer belum punya `Wilayah Ongkir`
- `Wilayah Ongkir` customer belum punya rule
- produk ongkir pada rule tidak valid atau `sale_ok = False`
- `Tarif per Kg` pada rule masih `0`
- semua produk pada order belum punya berat sehingga total kilogram = `0`
@@ -142,3 +140,9 @@ Setelah order berhasil dibuat, verifikasi di form Sales Order:
- `Wilayah Frontend` terisi
- ada line biaya pengiriman otomatis
- `Team Sales` sesuai payload frontend
Tambahan untuk admin sales di list view:
- kolom `Wilayah Customer` tampil untuk kebutuhan grouping prioritas
- kolom `Referensi Customer` tampil untuk kebutuhan sorting dan validasi
- referensi customer di atas `4000` menandakan pelanggan mitra
@@ -5,7 +5,7 @@ from odoo.exceptions import ValidationError
class FrontendShippingRule(models.Model):
_name = "sale.frontend.shipping.rule"
_description = "Frontend Shipping Rule"
_order = "team_id, wilayah_kecamatan_id, id"
_order = "wilayah_kecamatan_id, id"
name = fields.Char(string="Rule Name", compute="_compute_name", store=True)
active = fields.Boolean(default=True)
@@ -15,20 +15,6 @@ class FrontendShippingRule(models.Model):
required=True,
default=lambda self: self.env.company,
)
business_category_id = fields.Many2one(
"crm.business.category",
string="Business Category",
related="team_id.business_category_id",
store=True,
readonly=True,
)
team_id = fields.Many2one(
"crm.team",
string="Team Sales",
required=True,
domain="[('company_id', '=', company_id)]",
ondelete="restrict",
)
wilayah_kecamatan_id = fields.Many2one(
"wilayah.kecamatan",
string="Wilayah",
@@ -37,7 +23,7 @@ class FrontendShippingRule(models.Model):
)
shipping_product_id = fields.Many2one(
"product.product",
string="Shipping Product",
string="Produk Ongkir Wilayah",
required=True,
domain="[('sale_ok', '=', True)]",
ondelete="restrict",
@@ -53,22 +39,24 @@ class FrontendShippingRule(models.Model):
_sql_constraints = [
(
"sale_frontend_shipping_rule_unique",
"unique(company_id, team_id, wilayah_kecamatan_id)",
"Frontend shipping rule for this company, team sales, and wilayah already exists.",
"unique(company_id, wilayah_kecamatan_id)",
"Frontend shipping rule for this company and wilayah already exists.",
),
]
@api.depends("team_id", "wilayah_kecamatan_id", "shipping_product_id", "shipping_price_per_kg")
@api.depends("wilayah_kecamatan_id", "shipping_product_id", "shipping_price_per_kg")
def _compute_name(self):
for rule in self:
parts = [rule.team_id.name, rule.wilayah_kecamatan_id.name, rule.shipping_product_id.display_name]
parts = [
rule.wilayah_kecamatan_id.name,
rule.shipping_product_id.display_name,
"%.2f / Kg" % rule.shipping_price_per_kg if rule.shipping_price_per_kg else False,
]
rule.name = " / ".join([part for part in parts if part])
@api.constrains("company_id", "team_id", "shipping_product_id", "shipping_price_per_kg")
@api.constrains("company_id", "shipping_product_id", "shipping_price_per_kg")
def _check_company_consistency(self):
for rule in self:
if rule.team_id.company_id and rule.team_id.company_id != rule.company_id:
raise ValidationError(_("Team Sales company must match the shipping rule company."))
if rule.shipping_product_id.company_id and rule.shipping_product_id.company_id != rule.company_id:
raise ValidationError(_("Shipping product company must match the shipping rule company."))
if rule.shipping_price_per_kg <= 0:
@@ -28,6 +28,12 @@ class ResPartner(models.Model):
index=True,
readonly=True,
)
shipping_wilayah_kecamatan_id = fields.Many2one(
"wilayah.kecamatan",
string="Wilayah Ongkir",
ondelete="restrict",
help="Wilayah customer yang dipakai untuk menentukan rule tarif ongkos kirim frontend.",
)
customer_segment_id = fields.Many2one(
"customer.behavior.segment",
string="Customer Segment",
@@ -5,6 +5,8 @@ from odoo.exceptions import UserError, ValidationError
class SaleOrder(models.Model):
_inherit = "sale.order"
_FRONTEND_SHIPPING_RECALC_FIELDS = {"partner_id", "order_line", "is_frontend_order"}
is_frontend_order = fields.Boolean(
string="Frontend Order",
copy=False,
@@ -16,11 +18,18 @@ class SaleOrder(models.Model):
copy=False,
tracking=True,
)
sale_order_type = fields.Selection(
[("kering", "Kering"), ("basah", "Basah")],
string="Sale Order Type",
tracking=True,
customer_qr_ref = fields.Char(
string="Referensi Customer",
related="partner_id.commercial_partner_id.customer_qr_ref",
store=True,
readonly=True,
)
customer_wilayah_kecamatan_id = fields.Many2one(
"wilayah.kecamatan",
string="Wilayah Customer",
related="partner_id.commercial_partner_id.shipping_wilayah_kecamatan_id",
store=True,
readonly=True,
)
def _is_sales_admin_user(self):
@@ -130,6 +139,27 @@ class SaleOrder(models.Model):
if order.team_id and order.team_id.business_category_id:
order.business_category_id = order.team_id.business_category_id
@api.onchange("partner_id", "is_frontend_order", "order_line")
def _onchange_frontend_shipping_preview(self):
for order in self:
if order.state not in ("draft", "sent"):
continue
shipping_lines = order.order_line.filtered("is_frontend_shipping_line")
if not order.is_frontend_order:
order.frontend_kecamatan_id = False
order.order_line -= shipping_lines
continue
try:
order.with_context(skip_frontend_shipping_recalc=True)._apply_frontend_shipping_rule()
except UserError:
# Keep editing flow smooth: clear stale auto shipping line until
# required shipping configuration/data becomes valid.
partner = order.partner_id.commercial_partner_id
order.frontend_kecamatan_id = partner.shipping_wilayah_kecamatan_id.id
order.order_line -= shipping_lines
@api.model_create_multi
def create(self, vals_list):
orders = super().create([self._prepare_business_category_vals(vals) for vals in vals_list])
@@ -137,7 +167,19 @@ class SaleOrder(models.Model):
def write(self, vals):
vals = self._prepare_business_category_vals(vals)
return super().write(vals)
result = super().write(vals)
# Recalculate frontend shipping whenever draft quotation data that affects
# shipping calculation is edited from backend UI.
if not self.env.context.get("skip_frontend_shipping_recalc") and self._needs_frontend_shipping_recalc(vals):
orders_to_recalc = self.filtered(lambda order: order.state in ("draft", "sent") and order.is_frontend_order)
if orders_to_recalc:
orders_to_recalc._apply_frontend_shipping_rule()
return result
def _needs_frontend_shipping_recalc(self, vals):
return bool(self._FRONTEND_SHIPPING_RECALC_FIELDS.intersection(set(vals.keys())))
def _prepare_business_category_vals(self, vals):
vals = dict(vals)
@@ -172,24 +214,27 @@ class SaleOrder(models.Model):
for order in self:
if not order.is_frontend_order:
continue
if not order.team_id:
raise UserError(_("Team Sales is required for frontend shipping rule."))
if not order.frontend_kecamatan_id:
raise UserError(_("Wilayah frontend is required for frontend shipping rule."))
partner = order.partner_id.commercial_partner_id
if not partner.shipping_wilayah_kecamatan_id:
raise UserError(
_("Customer '%s' must have Wilayah Ongkir before frontend shipping cost can be calculated.")
% partner.display_name
)
order.frontend_kecamatan_id = partner.shipping_wilayah_kecamatan_id.id
rule = self.env["sale.frontend.shipping.rule"].search(
[
("active", "=", True),
("company_id", "=", order.company_id.id),
("team_id", "=", order.team_id.id),
("wilayah_kecamatan_id", "=", order.frontend_kecamatan_id.id),
("wilayah_kecamatan_id", "=", partner.shipping_wilayah_kecamatan_id.id),
],
limit=1,
)
if not rule:
raise UserError(
_("No frontend shipping rule found for Team Sales '%s' and Wilayah '%s'.")
% (order.team_id.name, order.frontend_kecamatan_id.name)
_("No frontend shipping rule found for Wilayah '%s'.")
% partner.shipping_wilayah_kecamatan_id.name
)
product = rule.shipping_product_id
@@ -199,8 +244,8 @@ class SaleOrder(models.Model):
)
if rule.shipping_price_per_kg <= 0:
raise UserError(
_("Shipping rule for Team Sales '%s' and Wilayah '%s' must define Tarif per Kg greater than 0.")
% (order.team_id.name, order.frontend_kecamatan_id.name)
_("Shipping rule for Wilayah '%s' must define Tarif per Kg greater than 0.")
% partner.shipping_wilayah_kecamatan_id.name
)
total_weight = order._get_frontend_shipping_total_weight()
@@ -234,18 +279,11 @@ class SaleOrder(models.Model):
primary_line = existing_lines[0]
update_vals = dict(line_vals)
update_vals.pop("order_id", None)
primary_line.write(update_vals)
primary_line.with_context(skip_frontend_shipping_recalc=True).write(update_vals)
if len(existing_lines) > 1:
(existing_lines - primary_line).unlink()
(existing_lines - primary_line).with_context(skip_frontend_shipping_recalc=True).unlink()
else:
self.env["sale.order.line"].create(line_vals)
@api.constrains("sale_order_type")
def _check_sale_order_type(self):
allowed = {"kering", "basah"}
for order in self:
if order.sale_order_type and order.sale_order_type not in allowed:
raise ValidationError(_("Sale Order Type must be either Kering or Basah."))
self.env["sale.order.line"].with_context(skip_frontend_shipping_recalc=True).create(line_vals)
@api.constrains("business_category_id", "team_id", "company_id", "analytic_account_id")
def _check_business_category_team(self):
@@ -425,3 +463,32 @@ class SaleOrderLine(models.Model):
if analytic:
vals["analytic_account_id"] = analytic.id
return vals
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines._recalculate_frontend_shipping_from_lines()
return lines
def write(self, vals):
result = super().write(vals)
self._recalculate_frontend_shipping_from_lines()
return result
def unlink(self):
orders_to_recalc = self.mapped("order_id").filtered(
lambda order: order.state in ("draft", "sent") and order.is_frontend_order
)
result = super().unlink()
if not self.env.context.get("skip_frontend_shipping_recalc") and orders_to_recalc:
orders_to_recalc._apply_frontend_shipping_rule()
return result
def _recalculate_frontend_shipping_from_lines(self):
if self.env.context.get("skip_frontend_shipping_recalc"):
return
orders_to_recalc = self.mapped("order_id").filtered(
lambda order: order.state in ("draft", "sent") and order.is_frontend_order
)
if orders_to_recalc:
orders_to_recalc._apply_frontend_shipping_rule()
@@ -7,8 +7,6 @@
<tree>
<field name="active"/>
<field name="company_id"/>
<field name="business_category_id"/>
<field name="team_id"/>
<field name="wilayah_kecamatan_id"/>
<field name="shipping_product_id"/>
<field name="shipping_price_per_kg"/>
@@ -25,8 +23,6 @@
<group>
<field name="active"/>
<field name="company_id"/>
<field name="business_category_id" readonly="1"/>
<field name="team_id"/>
<field name="wilayah_kecamatan_id"/>
<field name="shipping_product_id"/>
<field name="shipping_price_per_kg"/>
@@ -41,18 +37,15 @@
<field name="model">sale.frontend.shipping.rule</field>
<field name="arch" type="xml">
<search>
<field name="team_id"/>
<field name="wilayah_kecamatan_id"/>
<field name="shipping_product_id"/>
<field name="shipping_price_per_kg"/>
<field name="business_category_id"/>
<field name="company_id"/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Company" name="group_company" context="{'group_by': 'company_id'}"/>
<filter string="Business Category" name="group_business_category" context="{'group_by': 'business_category_id'}"/>
<filter string="Team Sales" name="group_team" context="{'group_by': 'team_id'}"/>
<filter string="Wilayah" name="group_wilayah" context="{'group_by': 'wilayah_kecamatan_id'}"/>
<filter string="Produk Ongkir" name="group_shipping_product" context="{'group_by': 'shipping_product_id'}"/>
</group>
</search>
</field>
@@ -9,6 +9,7 @@
<group string="Customer QR">
<group>
<field name="customer_qr_ref" readonly="1"/>
<field name="shipping_wilayah_kecamatan_id"/>
</group>
</group>
</xpath>
@@ -33,7 +33,6 @@
<xpath expr="//field[@name='partner_id']" position="after">
<field name="company_id" invisible="1"/>
<field name="sale_order_type"/>
<field name="business_category_id"/>
<field name="analytic_account_id" readonly="1"/>
<field name="is_frontend_order" readonly="1"/>
@@ -67,16 +66,29 @@
<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="customer_wilayah_kecamatan_id"/>
<field name="customer_qr_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="Team Sales" name="group_by_sales_team" context="{'group_by': 'team_id'}"/>
<filter string="Sale Order Type" name="group_by_sale_order_type" context="{'group_by': 'sale_order_type'}"/>
<filter string="Wilayah Customer" name="group_by_customer_wilayah" context="{'group_by': 'customer_wilayah_kecamatan_id'}"/>
<filter string="Frontend Orders" name="frontend_orders" domain="[('is_frontend_order', '=', True)]"/>
<filter string="Waiting Sales Leader" name="waiting_sales_leader" domain="[('approval_state', '=', 'waiting_sales_leader')]"/>
<filter string="Waiting Accounting" name="waiting_accounting" domain="[('approval_state', '=', 'waiting_accounting')]"/>
</xpath>
</field>
</record>
<record id="view_quotation_tree_customer_priority" model="ir.ui.view">
<field name="name">sale.order.tree.customer.priority</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_quotation_tree_with_onboarding"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="customer_wilayah_kecamatan_id" optional="show"/>
<field name="customer_qr_ref" optional="show"/>
</xpath>
</field>
</record>
</odoo>
@@ -5,6 +5,14 @@ class ResPartner(models.Model):
_inherit = 'res.partner'
nik = fields.Char(string='NIK')
partner_gid = fields.Char(
string='Company database ID',
help='Compatibility field for legacy partner views that still reference partner_gid.',
)
additional_info = fields.Text(
string='Additional Info',
help='Compatibility field for legacy partner views that still reference additional_info.',
)
class StockPickikngSapiInherit(models.Model):
_inherit = 'stock.move'
@@ -14,4 +22,4 @@ class StockPickikngSapiInherit(models.Model):
class StockPickikngInherit(models.Model):
_inherit = 'stock.picking'
tipe_id = fields.Many2one('master.tipe.sapi', 'Tipe Sapi')
tipe_id = fields.Many2one('master.tipe.sapi', 'Tipe Sapi')
+1 -5
View File
@@ -14,7 +14,6 @@ python test_frontend_shipping_rule_api.py ^
--payment-term-id 4 ^
--team-id 3 ^
--business-category-id 2 ^
--wilayah-id 15 ^
--product-id 1001
"""
@@ -35,11 +34,9 @@ def parse_args():
parser.add_argument("--partner-id", type=int, required=True)
parser.add_argument("--customer-qr-ref")
parser.add_argument("--commitment-date", default="2026-04-04 10:00:00")
parser.add_argument("--sale-order-type", default="kering")
parser.add_argument("--payment-term-id", type=int, required=True)
parser.add_argument("--team-id", type=int, required=True)
parser.add_argument("--business-category-id", type=int, required=True)
parser.add_argument("--wilayah-id", type=int, required=True)
parser.add_argument("--product-id", type=int, required=True)
parser.add_argument("--qty", type=float, default=1.0)
parser.add_argument("--price-unit", type=float)
@@ -80,11 +77,9 @@ def create_order(session, base_url, endpoint, args):
params = {
"partner_id": args.partner_id,
"commitment_date": args.commitment_date,
"sale_order_type": args.sale_order_type,
"payment_term_id": args.payment_term_id,
"team_id": args.team_id,
"business_category_id": args.business_category_id,
"wilayah_id": args.wilayah_id,
"note": args.note,
"order_line": [order_line],
}
@@ -139,6 +134,7 @@ def main():
print("is_frontend_order :", data.get("is_frontend_order"))
print("wilayah_id :", data.get("wilayah_id"))
print("wilayah_name :", data.get("wilayah_name"))
print("shipping_product :", data.get("shipping_product_name"))
print("terms :", data.get("terms_and_conditions"))
except Exception as exc: