perubahan wilayah berdasarkan customer
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user