summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorit-fixcomart <it@fixcomart.co.id>2025-07-28 15:09:55 +0700
committerit-fixcomart <it@fixcomart.co.id>2025-07-28 15:09:55 +0700
commitd15ce4e186e2b77f01e8dfd03886298cc733d4c1 (patch)
tree1b32a4c29c4fcea85070fcecb5b77a7d55d30029
parentdeba962d7368a5c4e30441b5a640102608e3dde6 (diff)
parent36a53535dbdc5777266fd9276b4c557259dab6be (diff)
<hafid> merging odoo-backup
-rw-r--r--ab_openstreetmap/__init__.py0
-rw-r--r--ab_openstreetmap/__manifest__.py17
-rw-r--r--ab_openstreetmap/static/src/js/googlemap_widget.js133
-rw-r--r--ab_openstreetmap/static/src/xml/googlemap_template.xml7
-rw-r--r--ab_openstreetmap/views/templates.xml9
-rw-r--r--indoteknik_api/controllers/api_v1/cart.py35
-rw-r--r--indoteknik_api/controllers/api_v1/partner.py186
-rw-r--r--indoteknik_api/controllers/api_v1/product.py114
-rw-r--r--indoteknik_api/controllers/api_v1/sale_order.py4
-rw-r--r--indoteknik_api/controllers/api_v1/state.py10
-rw-r--r--indoteknik_api/controllers/api_v1/stock_picking.py174
-rw-r--r--indoteknik_api/models/sale_order.py26
-rwxr-xr-xindoteknik_custom/__manifest__.py7
-rwxr-xr-xindoteknik_custom/models/__init__.py6
-rw-r--r--indoteknik_custom/models/account_move.py227
-rw-r--r--indoteknik_custom/models/approval_payment_term.py187
-rw-r--r--indoteknik_custom/models/automatic_purchase.py2
-rw-r--r--indoteknik_custom/models/commision.py82
-rw-r--r--indoteknik_custom/models/coretax_fatur.py90
-rw-r--r--indoteknik_custom/models/dunning_run.py20
-rw-r--r--indoteknik_custom/models/invoice_reklas.py116
-rw-r--r--indoteknik_custom/models/invoice_reklas_penjualan.py60
-rw-r--r--indoteknik_custom/models/mail_mail.py19
-rw-r--r--indoteknik_custom/models/manufacturing.py3
-rw-r--r--indoteknik_custom/models/mrp_production.py7
-rw-r--r--indoteknik_custom/models/patch/__init__.py1
-rw-r--r--indoteknik_custom/models/patch/http_override.py45
-rwxr-xr-xindoteknik_custom/models/product_template.py5
-rwxr-xr-xindoteknik_custom/models/purchase_order.py168
-rw-r--r--indoteknik_custom/models/purchase_order_sales_match.py14
-rw-r--r--indoteknik_custom/models/purchasing_job.py57
-rw-r--r--indoteknik_custom/models/refund_sale_order.py653
-rw-r--r--indoteknik_custom/models/requisition.py20
-rw-r--r--indoteknik_custom/models/res_partner.py526
-rwxr-xr-xindoteknik_custom/models/sale_order.py1568
-rw-r--r--indoteknik_custom/models/sale_order_delay.py31
-rw-r--r--indoteknik_custom/models/sale_order_line.py117
-rw-r--r--indoteknik_custom/models/sale_orders_multi_update.py7
-rw-r--r--indoteknik_custom/models/shipment_group.py12
-rw-r--r--indoteknik_custom/models/stock_picking.py678
-rw-r--r--indoteknik_custom/models/stock_picking_return.py164
-rw-r--r--indoteknik_custom/models/tukar_guling.py843
-rw-r--r--indoteknik_custom/models/tukar_guling_po.py662
-rw-r--r--indoteknik_custom/models/uangmuka_pembelian.py2
-rw-r--r--indoteknik_custom/models/user_pengajuan_tempo_request.py6
-rw-r--r--indoteknik_custom/patch.py16
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv11
-rw-r--r--indoteknik_custom/views/account_move.xml16
-rw-r--r--indoteknik_custom/views/approval_payment_term.xml94
-rw-r--r--indoteknik_custom/views/customer_commision.xml12
-rw-r--r--indoteknik_custom/views/ir_sequence.xml70
-rw-r--r--indoteknik_custom/views/mail_template_invoice_reminder.xml51
-rw-r--r--indoteknik_custom/views/mrp_production.xml2
-rwxr-xr-xindoteknik_custom/views/purchase_order.xml35
-rw-r--r--indoteknik_custom/views/purchasing_job.xml12
-rw-r--r--indoteknik_custom/views/refund_sale_order.xml199
-rw-r--r--indoteknik_custom/views/res_partner.xml31
-rwxr-xr-xindoteknik_custom/views/sale_order.xml82
-rw-r--r--indoteknik_custom/views/sale_order_delay.xml44
-rw-r--r--indoteknik_custom/views/stock_picking.xml38
-rw-r--r--indoteknik_custom/views/tukar_guling.xml128
-rw-r--r--indoteknik_custom/views/tukar_guling_po.xml127
-rw-r--r--indoteknik_custom/views/tukar_guling_return_views.xml20
-rw-r--r--indoteknik_custom/views/user_pengajuan_tempo.xml2
-rwxr-xr-xindoteknik_custom/views/x_manufactures.xml40
65 files changed, 7179 insertions, 971 deletions
diff --git a/ab_openstreetmap/__init__.py b/ab_openstreetmap/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/ab_openstreetmap/__init__.py
diff --git a/ab_openstreetmap/__manifest__.py b/ab_openstreetmap/__manifest__.py
new file mode 100644
index 00000000..9d76c34c
--- /dev/null
+++ b/ab_openstreetmap/__manifest__.py
@@ -0,0 +1,17 @@
+{
+ 'name': "Openstreetmap Widget",
+ 'summary': """
+ Openstreetmap Widget
+ """,
+ 'description': """
+ Show Openstreetmap in Form View
+ Required for works add a lat long field to the model
+ """,
+ 'author': "PT. ISMATA NUSANTARA ABADI",
+ 'website': "http://www.ismata.co.id",
+ 'category': 'Uncategorized',
+ 'version': '0.1',
+ 'depends': ['base'],
+ "qweb": ['static/src/xml/googlemap_template.xml'],
+ 'data': ['views/templates.xml'],
+}
diff --git a/ab_openstreetmap/static/src/js/googlemap_widget.js b/ab_openstreetmap/static/src/js/googlemap_widget.js
new file mode 100644
index 00000000..6471be0b
--- /dev/null
+++ b/ab_openstreetmap/static/src/js/googlemap_widget.js
@@ -0,0 +1,133 @@
+odoo.define("ab_openstreetmap.googlemap_widget", function (require) {
+ "use strict";
+
+ const AbstractField = require("web.AbstractField");
+ const fieldRegistry = require("web.field_registry");
+ const rpc = require("web.rpc");
+
+ const GoogleMapWidget = AbstractField.extend({
+ template: "googlemap_template",
+
+ start: async function () {
+ await this._super(...arguments);
+ this._waitForMapReady();
+ },
+
+ on_attach_callback: function () {
+ this._waitForMapReady(); // Trigger ulang saat widget dimunculkan
+ },
+
+ _waitForMapReady: function () {
+ const mapEl = document.getElementById("mapid");
+ if (mapEl && mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0) {
+ this._loadGoogle();
+ } else {
+ setTimeout(() => this._waitForMapReady(), 300); // Tambah jeda untuk mode edit/tab
+ }
+ },
+
+ async _loadGoogle() {
+ const apiKey = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE"; // Ganti sesuai kebutuhan
+ const mapId = "1af072c8d80a2adec8057f34"; // Ganti sesuai kebutuhan
+
+ if (!window.google || !window.google.maps) {
+ const script = document.createElement("script");
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&v=weekly&libraries=places,marker`;
+ script.async = true;
+ script.defer = true;
+ script.onload = () => this._initMap(mapId);
+ document.head.appendChild(script);
+ } else {
+ this._initMap(mapId);
+ }
+ },
+
+ async _initMap(mapId) {
+ const lat = parseFloat(this.recordData.latitude) || -6.2;
+ const lng = parseFloat(this.recordData.longtitude) || 106.816666;
+ const edit = this.mode === "edit";
+ const mapEl = document.getElementById("mapid");
+ if (!mapEl) return;
+
+ mapEl.style.position = "relative";
+ mapEl.style.minHeight = "400px"; // Pastikan map tidak collapse
+
+ const { Map } = await google.maps.importLibrary("maps");
+ const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
+
+ const map = new Map(mapEl, {
+ center: { lat, lng },
+ zoom: 15,
+ mapId: mapId || undefined,
+ });
+
+ // Trigger resize untuk menangani kasus map awalnya hidden
+ setTimeout(() => {
+ google.maps.event.trigger(map, "resize");
+ map.setCenter({ lat, lng }); // Reset ulang posisi setelah resize
+ }, 300);
+
+ const marker = new AdvancedMarkerElement({
+ map,
+ position: { lat, lng },
+ gmpDraggable: edit,
+ title: "Lokasi",
+ });
+
+ if (edit) {
+ marker.addListener("dragend", () => {
+ const pos = marker.position;
+ this._updateCoordinates(pos.lat, pos.lng);
+ });
+
+ // Tambahkan input search
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "Cari alamat...";
+ input.id = "search-input";
+ input.style.cssText = `
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ width: 300px;
+ padding: 6px;
+ font-size: 14px;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ background: white;
+ `;
+ mapEl.appendChild(input);
+
+ const autocomplete = new google.maps.places.Autocomplete(input);
+ autocomplete.addListener("place_changed", () => {
+ const place = autocomplete.getPlace();
+ if (place && place.geometry && place.geometry.location) {
+ const pos = place.geometry.location;
+ map.setCenter(pos);
+ marker.position = pos;
+ this._updateCoordinates(pos.lat(), pos.lng());
+ }
+ });
+ }
+ },
+
+ _updateCoordinates(lat, lng) {
+ this.trigger_up("field_changed", {
+ dataPointID: this.dataPointID,
+ changes: {
+ latitude: lat.toString(),
+ longtitude: lng.toString(),
+ },
+ viewType: this.viewType,
+ });
+ },
+
+ isSet() {
+ return true;
+ },
+ });
+
+ fieldRegistry.add("googlemap", GoogleMapWidget);
+});
diff --git a/ab_openstreetmap/static/src/xml/googlemap_template.xml b/ab_openstreetmap/static/src/xml/googlemap_template.xml
new file mode 100644
index 00000000..e4639a51
--- /dev/null
+++ b/ab_openstreetmap/static/src/xml/googlemap_template.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+ <t t-name="googlemap_template">
+ <div id="mapid" style="height: 400px; width: 100%;"></div>
+ </t>
+</templates>
+
diff --git a/ab_openstreetmap/views/templates.xml b/ab_openstreetmap/views/templates.xml
new file mode 100644
index 00000000..1f5a729b
--- /dev/null
+++ b/ab_openstreetmap/views/templates.xml
@@ -0,0 +1,9 @@
+<odoo>
+ <data>
+ <template id="assets_backend" inherit_id="web.assets_backend">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/ab_openstreetmap/static/src/js/googlemap_widget.js"/>
+ </xpath>
+ </template>
+ </data>
+</odoo>
diff --git a/indoteknik_api/controllers/api_v1/cart.py b/indoteknik_api/controllers/api_v1/cart.py
index 7a40b1e2..fdc237cf 100644
--- a/indoteknik_api/controllers/api_v1/cart.py
+++ b/indoteknik_api/controllers/api_v1/cart.py
@@ -16,24 +16,31 @@ class Cart(controller.Controller):
offset = int(kw.get('offset', 0))
query = [('user_id', '=', user_id)]
carts = user_cart.search(query, limit=limit, offset=offset, order='create_date desc')
- # carts.write({'source': 'add_to_cart'})
+
products = []
products_inactive = []
+
for cart in carts:
- if cart.product_id:
- price = cart.product_id._v2_get_website_price_include_tax()
- if cart.product_id.active and price > 0:
- product = cart.with_context(price_for="web").get_products()
- for product_active in product:
- products.append(product_active)
+ try:
+ if cart.product_id:
+ price = cart.product_id._v2_get_website_price_include_tax()
+
+ if cart.product_id.active and price > 0:
+ product = cart.with_context(price_for="web").get_products()
+ for product_active in product:
+ products.append(product_active)
+ else:
+ product_inactives = cart.with_context(price_for="web").get_products()
+ for inactives in product_inactives:
+ products_inactive.append(inactives)
else:
- product_inactives = cart.with_context(price_for="web").get_products()
- for inactives in product_inactives:
- products_inactive.append(inactives)
- else:
- program = cart.with_context(price_for="web").get_products()
- for programs in program:
- products.append(programs)
+ program = cart.with_context(price_for="web").get_products()
+ for programs in program:
+ products.append(programs)
+
+ except Exception as e:
+ continue
+
data = {
'product_total': user_cart.search_count(query),
'products': products,
diff --git a/indoteknik_api/controllers/api_v1/partner.py b/indoteknik_api/controllers/api_v1/partner.py
index 126fded4..b8bd21be 100644
--- a/indoteknik_api/controllers/api_v1/partner.py
+++ b/indoteknik_api/controllers/api_v1/partner.py
@@ -1,6 +1,6 @@
from .. import controller
from odoo import http
-from odoo.http import request
+from odoo.http import request, Response
from odoo import fields
import json
import base64
@@ -61,46 +61,48 @@ class Partner(controller.Controller):
partner = request.env['res.users'].api_address_response(partner)
return self.response(partner)
- @http.route(prefix + 'partner/<id>/address', auth='public', methods=['PUT', 'OPTIONS'], csrf=False)
+ @http.route(prefix + 'partner/<id>/address', type="json", auth='public', methods=['PUT', 'OPTIONS'], csrf=False, cors='*')
@controller.Controller.must_authorized()
- def write_partner_address_by_id(self, **kw):
+ def write_partner_address_by_id(self, id, **kw):
+ headers = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': '*'
+ }
+ if request.httprequest.method == 'OPTIONS':
+ return Response(status=200, headers=headers)
try:
- params = self.get_request_params(kw, {
- 'id': ['required', 'number'],
- 'type': ['default:other'],
- 'name': ['required'],
- 'email': ['required'],
- 'mobile': ['required'],
- 'phone': [''],
- 'street': ['required'],
- 'state_id': ['required', 'number', 'alias:state_id'],
- 'city_id': ['required', 'number', 'alias:kota_id'],
- 'district_id': ['number', 'alias:kecamatan_id'],
- 'sub_district_id': ['number', 'alias:kelurahan_id', 'exclude_if_null'],
- 'zip': ['required'],
- 'longtitude': '',
- 'latitude': '',
- 'address_map': [],
- 'alamat_lengkap_text': []
- })
+ params = self.get_request_params(request.jsonrequest, {
+ 'id': ['required', 'number'],
+ 'type': ['default:other'],
+ 'name': ['required'],
+ 'email': ['required'],
+ 'mobile': ['required'],
+ 'phone': [''],
+ 'street': ['required'],
+ 'state_id': ['required', 'alias:state_id'],
+ 'city_id': ['required', 'alias:kota_id'],
+ 'district_id': ['alias:kecamatan_id'],
+ 'sub_district_id': ['alias:kelurahan_id', 'exclude_if_null'],
+ 'zip': ['required'],
+ 'longtitude': '',
+ 'latitude': '',
+ 'address_map': [],
+ 'alamat_lengkap_text': []
+ })
if not params['valid']:
- return self.response(code=400, description=params)
-
- partner = request.env[self._name].sudo().search([('id', '=', params['value']['id'])], limit=1)
+ return {'headers' : headers,'code': 400, 'description': params}
+ partner = request.env['res.partner'].sudo().search([('id', '=', id)], limit=1)
if not partner:
- return self.response(code=404, description='User not found')
-
- try:
- partner.write(params['value'])
- except Exception as e:
- return self.response(code=500, description=f'Error writing partner data: {str(e)}')
+ return {'headers' : headers,'code': 404, 'description': 'User not found'}
- return self.response({'id': partner.id})
+ partner.write(params['value'])
+ return {'id': partner.id, 'headers' : headers}
except Exception as e:
- return self.response(code=500, description=f'Unexpected error: {str(e)}')
+ return {'headers' : headers,'code': 500, 'description': f'Internal Error: {str(e)}'}
@http.route(prefix + 'partner/address', auth='public', methods=['POST', 'OPTIONS'], csrf=False)
@controller.Controller.must_authorized()
@@ -132,69 +134,83 @@ class Partner(controller.Controller):
'id': partner.id,
})
- @http.route(prefix + 'partner/<id>', auth='public', methods=['PUT', 'OPTIONS'], csrf=False)
+ @http.route(prefix + 'partner/<int:id>', auth='public', methods=['POST', 'OPTIONS'], csrf=False)
@controller.Controller.must_authorized()
- def write_partner_by_id(self, **kw):
- params = self.get_request_params(kw, {
- 'id': ['', 'number'],
- 'name': [],
- 'company_type_id': ['number'],
- 'industry_id': ['number'],
- 'tax_name': ['alias:nama_wajib_pajak'],
- 'npwp': [],
- 'alamat_lengkap_text': [],
- 'street': [],
- 'email': [],
- 'mobile': []
- })
- id_user = self.get_request_params(kw, {
- 'id_user': ['number']
- })
- params_user = self.get_request_params(kw, {
- 'company_type_id': ['number'],
- 'industry_id': ['number'],
- 'tax_name': ['alias:nama_wajib_pajak'],
- 'npwp': [],
- 'alamat_lengkap_text': [],
- })
+ def write_partner_by_id(self, id, **kw):
+ try:
+ # Ambil data JSON langsung
+ request_data = kw
+
+ partner = request.env['res.partner'].sudo().browse(id)
+ if not partner.exists():
+ return self.response({
+ 'code': 400,
+ 'description': 'Partner not found'
+ })
- if not params['valid']:
- return self.response(code=400, description=params)
+ partner_params = self.get_request_params(request_data, {
+ 'tax_name': ['alias:nama_wajib_pajak'],
+ 'company_type_id': ['number'],
+ 'industry_id': ['number'],
+ 'npwp': [],
+ 'alamat_lengkap_text': [],
+ 'street': [],
+ 'email': [],
+ 'mobile': []
+ })
- partner = request.env[self._name].search([('id', '=', params['value']['id'])], limit=1)
- user = request.env[self._name].search([('id', '=', id_user['value']['id_user'])], limit=1)
- if not partner:
- return self.response(code=404, description='Partner not found')
+ if not partner_params['valid']:
+ return self.response({
+ 'code': 400,
+ 'description': partner_params
+ })
- if not params['value'].get('tax_name'):
- params['value']['nama_wajib_pajak'] = params['value'].get('name')
- params_user['value']['nama_wajib_pajak'] = params_user['value'].get('name')
+ partner_values = partner_params['value']
- if not params['value'].get('alamat_lengkap_text'):
- params['value']['alamat_lengkap_text'] = params['value'].get('street')
- params_user['value']['alamat_lengkap_text'] = params_user['value'].get('street')
+ if 'id_user' in request_data:
+ user_params = self.get_request_params(request_data, {
+ 'id_user': ['required', 'number'],
+ 'company_type_id': ['number'],
+ 'industry_id': ['number'],
+ 'tax_name': ['alias:nama_wajib_pajak'],
+ 'npwp': [],
+ 'alamat_lengkap_text': [],
+ })
- if not params['value'].get('npwp'):
- params['value']['npwp'] = "00.000.000.0-000.000"
- params_user['value']['npwp'] = "00.000.000.0-000.000"
+ if not user_params['valid']:
+ return self.response({
+ 'code': 400,
+ 'description': user_params
+ })
- # Filter parameter yang memiliki nilai saja untuk partner
- params_filtered = {k: v for k, v in params['value'].items() if v}
+ user = request.env['res.partner'].sudo().browse(int(user_params['value']['id_user']))
+ if user.exists():
+ user_values = user_params['value']
- # Filter parameter yang memiliki nilai saja untuk user
- params_user_filtered = {k: v for k, v in params_user['value'].items() if v}
+ if not user_values.get('tax_name'):
+ user_values['nama_wajib_pajak'] = user_values.get('name', user.name)
- # Update partner dan user hanya dengan parameter yang memiliki nilai
- if params_filtered:
- partner.write(params_filtered)
+ if not user_values.get('alamat_lengkap_text'):
+ user_values['alamat_lengkap_text'] = user_values.get('street', user.street)
- if params_user_filtered:
- user.write(params_user_filtered)
+ if not user_values.get('npwp'):
+ user_values['npwp'] = "00.000.000.0-000.000"
- # Return response dengan ID partner yang di-update
- return self.response({
- 'id': partner.id
- })
+ user_values_filtered = {k: v for k, v in user_values.items() if k != 'id_user' and v is not None}
+ if user_values_filtered:
+ user.write(user_values_filtered)
+
+ partner.write(partner_values)
+
+ return self.response({
+ 'partner_id': partner.id
+ })
+
+ except Exception as e:
+ return self.response({
+ 'code': 500,
+ 'description': f'Internal Error: {str(e)}'
+ })
@http.route(prefix + 'partner/industry', auth='public', methods=['GET', 'OPTIONS'])
@controller.Controller.must_authorized()
@@ -306,7 +322,7 @@ class Partner(controller.Controller):
data = True if pengajuan_tempo.id else False
return self.response(data)
- @http.route(prefix + 'partner/pengajuan_tempo', auth='public', methods=['POST'], csrf=False)
+ @http.route(prefix + 'partner/pengajuan_tempo', auth='public', methods=['POST', 'OPTIONS'], csrf=False)
@controller.Controller.must_authorized()
def write_pengajuan_tempo(self, **kw):
try:
diff --git a/indoteknik_api/controllers/api_v1/product.py b/indoteknik_api/controllers/api_v1/product.py
index a88c3368..e97a7ff8 100644
--- a/indoteknik_api/controllers/api_v1/product.py
+++ b/indoteknik_api/controllers/api_v1/product.py
@@ -2,6 +2,7 @@ from .. import controller
from odoo import http
from odoo.http import request, Response
from datetime import datetime, timedelta
+import pytz
import ast
import logging
import math
@@ -46,12 +47,15 @@ class Product(controller.Controller):
('product_id', 'in', product_ids),
('is_winner', '=', True)
])
+ jakarta = pytz.timezone("Asia/Jakarta")
+ start_date = datetime.now(jakarta)
+
+ offset, is3pm = request.env['sale.order'].get_days_until_next_business_day(start_date)
+ additional_days = offset
- start_date = datetime.today().date()
- additional_days = request.env['sale.order'].get_days_until_next_business_day(start_date)
include_instant = True
- if(len(products) != len(product_ids)):
+ if(len(products) == len(product_ids)):
products_data_params = {product["id"] : product for product in product_data }
all_fast_products = all(
@@ -63,8 +67,8 @@ class Product(controller.Controller):
return self.response({
'include_instant': include_instant,
'sla_duration': 1,
- 'sla_additional_days': additional_days,
- 'sla_total' : int(1) + int(additional_days),
+ 'sla_additional_days': int(additional_days),
+ 'sla_total' : int(additional_days),
'sla_unit': 'Hari'
})
@@ -96,27 +100,40 @@ class Product(controller.Controller):
})
@http.route(prefix + 'product_variant/<id>/stock', auth='public', methods=['GET', 'OPTIONS'])
- @controller.Controller.must_authorized()
+ @controller.Controller.must_authorized()
def get_product_template_stock_by_id(self, **kw):
id = int(kw.get('id'))
date_7_days_ago = datetime.now() - timedelta(days=7)
- product = request.env['product.product'].search(
- [('id', '=', id)], limit=1)
- product_sla = request.env['product.sla'].search(
- [('product_variant_id', '=', id)], limit=1)
+ product_pruchase = request.env['purchase.pricelist'].search([
+ ('product_id', '=', id),
+ ('is_winner', '=', True)
+ ])
stock_vendor = request.env['stock.vendor'].search([
('product_variant_id', '=', id),
('write_date', '>=', date_7_days_ago.strftime("%Y-%m-%d %H:%M:%S"))
], limit=1)
+
+ product = product_pruchase.product_id
+
+ vendor_sla = request.env['vendor.sla'].search([('id_vendor', '=', product_pruchase.vendor_id.id)], limit=1)
+ slatime = 15
+ if vendor_sla:
+ if vendor_sla.unit == 'hari':
+ vendor_duration = vendor_sla.duration * 24 * 60
+ else :
+ vendor_duration = vendor_sla.duration * 60
+
+ estimation_sla = (1 * 24 * 60) + vendor_duration
+ estimation_sla_days = estimation_sla / (24 * 60)
+ slatime = math.ceil(estimation_sla_days)
qty_available = product.qty_free_bandengan
-
if qty_available < 1 :
qty_available = 0
qty = 0
- sla_date = '-'
+ sla_date = f'{slatime} Hari'
# Qty Stock Vendor
qty_vendor = stock_vendor.quantity
@@ -136,28 +153,89 @@ class Product(controller.Controller):
if qty_available > 0:
qty = qty_available + total_adem + total_excell
- sla_date = product_sla.sla or 1
+ sla_date = '1 Hari'
elif qty_altama > 0 or qty_vendor > 0:
qty = total_adem if qty_altama > 0 else total_excell
- sla_date = product_sla.sla
+ sla_date = f'{slatime} Hari'
else:
- sla_date = product_sla.sla
+ sla_date = f'{slatime} Hari'
except:
print('error')
else:
if qty_available > 0:
qty = qty_available
- sla_date = product_sla.sla or 'Indent'
+ sla_date = f'1 Hari'
elif qty_vendor > 0:
qty = total_excell
- sla_date = '2-4 Hari'
+ sla_date = f'{slatime} Hari'
data = {
'qty': qty,
'sla_date': sla_date
}
- return self.response(data, headers=[('Cache-Control', 'max-age=600, private')])
+ return self.response(data, headers=[('Cache-Control', 'max-age=600, private')])
+ # def get_product_template_stock_by_id(self, **kw):
+ # id = int(kw.get('id'))
+ # date_7_days_ago = datetime.now() - timedelta(days=7)
+ # product = request.env['product.product'].search(
+ # [('id', '=', id)], limit=1)
+ # product_sla = request.env['product.sla'].search(
+ # [('product_variant_id', '=', id)], limit=1)
+ # stock_vendor = request.env['stock.vendor'].search([
+ # ('product_variant_id', '=', id),
+ # ('write_date', '>=', date_7_days_ago.strftime("%Y-%m-%d %H:%M:%S"))
+ # ], limit=1)
+
+ # qty_available = product.qty_free_bandengan
+
+
+ # if qty_available < 1 :
+ # qty_available = 0
+
+ # qty = 0
+ # sla_date = '-'
+
+ # # Qty Stock Vendor
+ # qty_vendor = stock_vendor.quantity
+ # qty_vendor -= int(qty_vendor * 0.1)
+ # qty_vendor = math.ceil(float(qty_vendor))
+ # total_excell = qty_vendor
+
+ # is_altama_product = product.x_manufacture.id in [10, 122, 89]
+ # if is_altama_product:
+ # try:
+ # # Qty Altama
+ # qty_altama = request.env['product.template'].get_stock_altama(
+ # product.default_code)
+ # qty_altama -= int(qty_altama * 0.1)
+ # qty_altama = math.ceil(float(qty_altama))
+ # total_adem = qty_altama
+
+ # if qty_available > 0:
+ # qty = qty_available + total_adem + total_excell
+ # sla_date = product_sla.sla or 1
+ # elif qty_altama > 0 or qty_vendor > 0:
+ # qty = total_adem if qty_altama > 0 else total_excell
+ # sla_date = product_sla.sla
+ # else:
+ # sla_date = product_sla.sla
+ # except:
+ # print('error')
+ # else:
+ # if qty_available > 0:
+ # qty = qty_available
+ # sla_date = product_sla.sla or 'Indent'
+ # elif qty_vendor > 0:
+ # qty = total_excell
+ # sla_date = '2-4 Hari'
+
+ # data = {
+ # 'qty': qty,
+ # 'sla_date': sla_date
+ # }
+
+ # return self.response(data, headers=[('Cache-Control', 'max-age=600, private')])
@http.route(prefix + 'product_variant/<id>/qty_available', auth='public', methods=['GET', 'OPTIONS'])
@controller.Controller.must_authorized()
diff --git a/indoteknik_api/controllers/api_v1/sale_order.py b/indoteknik_api/controllers/api_v1/sale_order.py
index e87b357e..3ecaff57 100644
--- a/indoteknik_api/controllers/api_v1/sale_order.py
+++ b/indoteknik_api/controllers/api_v1/sale_order.py
@@ -521,7 +521,7 @@ class SaleOrder(controller.Controller):
if params['value']['type'] == 'sale_order':
parameters['approval_status'] = 'pengajuan1'
- sale_order = request.env['sale.order'].create([parameters])
+ sale_order = request.env['sale.order'].with_context(from_website_checkout=True).create([parameters])
sale_order.onchange_partner_contact()
user_id = params['value']['user_id']
@@ -540,6 +540,7 @@ class SaleOrder(controller.Controller):
'product_available_quantity': cart['available_quantity']
})
order_line.product_id_change()
+ order_line.weight = order_line.product_id.weight
order_line.onchange_vendor_id()
order_line.price_unit = cart['price']['price']
order_line.discount = cart['price']['discount_percentage']
@@ -575,6 +576,7 @@ class SaleOrder(controller.Controller):
elif sale_order._requires_approval_margin_manager():
sale_order.approval_status = 'pengajuan1'
# user_cart.browse(cart_ids).unlink()
+ sale_order._auto_set_shipping_from_website()
return self.response({
'id': sale_order.id,
'name': sale_order.name
diff --git a/indoteknik_api/controllers/api_v1/state.py b/indoteknik_api/controllers/api_v1/state.py
index 958359a7..c03042e7 100644
--- a/indoteknik_api/controllers/api_v1/state.py
+++ b/indoteknik_api/controllers/api_v1/state.py
@@ -8,12 +8,8 @@ class District(controller.Controller):
@http.route(prefix + 'state', auth='public', methods=['GET', 'OPTIONS'])
@controller.Controller.must_authorized()
def get_state(self, **kw):
- tempo = kw.get('tempo')
- parameters = []
- if tempo == 'true':
- parameters.append(('country_id', '=', 100))
+ parameters = [('country_id', '=', 100)] # selalu ambil country_id = 100
-
name = kw.get('name')
if name:
name = '%' + name.replace(' ', '%') + '%'
@@ -22,7 +18,7 @@ class District(controller.Controller):
states = request.env['res.country.state'].search(parameters)
data = []
for state in states:
- data.append({ 'id': state.id, 'name': state.name})
-
+ data.append({'id': state.id, 'name': state.name})
+
return self.response(data)
diff --git a/indoteknik_api/controllers/api_v1/stock_picking.py b/indoteknik_api/controllers/api_v1/stock_picking.py
index 31706b99..85b0fbba 100644
--- a/indoteknik_api/controllers/api_v1/stock_picking.py
+++ b/indoteknik_api/controllers/api_v1/stock_picking.py
@@ -1,9 +1,14 @@
from .. import controller
from odoo import http
-from odoo.http import request
+from odoo.http import request, Response
from pytz import timezone
from datetime import datetime
+import json
+import logging
+_logger = logging.getLogger(__name__)
+
+_logger = logging.getLogger(__name__)
class StockPicking(controller.Controller):
prefix = '/api/v1/'
@@ -22,42 +27,44 @@ class StockPicking(controller.Controller):
if not get_params['valid']:
return self.response(code=400, description=get_params)
-
+
params = get_params['value']
partner_id = params['partner_id']
limit = params['limit']
offset = params['offset']
-
+
child_ids = request.env['res.partner'].browse(partner_id).get_child_ids()
pending_domain = [('driver_departure_date', '=', False), ('driver_arrival_date', '=', False)]
shipment_domain = [('driver_departure_date', '!=', False), ('driver_arrival_date', '=', False)]
shipment_domain2 = [('driver_departure_date', '!=', False), ('sj_return_date', '=', False)]
- completed_domain = [('driver_departure_date', '!=', False),'|', ('driver_arrival_date', '!=', False), ('sj_return_date', '!=', False)]
+ completed_domain = [('driver_departure_date', '!=', False), '|', ('driver_arrival_date', '!=', False),
+ ('sj_return_date', '!=', False)]
completed_domain2 = [('driver_departure_date', '!=', False), ('sj_return_date', '!=', False)]
picking_model = request.env['stock.picking']
domain = [
- ('partner_id', 'in', child_ids),
- ('sale_id', '!=', False),
- ('origin', 'ilike', 'SO%'),
- ('state', '!=', 'cancel')
+ ('partner_id', 'in', child_ids),
+ ('sale_id', '!=', False),
+ ('origin', 'ilike', 'SO%'),
+ ('state', '!=', 'cancel'),
+ ('name', 'ilike', 'BU/OUT%')
]
-
+
if params['q']:
query_like = '%' + params['q'].replace(' ', '%') + '%'
- domain += ['|', '|',
- ('name', 'ilike', query_like),
- ('sale_id.client_order_ref', 'ilike', query_like),
- ('delivery_tracking_no', 'ilike', query_like)
- ]
+ domain += ['|', '|',
+ ('name', 'ilike', query_like),
+ ('sale_id.client_order_ref', 'ilike', query_like),
+ ('delivery_tracking_no', 'ilike', query_like)
+ ]
default_domain = domain.copy()
-
+
if params['status'] == 'pending':
domain += pending_domain
elif params['status'] == 'shipment':
- domain += shipment_domain + shipment_domain2
+ domain += shipment_domain + shipment_domain2
elif params['status'] == 'completed':
domain += completed_domain
@@ -65,7 +72,7 @@ class StockPicking(controller.Controller):
res_pickings = []
for picking in stock_pickings:
manifests = picking.get_manifests()
-
+
res_pickings.append({
'id': picking.id,
'name': picking.name,
@@ -86,12 +93,12 @@ class StockPicking(controller.Controller):
'summary': {
'pending_count': picking_model.search_count(default_domain + pending_domain),
'shipment_count': picking_model.search_count(default_domain + shipment_domain + shipment_domain2),
- 'completed_count': picking_model.search_count(default_domain + completed_domain )
+ 'completed_count': picking_model.search_count(default_domain + completed_domain)
},
'picking_total': picking_model.search_count(domain),
'pickings': res_pickings
})
-
+
@http.route(PREFIX_PARTNER + 'stock-picking/<id>/tracking', auth='public', method=['GET', 'OPTIONS'])
@controller.Controller.must_authorized(private=True, private_key='partner_id')
def get_partner_stock_picking_detail_tracking(self, **kw):
@@ -101,7 +108,6 @@ class StockPicking(controller.Controller):
picking = picking_model.browse(id)
if not picking:
return self.response(None)
- hostori = picking.get_tracking_detail()
return self.response(picking.get_tracking_detail())
@http.route(prefix + 'stock-picking/<id>/tracking', auth='public', method=['GET', 'OPTIONS'])
@@ -140,49 +146,105 @@ class StockPicking(controller.Controller):
return self.response({
'name': picking_data.name
})
-
- @http.route(prefix + 'webhook/biteship', type='json', auth='public', methods=['POST'], csrf=False)
- def udpate_status_from_bitehsip(self, **kw):
+
+ @http.route(prefix + 'webhook/biteship', type='json', auth='public', methods=['POST'], csrf=False)
+ def update_status_from_biteship(self, **kw):
+ _logger.info("Biteship Webhook: Request received at controller start (type='json').")
+
try:
- if not request.jsonrequest:
- return "ok"
+ # Karena type='json', Odoo secara otomatis akan mem-parsing JSON untuk Anda.
+ # 'data' akan berisi dictionary Python dari payload JSON Biteship.
+ data = request.jsonrequest
- data = request.jsonrequest # Ambil data JSON dari request
- event = data.get('event')
+ # Log ini akan menunjukkan payload yang diterima (sudah dalam bentuk dict)
+ _logger.info(f"Biteship Webhook: Parsed JSON data from request.jsonrequest: {json.dumps(data)}")
- # Handle Event Berdasarkan Jenisnya
- if event == "order.status":
- self.process_order_status(data)
- elif event == "order.price":
- self.process_order_price(data)
- elif event == "order.waybill_id":
- self.process_order_waybill(data)
+ event = data.get('event')
+ if event:
+ _logger.info(f"Biteship Webhook: Processing event: {event}")
+ if event == "order.status":
+ self.process_order_status(data)
+ elif event == "order.price":
+ self.process_order_price(data)
+ elif event == "order.waybill_id":
+ self.process_order_waybill(data)
+ # Tambahkan logika untuk event lain jika ada
+ else:
+ _logger.info("Biteship Webhook: No specific event in payload. Likely an installation/verification ping or unknown event type.")
+
+ # Untuk route type='json', Anda cukup mengembalikan dictionary Python.
+ # Odoo akan secara otomatis mengonversinya menjadi respons JSON yang valid.
+ return {'status': 'ok'}
- return {'success': True, 'message': f'Webhook {event} received'}
except Exception as e:
- return {'success': False, 'message': str(e)}
+ _logger.error(f"Biteship Webhook: Unhandled error during processing: {e}", exc_info=True)
+ # Untuk error, kembalikan dictionary error juga, Odoo akan mengonversinya ke JSON
+ return {'status': 'error', 'message': str(e)}
def process_order_status(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))], limit=1)
- if data.get('status') == 'picked':
- picking_model.write({'driver_departure_date': datetime.utcnow()})
- elif data.get('status') == 'delivered':
- picking_model.write({'driver_arrival_date': datetime.utcnow()})
-
+ picking = request.env['stock.picking'].sudo().search([
+ ('biteship_id', '=', data.get('order_id'))
+ ], limit=1)
+
+ if not picking:
+ _logger.warning(f"[Webhook] Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
+
+ status = data.get('status')
+ timestamp = data.get('updated_at') or datetime.utcnow().isoformat()
+
+ description = picking._get_biteship_status_description(status, {
+ "courier": {"company": data.get("courier_company", "")},
+ "destination": {"contact_name": picking.partner_id.name or ""}
+ })
+
+ # Tambahkan extra data dari webhook
+ extra_data = {
+ "courier_driver_name": data.get("courier_driver_name"),
+ "courier_driver_phone": data.get("courier_driver_phone"),
+ "courier_driver_plate_number": data.get("courier_driver_plate_number"),
+ "courier_link": data.get("courier_link"),
+ "order_price": data.get("order_price"),
+ "status": data.get("status"),
+ }
+
+ picking.log_biteship_event_from_webhook(status, timestamp, description, extra_data=extra_data)
+
+
+
def process_order_price(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))], limit=1)
- order = request.env['sale.order'].sudo().search([('name', '=', picking_model.sale_id.name)], limit=1)
- if order:
- order.write({
- 'delivery_amt': data.get('price')
- })
+ picking = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))], limit=1)
+
+ if not picking:
+ _logger.warning(f"Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
+
+ picking.log_biteship_event_from_webhook(
+ status='order.price',
+ timestamp=data.get('updated_at') or datetime.utcnow().isoformat(),
+ description='Biaya pengiriman telah diperbarui berdasarkan informasi terbaru dari Biteship.',
+ extra_data={
+ "order_price": data.get("price")
+ }
+ )
+
def process_order_waybill(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))], limit=1)
- if picking_model:
- picking_model.write({
- 'biteship_waybill_id': data.get('courier_waybill_id'),
- 'delivery_tracking_no': data.get('courier_waybill_id'),
- 'biteship_tracking_id':data.get('courier_tracking_id')
- })
- \ No newline at end of file
+ picking = request.env['stock.picking'].sudo().search([
+ ('biteship_id', '=', data.get('order_id'))
+ ], limit=1)
+
+ if not picking:
+ _logger.warning(f"Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
+
+ picking.log_biteship_event_from_webhook(
+ status='order.waybill_id',
+ timestamp=data.get('updated_at') or datetime.utcnow().isoformat(),
+ description="Nomor waybill dan tracking diperbarui melalui Biteship.",
+ extra_data={
+ "tracking_id": data.get("courier_tracking_id"),
+ "waybill_id": data.get("courier_waybill_id")
+ }
+ )
+
diff --git a/indoteknik_api/models/sale_order.py b/indoteknik_api/models/sale_order.py
index 4fc96386..5427ed07 100644
--- a/indoteknik_api/models/sale_order.py
+++ b/indoteknik_api/models/sale_order.py
@@ -30,20 +30,24 @@ class SaleOrder(models.Model):
'payment_type': sale_order.payment_type,
'pickings': []
}
- # Urutkan picking berdasarkan create_date descending
- sorted_pickings = sale_order.picking_ids.sorted(key=lambda p: p.create_date, reverse=True)
-
- for picking in sorted_pickings:
+ for picking in sale_order.picking_ids:
+ picking_model = self.env['stock.picking'].sudo().search([('id', '=', picking.id), ('name', 'like', '%BU/OUT/%')], limit=1)
+ if not picking_model:
+ continue
+ response = picking_model.get_tracking_detail()
+
data['pickings'].append({
+ 'waybill_number' : response['waybill_number'] or '',
+ 'delivered_date': response['delivered_date'],
+ 'delivery_order' : {
+ 'carrier' : response['delivery_order']['carrier'] or '',
+ 'service' : response['delivery_order']['service'] or ''
+ },
+ 'eta' : response['eta'],
'id': picking.id,
'name': picking.name,
- 'tracking_number': picking.delivery_tracking_no or '',
- 'carrier_name': picking.carrier_id.name or '',
- 'delivered': picking.waybill_id.delivered or picking.driver_arrival_date != False or picking.sj_return_date != False,
- 'date': self.env['rest.api'].datetime_to_str(picking.driver_departure_date, '%d/%m/%Y'),
- 'eta': picking.generate_eta_delivery(),
- 'service_type': sale_order.delivery_service_type or '',
- 'weight_total': picking.weight or 0,
+ # 'tracking_number': picking.delivery_tracking_no or '',
+ # 'delivered': picking.waybill_id.delivered or picking.driver_arrival_date != False or picking.sj_return_date != False,
})
if sale_order.state == 'cancel':
data['status'] = 'cancel'
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index 9fe3dcdb..2a4db273 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -97,6 +97,7 @@
'views/mail_template_po.xml',
'views/mail_template_efaktur.xml',
'views/mail_template_invoice_po.xml',
+ 'views/mail_template_invoice_reminder.xml',
'views/price_group.xml',
'views/mrp_production.xml',
'views/apache_solr.xml',
@@ -156,6 +157,7 @@
'views/stock_backorder_confirmation_views.xml',
'views/barcoding_product.xml',
'views/project_views.xml',
+ 'views/approval_payment_term.xml',
'report/report.xml',
'report/report_banner_banner.xml',
'report/report_banner_banner2.xml',
@@ -167,6 +169,11 @@
'views/coretax_faktur.xml',
'views/public_holiday.xml',
'views/stock_inventory.xml',
+ 'views/sale_order_delay.xml',
+ 'views/tukar_guling.xml',
+ # 'views/tukar_guling_return_views.xml'
+ 'views/tukar_guling_po.xml',
+ # 'views/refund_sale_order.xml',
],
'demo': [],
'css': [],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 08fa9803..51d25c1f 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -149,4 +149,10 @@ from . import sales_order_koli
from . import stock_backorder_confirmation
from . import account_payment_register
from . import stock_inventory
+from . import sale_order_delay
from . import approval_invoice_date
+from . import approval_payment_term
+from . import refund_sale_order
+# from . import patch
+from . import tukar_guling
+from . import tukar_guling_po
diff --git a/indoteknik_custom/models/account_move.py b/indoteknik_custom/models/account_move.py
index 30de67be..1a6fad1c 100644
--- a/indoteknik_custom/models/account_move.py
+++ b/indoteknik_custom/models/account_move.py
@@ -1,5 +1,6 @@
from odoo import models, api, fields
from odoo.exceptions import AccessError, UserError, ValidationError
+from markupsafe import escape as html_escape
from datetime import timedelta, date, datetime
from pytz import timezone, utc
import logging
@@ -8,12 +9,15 @@ import PyPDF2
import os
import re
from terbilang import Terbilang
+from collections import defaultdict
+from odoo.tools.misc import formatLang
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
+ _description = 'Account Move'
invoice_day_to_due = fields.Integer(string="Day to Due", compute="_compute_invoice_day_to_due")
bill_day_to_due = fields.Date(string="Day to Due", compute="_compute_bill_day_to_due")
date_send_fp = fields.Datetime(string="Tanggal Kirim Faktur Pajak")
@@ -67,13 +71,189 @@ class AccountMove(models.Model):
is_hr = fields.Boolean(string="Is HR?", default=False)
purchase_order_id = fields.Many2one('purchase.order', string='Purchase Order')
length_of_payment = fields.Integer(string="Length of Payment", compute='compute_length_of_payment')
+ reklas_misc_id = fields.Many2one('account.move', string='Journal Entries Reklas')
+ # Di model account.move
+ bill_id = fields.Many2one('account.move', string='Vendor Bill', domain=[('move_type', '=', 'in_invoice')], help='Bill asal dari proses reklas ini')
+ down_payment = fields.Boolean('Down Payments?')
+ refund_id = fields.Many2one('refund.sale.order', string='Refund Reference')
+ refund_so_ids = fields.Many2many(
+ 'sale.order',
+ 'account_move_sale_order_rel',
+ 'move_id',
+ 'sale_order_id',
+ string='Group SO Number'
+ )
+
+ refund_so_links = fields.Html(
+ string="Group SO Numbers",
+ compute="_compute_refund_so_links",
+ )
+
+ has_refund_so = fields.Boolean(
+ string='Has Refund SO',
+ compute='_compute_has_refund_so',
+ )
+
+ # def name_get(self):
+ # result = []
+ # for move in self:
+ # if move.move_type == 'entry':
+ # # Jika masih draft, tampilkan 'Draft CAB'
+ # if move.state == 'draft':
+ # label = 'Draft CAB'
+ # else:
+ # label = move.name
+ # result.append((move.id, label))
+ # else:
+ # # Untuk invoice dan lainnya, pakai default
+ # result.append((move.id, move.display_name))
+ # return result
+
+ # def send_due_invoice_reminder(self):
+ # today = fields.Date.today()
+ # target_dates = [
+ # today - timedelta(days=7),
+ # today - timedelta(days=3),
+ # today,
+ # today + timedelta(days=3),
+ # today + timedelta(days=7),
+ # ]
+
+ # partner = self.env['res.partner'].search([('name', 'ilike', 'BANGUNAN TEKNIK GRUP')], limit=1)
+ # if not partner:
+ # _logger.info("Partner tidak ditemukan.")
+ # return
+
+ # invoices = self.env['account.move'].search([
+ # ('move_type', '=', 'out_invoice'),
+ # ('state', '=', 'posted'),
+ # ('payment_state', 'not in', ['paid','in_payment', 'reversed']),
+ # ('invoice_date_due', 'in', target_dates),
+ # ('partner_id', '=', partner.id),
+ # ])
+
+ # _logger.info(f"Invoices tahap 1: {invoices}")
+
+ # invoices = invoices.filtered(
+ # lambda inv: inv.invoice_payment_term_id and 'tempo' in (inv.invoice_payment_term_id.name or '').lower()
+ # )
+ # _logger.info(f"Invoices tahap 2: {invoices}")
+
+ # if not invoices:
+ # _logger.info(f"Tidak ada invoice yang due untuk partner: {partner.name}")
+ # return
+
+ # grouped = {}
+ # for inv in invoices:
+ # grouped.setdefault(inv.partner_id, []).append(inv)
+
+ # template = self.env.ref('indoteknik_custom.mail_template_invoice_due_reminder')
+
+ # for partner, invs in grouped.items():
+ # if not partner.email:
+ # _logger.info(f"Partner {partner.name} tidak memiliki email")
+ # continue
+
+ # invoice_table_rows = ""
+ # for inv in invs:
+ # days_to_due = (inv.invoice_date_due - today).days if inv.invoice_date_due else 0
+ # invoice_table_rows += f"""
+ # <tr>
+ # <td>{inv.name}</td>
+ # <td>{fields.Date.to_string(inv.invoice_date) or '-'}</td>
+ # <td>{fields.Date.to_string(inv.invoice_date_due) or '-'}</td>
+ # <td>{days_to_due}</td>
+ # <td>{formatLang(self.env, inv.amount_total, currency_obj=inv.currency_id)}</td>
+ # <td>{inv.ref or '-'}</td>
+ # </tr>
+ # """
+
+ # subject = f"Reminder Invoice Due - {partner.name}"
+ # body_html = re.sub(
+ # r"<tbody[^>]*>.*?</tbody>",
+ # f"<tbody>{invoice_table_rows}</tbody>",
+ # template.body_html,
+ # flags=re.DOTALL
+ # ).replace('${object.name}', partner.name) \
+ # .replace('${object.partner_id.name}', partner.name)
+ # # .replace('${object.email}', partner.email or '')
+
+ # values = {
+ # 'subject': subject,
+ # 'email_to': 'andrifebriyadiputra@gmail.com', # Ubah ke partner.email untuk produksi
+ # 'email_from': 'finance@indoteknik.co.id',
+ # 'body_html': body_html,
+ # 'reply_to': f'invoice+account.move_{invs[0].id}@indoteknik.co.id',
+ # }
+
+ # _logger.info(f"VALUES: {values}")
+
+ # template.send_mail(invs[0].id, force_send=True, email_values=values)
+
+ # # Default System User
+ # user_system = self.env['res.users'].browse(25)
+ # system_id = user_system.partner_id.id if user_system else False
+ # _logger.info(f"System User: {user_system.name} ({user_system.id})")
+ # _logger.info(f"System User ID: {system_id}")
+
+ # for inv in invs:
+ # inv.message_post(
+ # subject=subject,
+ # body=body_html,
+ # subtype_id=self.env.ref('mail.mt_note').id,
+ # author_id=system_id,
+ # )
+
+ # _logger.info(f"Reminder terkirim ke {partner.name} ({values['email_to']}) → {len(invs)} invoice")
+
+
+ @api.onchange('invoice_date')
+ def _onchange_invoice_date(self):
+ if self.invoice_date:
+ self.date = self.invoice_date
+
+ @api.onchange('date')
+ def _onchange_date(self):
+ if self.date:
+ self.invoice_date = self.date
+
+ @api.depends('refund_so_ids')
+ def _compute_refund_so_links(self):
+ for rec in self:
+ links = []
+ for so in rec.refund_so_ids:
+ url = f"/web#id={so.id}&model=sale.order&view_type=form"
+ name = html_escape(so.name or so.display_name)
+ links.append(f'<a href="{url}" target="_blank">{name}</a>')
+ rec.refund_so_links = ', '.join(links) if links else "-"
+
+ @api.depends('refund_so_ids')
+ def _compute_has_refund_so(self):
+ for rec in self:
+ rec.has_refund_so = bool(rec.refund_so_ids)
+
+
+ # def compute_length_of_payment(self):
+ # for rec in self:
+ # payment_term = rec.invoice_payment_term_id.line_ids[0].days
+ # terima_faktur = rec.date_terima_tukar_faktur
+ # payment = self.search([('ref', '=', rec.name), ('move_type', '=', 'entry')], limit=1)
+
+ # if payment and terima_faktur:
+ # date_diff = terima_faktur - payment.date
+ # rec.length_of_payment = date_diff.days + payment_term
+ # else:
+ # rec.length_of_payment = 0
def compute_length_of_payment(self):
for rec in self:
- payment_term = rec.invoice_payment_term_id.line_ids[0].days
+ payment_term = 0
+ if rec.invoice_payment_term_id and rec.invoice_payment_term_id.line_ids:
+ payment_term = rec.invoice_payment_term_id.line_ids[0].days
+
terima_faktur = rec.date_terima_tukar_faktur
payment = self.search([('ref', '=', rec.name), ('move_type', '=', 'entry')], limit=1)
-
+
if payment and terima_faktur:
date_diff = terima_faktur - payment.date
rec.length_of_payment = date_diff.days + payment_term
@@ -126,13 +306,38 @@ class AccountMove(models.Model):
}
template.send_mail(record.id, email_values=email_values, force_send=True)
+ # @api.model
+ # def create(self, vals):
+ # vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0'
+ # result = super(AccountMove, self).create(vals)
+ # # result._update_line_name_from_ref()
+ # return result
+
@api.model
def create(self, vals):
- vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0'
+ vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0'
result = super(AccountMove, self).create(vals)
- # result._update_line_name_from_ref()
+
+ # Tambahan: jika ini Vendor Bill dan tanggal belum diisi
+ if result.move_type == 'in_invoice' and not vals.get('invoice_date') and not vals.get('date'):
+ po = result.purchase_order_id
+ if po:
+ # Cari receipt dari PO
+ picking = self.env['stock.picking'].search([
+ ('purchase_id', '=', po.id),
+ ('picking_type_code', '=', 'incoming'),
+ ('state', '=', 'done'),
+ ('date_done', '!=', False),
+ ], order='date_done desc', limit=1)
+
+ if picking:
+ receipt_date = picking.date_done
+ result.invoice_date = receipt_date
+ result.date = receipt_date
+
return result
+
def compute_so_shipping_paid_by(self):
for record in self:
record.so_shipping_paid_by = record.sale_id.shipping_paid_by
@@ -381,18 +586,18 @@ class AccountMove(models.Model):
return invoices
def export_faktur_to_xml(self):
-
valid_invoices = self
- # Panggil model coretax.faktur untuk menghasilkan XML
coretax_faktur = self.env['coretax.faktur'].create({})
- response = coretax_faktur.export_to_download(invoices=valid_invoices)
- current_time = datetime.utcnow()
- # Tandai faktur sebagai sudah diekspor
+ response = coretax_faktur.export_to_download(
+ invoices=valid_invoices,
+ down_payments=[inv.down_payment for inv in valid_invoices],
+ )
+
valid_invoices.write({
'is_efaktur_exported': True,
- 'date_efaktur_exported': current_time, # Set tanggal ekspor
+ 'date_efaktur_exported': datetime.utcnow(),
})
- return response
+ return response \ No newline at end of file
diff --git a/indoteknik_custom/models/approval_payment_term.py b/indoteknik_custom/models/approval_payment_term.py
new file mode 100644
index 00000000..6c857b45
--- /dev/null
+++ b/indoteknik_custom/models/approval_payment_term.py
@@ -0,0 +1,187 @@
+from odoo import models, api, fields
+from odoo.exceptions import AccessError, UserError, ValidationError
+from datetime import timedelta, date, datetime
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class ApprovalPaymentTerm(models.Model):
+ _name = "approval.payment.term"
+ _description = "Approval Payment Term"
+ _inherit = ['mail.thread']
+ _rec_name = 'number'
+
+ number = fields.Char(string='Document No', index=True, copy=False, readonly=True, tracking=True)
+ partner_id = fields.Many2one('res.partner', string='Partner', copy=False)
+ property_payment_term_id = fields.Many2one('account.payment.term', string='Payment Term', copy=False, tracking=True)
+ parent_id = fields.Many2one('res.partner', string='Related Company', copy=False)
+ blocking_stage = fields.Float(string='Blocking Amount',
+ help="Cannot make sales once the selected "
+ "customer is crossed blocking amount."
+ "Set its value to 0.00 to disable "
+ "this feature", tracking=True, copy=False)
+ warning_stage = fields.Float(string='Warning Amount',
+ help="A warning message will appear once the "
+ "selected customer is crossed warning "
+ "amount. Set its value to 0.00 to"
+ " disable this feature", tracking=True, copy=False)
+ active_limit = fields.Boolean('Active Credit Limit', copy=False, tracking=True)
+ approve_sales_manager = fields.Boolean('Approve Sales Manager', tracking=True, copy=False)
+ approve_finance = fields.Boolean('Approve Finance', tracking=True, copy=False)
+ approve_leader = fields.Boolean('Approve Pimpinan', tracking=True, copy=False)
+ reason = fields.Text('Reason', tracking=True)
+ approve_date = fields.Datetime('Approve Date')
+ state = fields.Selection([
+ ('waiting_approval_sales_manager', 'Waiting Approval Sales Manager'),
+ ('waiting_approval_finance', 'Waiting Approval Finance'),
+ ('waiting_approval_leader', 'Waiting Approval Leader'),
+ ('approved', 'Approved'),
+ ('rejected', 'Rejected')],
+ default='waiting_approval_sales_manager', tracking=True)
+ reason_reject = fields.Selection([('reason1', 'Reason 1'), ('reason2', 'Reason 2'), ('reason3', 'Reason 3')], string='Reason Reject', tracking=True)
+ sale_order_ids = fields.Many2many(
+ 'sale.order',
+ string='Sale Orders',
+ copy=False,
+ tracking=True
+ )
+
+ total = fields.Char(
+ string='Sale Order Totals',
+ compute='_compute_total'
+ )
+
+ grand_total = fields.Float(string='Grand Total', compute="_compute_grand_total")
+
+ change_log_688 = fields.Text(string="Change Log", readonly=True, copy=False)
+
+ def write(self, vals):
+ # Ambil nilai lama sebelum perubahan
+ old_values_dict = {
+ rec.id: rec.read(vals.keys())[0]
+ for rec in self
+ }
+
+ res = super().write(vals)
+
+ self._track_changes_for_user_688(vals, old_values_dict)
+ return res
+
+ def _track_changes_for_user_688(self, vals, old_values_dict):
+ if self.env.user.id != 688:
+ return
+
+ for rec in self:
+ changes = []
+ old_values = old_values_dict.get(rec.id, {})
+
+ for field_name, new_value in vals.items():
+ if field_name not in rec._fields or field_name == 'change_log_688':
+ continue
+
+ field = rec._fields[field_name]
+ old_value = old_values.get(field_name)
+
+ field_label = field.string # Ambil label user-friendly
+
+ # Relational field
+ if field.type == 'many2one':
+ old_id = old_value[0] if old_value else False
+ is_different = old_id != new_value
+ if is_different:
+ old_display = old_value[1] if old_value else 'False'
+ new_display = rec.env[field.comodel_name].browse(new_value).display_name if new_value else 'False'
+ changes.append(f"[{field_label}] dari '{old_display}' ke '{new_display}'")
+
+ else:
+ # Float khusus
+ if field.type == 'float':
+ is_different = not self._float_equal(old_value, new_value)
+ else:
+ is_different = old_value != new_value
+
+ if is_different:
+ changes.append(f"[{field_label}] dari '{old_value}' ke '{new_value}'")
+
+ if changes:
+ timestamp = fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ rec.change_log_688 = f"{timestamp} - Perubahan oleh Widya:\n" + "\n".join(changes)
+
+
+ @staticmethod
+ def _float_equal(val1, val2, eps=1e-6):
+ try:
+ return abs(float(val1 or 0.0) - float(val2 or 0.0)) < eps
+ except Exception:
+ return False
+
+ def _compute_grand_total(self):
+ for rec in self:
+ grand_total = sum(order.amount_total for order in rec.sale_order_ids)
+ rec.grand_total = grand_total
+
+ def _compute_total(self):
+ for rec in self:
+ totals_list = []
+ for order in rec.sale_order_ids:
+ formatted_total = "{:,.2f}".format(order.amount_total)
+ totals_list.append(f"{order.name}: {formatted_total}")
+
+ rec.total = "\n".join(totals_list) if totals_list else "No Sale Orders"
+
+
+ @api.constrains('partner_id')
+ def constrains_partner_id(self):
+ if self.partner_id:
+ self.parent_id = self.partner_id.parent_id.id if self.partner_id.parent_id else None
+ self.blocking_stage = self.partner_id.blocking_stage
+ self.warning_stage = self.partner_id.warning_stage
+ self.active_limit = self.partner_id.active_limit
+ self.property_payment_term_id = self.partner_id.property_payment_term_id.id
+
+ def button_approve(self):
+ user = self.env.user
+ is_it = user.has_group('indoteknik_custom.group_role_it')
+
+ if (not user.id ==7 and user.id == 19 and not self.approve_sales_manager) or (is_it and not self.approve_sales_manager):
+ self.approve_sales_manager = True
+ self.state = 'waiting_approval_finance'
+ return
+
+ if (not user.id ==7 and user.id == 688 and not self.approve_finance) or (is_it and not self.approve_finance):
+ self.approve_finance = True
+ self.state = 'waiting_approval_leader'
+ return
+
+ if (user.id == 7 and self.approve_finance) or (is_it and not self.approve_leader):
+ self.approve_leader = True
+
+ if not self.approve_finance and not is_it:
+ raise UserError('Harus Approval Finance!!')
+ if not self.approve_leader and not is_it:
+ raise UserError('Harus Approval Pimpinan!!')
+
+ if user.id == 7:
+ if not self.approve_finance:
+ raise UserError('Belum Di Approve Oleh Finance')
+
+ if self.approve_leader == True:
+ self.partner_id.write({
+ 'blocking_stage': self.blocking_stage,
+ 'warning_stage': self.warning_stage,
+ 'active_limit': self.active_limit,
+ 'property_payment_term_id': self.property_payment_term_id.id
+ })
+ self.approve_date = datetime.utcnow()
+ self.state = 'approved'
+
+ def button_reject(self):
+ if self.env.user.id not in [688, 7]:
+ raise UserError("Hanya Finance atau Pimpinan Yang Bisa Reject")
+ self.state = 'rejected'
+
+ @api.model
+ def create(self, vals):
+ vals['number'] = self.env['ir.sequence'].next_by_code('approval.payment.term') or '0'
+ result = super(ApprovalPaymentTerm, self).create(vals)
+ return result
diff --git a/indoteknik_custom/models/automatic_purchase.py b/indoteknik_custom/models/automatic_purchase.py
index c9edf07c..83a7cb3c 100644
--- a/indoteknik_custom/models/automatic_purchase.py
+++ b/indoteknik_custom/models/automatic_purchase.py
@@ -486,7 +486,7 @@ class AutomaticPurchase(models.Model):
# _logger.info('test %s' % point.product_id.name)
if point.product_id.qty_available_bandengan > point.product_min_qty:
continue
- qty_purchase = point.product_max_qty - point.product_id.qty_incoming_bandengan - point.product_id.qty_onhand_bandengan
+ qty_purchase = point.product_max_qty - point.product_id.qty_incoming_bandengan - point.product_id.qty_available_bandengan
po_line = self.env['purchase.order.line'].search([('product_id', '=', point.product_id.id), ('order_id.state', '=', 'done')], order='id desc', limit=1)
if self.vendor_id:
diff --git a/indoteknik_custom/models/commision.py b/indoteknik_custom/models/commision.py
index eeaa8efc..26b5df37 100644
--- a/indoteknik_custom/models/commision.py
+++ b/indoteknik_custom/models/commision.py
@@ -148,6 +148,7 @@ class CustomerCommision(models.Model):
_order = 'id desc'
_inherit = ['mail.thread']
_rec_name = 'number'
+ _description = 'Customer Benefits'
number = fields.Char(string='Document No', index=True, copy=False, readonly=True)
date_from = fields.Date(string='Date From', required=True)
@@ -175,10 +176,24 @@ class CustomerCommision(models.Model):
('approved', 'Approved'),
('reject', 'Rejected'),
], string='Status')
- commision_percent = fields.Float(string='Commision %', tracking=3)
- commision_amt = fields.Float(string='Commision Amount', tracking=3)
- commision_amt_text = fields.Char(string='Commision Amount Text', compute='compute_delivery_amt_text')
+
+ # commision_percent = fields.Float(string='Commision %', tracking=3)
+ commision_percent = fields.Float(string='Persentase (%)', tracking=3)
+
+ # commision_amt = fields.Float(string='Commision Amount', tracking=3)
+ commision_amt = fields.Float(string='Amount', tracking=3)
+
+ # cashback = fields.Float(string='Cashback', compute="compute_cashback")
+ cashback = fields.Float(string='PPh Cashback', compute="compute_cashback")
+
+ # total_commision = fields.Float(string='Total Commision', compute="compute_cashback")
+ total_commision = fields.Float(string='Cashback yang dibayarkan', compute="compute_cashback")
+
+ total_cashback = fields.Float(string='Total Cashback')
+ commision_amt_text = fields.Char(string='Amount Text', compute='compute_delivery_amt_text')
+ total_cashback_text = fields.Char(string='Cashback Text', compute='compute_total_cashback_text')
total_dpp = fields.Float(string='Total DPP', compute='_compute_total_dpp')
+ biaya_lain_lain = fields.Float(string='Biaya Lain-lain')
commision_type = fields.Selection([
('fee', 'Fee'),
('cashback', 'Cashback'),
@@ -268,6 +283,20 @@ class CustomerCommision(models.Model):
except:
record.commision_amt_text = res
+ def compute_total_cashback_text(self):
+ tb = Terbilang()
+
+ for record in self:
+ res = ''
+ try:
+ if record.total_commision > 0:
+ tb.parse(int(record.total_commision))
+ res = tb.getresult().title()
+ record.total_cashback_text = f"{res} Rupiah" if res else ""
+ except Exception as e:
+ record.total_cashback_text = ""
+ _logger.error("Error computing cashback text: %s", str(e))
+
def _compute_grouped_numbers(self):
for rec in self:
so_numbers = set()
@@ -328,23 +357,48 @@ class CustomerCommision(models.Model):
if self.total_dpp > 0 and self.commision_percent == 0:
self.commision_percent = (self.commision_amt / self.total_dpp) * 100
+ def compute_cashback(self):
+ if self.commision_type == 'cashback' and self.commision_amt > 0:
+ self.cashback = self.commision_amt * 0.15
+ self.total_commision = self.commision_amt * 0.85
+ else:
+ self.cashback = 0
+ self.total_commision = 0
+
def _compute_total_dpp(self):
for data in self:
total_dpp = 0
for line in data.commision_lines:
total_dpp = total_dpp + line.dpp
- data.total_dpp = total_dpp
+ data.total_dpp = total_dpp - data.biaya_lain_lain
@api.model
def create(self, vals):
- vals['number'] = self.env['ir.sequence'].next_by_code('customer.commision') or '0'
- # if vals['commision_amt'] > 0:
- # commision_amt = vals['commision_amt']
- # total_dpp = vals['total_dpp']
- # commision_percent = commision_amt / total_dpp * 100
- # vals['commision_percent'] = commision_percent
- result = super(CustomerCommision, self).create(vals)
- return result
+ commision_type = vals.get('commision_type')
+
+ if commision_type == 'cashback':
+ sequence_code = 'customer.commision.cashback'
+ elif commision_type == 'fee':
+ sequence_code = 'customer.commision.fee'
+ elif commision_type == 'rebate':
+ sequence_code = 'customer.commision.rebate'
+ else:
+ raise UserError('Tipe komisi tidak dikenal!')
+
+ vals['number'] = self.env['ir.sequence'].next_by_code(sequence_code) or '0'
+
+ return super(CustomerCommision, self).create(vals)
+
+ # @api.model
+ # def create(self, vals):
+ # vals['number'] = self.env['ir.sequence'].next_by_code('customer.commision') or '0'
+ # # if vals['commision_amt'] > 0:
+ # # commision_amt = vals['commision_amt']
+ # # total_dpp = vals['total_dpp']
+ # # commision_percent = commision_amt / total_dpp * 100
+ # # vals['commision_percent'] = commision_percent
+ # result = super(CustomerCommision, self).create(vals)
+ # return result
def action_confirm_customer_commision(self):
jakarta_tz = pytz.timezone('Asia/Jakarta')
@@ -354,12 +408,12 @@ class CustomerCommision(models.Model):
if not self.status or self.status == 'draft':
self.status = 'pengajuan1'
- elif self.status == 'pengajuan1' and self.env.user.is_sales_manager:
+ elif self.status == 'pengajuan1' and self.env.user.id == 19:
self.status = 'pengajuan2'
self.approved_by = (self.approved_by + ', ' if self.approved_by else '') + self.env.user.name
self.date_approved_sales = now_naive
self.position_sales = 'Sales Manager'
- elif self.status == 'pengajuan2' and self.env.user.id == 19:
+ elif self.status == 'pengajuan2' and self.env.user.id == 216:
self.status = 'pengajuan3'
self.approved_by = (self.approved_by + ', ' if self.approved_by else '') + self.env.user.name
self.date_approved_marketing = now_naive
diff --git a/indoteknik_custom/models/coretax_fatur.py b/indoteknik_custom/models/coretax_fatur.py
index 92ff1a72..ce94306f 100644
--- a/indoteknik_custom/models/coretax_fatur.py
+++ b/indoteknik_custom/models/coretax_fatur.py
@@ -3,6 +3,9 @@ import xml.etree.ElementTree as ET
from xml.dom import minidom
import base64
import re
+import logging
+
+_logger = logging.getLogger(__name__)
class CoretaxFaktur(models.Model):
@@ -32,7 +35,7 @@ class CoretaxFaktur(models.Model):
return cleaned_number
- def generate_xml(self, invoices=None):
+ def generate_xml(self, invoices=None, down_payments=False):
# Buat root XML
root = ET.Element('TaxInvoiceBulk', {
'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance",
@@ -72,59 +75,96 @@ class CoretaxFaktur(models.Model):
ET.SubElement(tax_invoice, 'BuyerEmail').text = invoice.partner_id.email or ''
ET.SubElement(tax_invoice, 'BuyerIDTKU').text = buyerIDTKU
- # Filter product
- product_lines = invoice.invoice_line_ids.filtered(
- lambda l: not l.display_type and hasattr(l, 'account_id') and
- l.account_id and l.product_id and
- l.account_id.id != self.DISCOUNT_ACCOUNT_ID and
- l.quantity != -1
- )
+ _logger.info(" invoice down_payments: %s", invoice.down_payment)
+ # Handle product lines based on down_payments flag
+ if invoice.down_payment and invoice.invoice_origin:
+ # Get from sale.order.line for down payment
+ sale_order = invoice.sale_id
+ if sale_order:
+ product_lines = sale_order.order_line.filtered(
+ lambda l: l.product_id and not l.is_downpayment and not l.display_type and not l.product_id.id == 229625
+ )
+ # Convert sale order lines to invoice-like format
+ converted_lines = []
+ for line in product_lines:
+ converted_lines.append({
+ 'name': line.name,
+ 'product_id': line.product_id,
+ 'price_subtotal': line.price_subtotal,
+ 'quantity': line.product_uom_qty,
+ 'price_unit': line.price_unit,
+ 'account_id': line.order_id.analytic_account_id or False,
+ })
+ product_lines = converted_lines
+ else:
+ product_lines = []
+ else:
+ # Normal case - get from invoice lines
+ product_lines = invoice.invoice_line_ids.filtered(
+ lambda l: not l.display_type and hasattr(l, 'account_id') and
+ l.account_id and l.product_id and
+ l.account_id.id != self.DISCOUNT_ACCOUNT_ID and
+ l.quantity != -1
+ )
- # Filter discount
+ # Filter discount (always from invoice)
discount_lines = invoice.invoice_line_ids.filtered(
lambda l: not l.display_type and (
- (hasattr(l, 'account_id') and l.account_id and
- l.account_id.id == self.DISCOUNT_ACCOUNT_ID) or
- (l.quantity == -1)
+ (hasattr(l, 'account_id') and l.account_id and
+ l.account_id.id == self.DISCOUNT_ACCOUNT_ID) or
+ (l.quantity == -1)
)
)
- # Calculate total product amount (before discount)
- total_product_amount = sum(line.price_subtotal for line in product_lines)
+ # Calculate totals
+ total_product_amount = sum(line.get('price_subtotal', 0) if isinstance(line, dict)
+ else line.price_subtotal for line in product_lines)
if total_product_amount == 0:
total_product_amount = 1 # Avoid division by zero
- # Calculate total discount amount
total_discount_amount = abs(sum(line.price_subtotal for line in discount_lines))
# Tambahkan elemen ListOfGoodService
list_of_good_service = ET.SubElement(tax_invoice, 'ListOfGoodService')
for line in product_lines:
+ # Handle both dict (converted sale lines) and normal invoice lines
+ if isinstance(line, dict):
+ line_price_subtotal = line['price_subtotal']
+ line_quantity = line['quantity']
+ line_name = line['name']
+ line_price_unit = line['price_unit']
+ else:
+ line_price_subtotal = line.price_subtotal
+ line_quantity = line.quantity
+ line_name = line.name
+ line_price_unit = line.price_unit
+
# Calculate prorated discount
- line_proportion = line.price_subtotal / total_product_amount
+ line_proportion = line_price_subtotal / total_product_amount
line_discount = total_discount_amount * line_proportion
- # unit_price = line.price_unit
- subtotal = line.price_subtotal
- quantity = line.quantity
+ subtotal = line_price_subtotal
+ quantity = line_quantity
total_discount = round(line_discount, 2)
# Calculate other tax values
otherTaxBase = round(subtotal * (11 / 12), 2) if subtotal else 0
vat_amount = round(otherTaxBase * 0.12, 2)
+ price_per_unit = round(subtotal / quantity, 2) if quantity else 0
+
# Create the line in XML
good_service = ET.SubElement(list_of_good_service, 'GoodService')
ET.SubElement(good_service, 'Opt').text = 'A'
ET.SubElement(good_service, 'Code').text = '000000'
- ET.SubElement(good_service, 'Name').text = line.name
+ ET.SubElement(good_service, 'Name').text = line_name
ET.SubElement(good_service, 'Unit').text = 'UM.0018'
- ET.SubElement(good_service, 'Price').text = str(round(subtotal / quantity, 2)) if subtotal else '0'
+ # ET.SubElement(good_service, 'Price').text = str(round(line_price_unit, 2)) if line_price_unit else '0'
+ ET.SubElement(good_service, 'Price').text = str(price_per_unit)
ET.SubElement(good_service, 'Qty').text = str(quantity)
ET.SubElement(good_service, 'TotalDiscount').text = str(total_discount)
- ET.SubElement(good_service, 'TaxBase').text = str(
- round(subtotal)) if subtotal else '0'
+ ET.SubElement(good_service, 'TaxBase').text = str(round(subtotal)) if subtotal else '0'
ET.SubElement(good_service, 'OtherTaxBase').text = str(otherTaxBase)
ET.SubElement(good_service, 'VATRate').text = '12'
ET.SubElement(good_service, 'VAT').text = str(vat_amount)
@@ -136,9 +176,9 @@ class CoretaxFaktur(models.Model):
xml_pretty = minidom.parseString(xml_str).toprettyxml(indent=" ")
return xml_pretty
- def export_to_download(self, invoices):
+ def export_to_download(self, invoices, down_payments):
# Generate XML content
- xml_content = self.generate_xml(invoices)
+ xml_content = self.generate_xml(invoices, down_payments)
# Encode content to Base64
xml_encoded = base64.b64encode(xml_content.encode('utf-8'))
diff --git a/indoteknik_custom/models/dunning_run.py b/indoteknik_custom/models/dunning_run.py
index bb53fc0c..5a6aebac 100644
--- a/indoteknik_custom/models/dunning_run.py
+++ b/indoteknik_custom/models/dunning_run.py
@@ -92,10 +92,23 @@ class DunningRun(models.Model):
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('partner_id', '=', partner.id),
- # ('amount_residual_signed', '>', 0),
('date_kirim_tukar_faktur', '=', False),
]
- invoices = self.env['account.move'].search(query, order='invoice_date')
+ invoices = self.env['account.move'].search(query)
+
+ # sort full berdasarkan tahun, bulan, nomor
+ def invoice_key(x):
+ try:
+ parts = x.name.split('/')
+ tahun = int(parts[1])
+ bulan = int(parts[2])
+ nomor = int(parts[3])
+ return (tahun, bulan, nomor)
+ except Exception:
+ return (0, 0, 0)
+
+ invoices = sorted(invoices, key=invoice_key)
+
count = 0
for invoice in invoices:
self.env['dunning.run.line'].create([{
@@ -123,8 +136,9 @@ class DunningRunLine(models.Model):
_name = 'dunning.run.line'
_description = 'Dunning Run Line'
# _order = 'dunning_id, id'
- _order = 'invoice_id desc, id'
+ _order = 'invoice_number asc, id'
+ invoice_number = fields.Char('Invoice Number', related='invoice_id.name')
dunning_id = fields.Many2one('dunning.run', string='Dunning Ref', required=True, ondelete='cascade', index=True, copy=False)
partner_id = fields.Many2one('res.partner', string='Customer')
invoice_id = fields.Many2one('account.move', string='Invoice')
diff --git a/indoteknik_custom/models/invoice_reklas.py b/indoteknik_custom/models/invoice_reklas.py
index d10d4c31..4ed7f0bf 100644
--- a/indoteknik_custom/models/invoice_reklas.py
+++ b/indoteknik_custom/models/invoice_reklas.py
@@ -11,7 +11,7 @@ _logger = logging.getLogger(__name__)
class InvoiceReklas(models.TransientModel):
_name = 'invoice.reklas'
_description = "digunakan untuk reklas Uang Muka Penjualan"
- reklas_id = fields.Many2one('account.move', string='Nomor CAB')
+ reklas_id = fields.Many2one('account.move', string='Nomor CAB', domain="[('move_type','=','entry')]")
pay_amt = fields.Float(string='Yang dibayarkan')
reklas_type = fields.Selection([
('penjualan', 'Penjualan'),
@@ -20,42 +20,109 @@ class InvoiceReklas(models.TransientModel):
@api.onchange('reklas_type')
def _onchange_reklas_type(self):
- if self.reklas_type == 'penjualan':
- invoices = self.env['account.move'].browse(self._context.get('active_ids', []))
- self.pay_amt = invoices.amount_total
+ active_ids = self._context.get('active_ids', [])
+ if not active_ids:
+ return
+
+ move = self.env['account.move'].browse(active_ids[0])
+ cab = False
+
+ # Deteksi dari mana asal CAB
+ if move.move_type == 'entry':
+ cab = move
+ elif move.move_type == 'in_invoice':
+ if move.reklas_misc_id:
+ cab = move.reklas_misc_id
+ elif move.purchase_order_id and move.purchase_order_id.move_id:
+ cab = move.purchase_order_id.move_id
+
+ # Isi field Nomor CAB jika ditemukan
+ if cab:
+ self.reklas_id = cab.id
+
+ # Nilai yang dibayarkan harus tetap ambil dari invoice/bill
+ self.pay_amt = move.amount_total
+
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ active_ids = self._context.get('active_ids', [])
+ if active_ids:
+ move = self.env['account.move'].browse(active_ids[0])
+ cab = False
+
+ if move.move_type == 'entry':
+ cab = move
+ elif move.move_type == 'in_invoice':
+ if move.reklas_misc_id:
+ cab = move.reklas_misc_id
+ elif move.purchase_order_id and move.purchase_order_id.move_id:
+ cab = move.purchase_order_id.move_id
+
+ if cab:
+ res['reklas_id'] = cab.id
+
+ res['pay_amt'] = move.amount_total
+ return res
+
+
+ # @api.onchange('reklas_type')
+ # def _onchange_reklas_type(self):
+ # if self.reklas_type == 'penjualan':
+ # invoices = self.env['account.move'].browse(self._context.get('active_ids', []))
+ # self.pay_amt = invoices.amount_total
def create_reklas(self):
if not self.reklas_type:
raise UserError('Reklas Tipe harus diisi')
if not self.reklas_id:
raise UserError('Nomor CAB harus diisi')
+
invoices = self.env['account.move'].browse(self._context.get('active_ids', []))
current_time = datetime.now()
+
for invoice in invoices:
- if self.reklas_type == 'penjualan':
- ref_name = 'REKLAS '+self.reklas_id.name+" UANG MUKA PENJUALAN "+invoice.name+" "+invoice.partner_id.name
- else:
- ref_name = 'REKLAS '+self.reklas_id.name+" UANG MUKA PEMBELIAN "+invoice.name+" "+invoice.partner_id.name
- if self.reklas_type == 'penjualan':
- parameters_header = {
- 'ref': ref_name,
- 'date': current_time,
- 'journal_id': 13
- }
- else:
- parameters_header = {
- 'ref': ref_name,
- 'date': current_time,
- 'journal_id': 13
- }
+ # Ambil nama PO jika ada
+ po_name = invoice.purchase_order_id.name if invoice.purchase_order_id else ''
+
+ # Susun nama referensi dengan aman
+ ref_name = 'REKLAS {} UANG MUKA {}{}{} {}'.format(
+ self.reklas_id.name or '',
+ 'PENJUALAN' if self.reklas_type == 'penjualan' else 'PEMBELIAN',
+ f" {invoice.name}" if invoice.name != self.reklas_id.name else '',
+ f" - {po_name}" if po_name else '',
+ invoice.partner_id.name or ''
+ )
+
+ # Header jurnal reklas
+ parameters_header = {
+ 'ref': ref_name,
+ 'date': current_time,
+ 'journal_id': 13
+ }
+
+ if invoice.purchase_order_id:
+ parameters_header['purchase_order_id'] = invoice.purchase_order_id.id
account_move = request.env['account.move'].create([parameters_header])
_logger.info('Success Reklas with %s' % account_move.name)
+ # ✅ Set Bill asal sebagai source document
+ if invoice.move_type == 'in_invoice':
+ account_move.bill_id = invoice.id
+
+ # Tambahkan info asal invoice ke jurnal (opsional)
+ account_move.invoice_origin = invoice.name
+
+ # Simpan hubungan balik ke invoice
+ invoice.reklas_misc_id = account_move.id
+
+ # Buat line debit dan kredit
if self.reklas_type == 'penjualan':
parameter_debit = {
'move_id': account_move.id,
- 'account_id': 668, # penerimaan belum alokasi
+ 'account_id': 668, # penerimaan belum alokasi
'partner_id': invoice.partner_id.id,
'currency_id': 12,
'debit': self.pay_amt,
@@ -71,7 +138,7 @@ class InvoiceReklas(models.TransientModel):
'credit': self.pay_amt,
'name': ref_name
}
- else:
+ else: # pembelian
parameter_debit = {
'move_id': account_move.id,
'account_id': 438,
@@ -90,7 +157,11 @@ class InvoiceReklas(models.TransientModel):
'credit': self.pay_amt,
'name': ref_name
}
+
+ # Simpan journal lines
request.env['account.move.line'].create([parameter_debit, parameter_credit])
+
+ # Tampilkan hasil jurnal reklas
return {
'name': _('Journal Entries'),
'view_mode': 'form',
@@ -100,4 +171,3 @@ class InvoiceReklas(models.TransientModel):
'type': 'ir.actions.act_window',
'res_id': account_move.id
}
- \ No newline at end of file
diff --git a/indoteknik_custom/models/invoice_reklas_penjualan.py b/indoteknik_custom/models/invoice_reklas_penjualan.py
index 80c3ed43..2f5ee160 100644
--- a/indoteknik_custom/models/invoice_reklas_penjualan.py
+++ b/indoteknik_custom/models/invoice_reklas_penjualan.py
@@ -17,43 +17,70 @@ class InvoiceReklasPenjualan(models.TransientModel):
def create_reklas_penjualan(self):
invoices = self.invoice_reklas_line
-
current_time = datetime.now()
account_move_ids = []
- for invoice in invoices:
- ref_name = 'REKLAS ' + invoice.reklas_id.name + " UANG MUKA PENJUALAN " + invoice.name + " " + invoice.partner_id.name
+
+ for line in invoices:
+ # Ambil nama SO jika ada
+ so_name = line.sale_id.name if line.sale_id else ''
+
+ # Susun referensi nama jurnal
+ ref_name = 'REKLAS {} UANG MUKA PENJUALAN {}{} {}'.format(
+ line.reklas_id.name or '',
+ line.name or '',
+ f" - {so_name}" if so_name else '',
+ line.partner_id.name or ''
+ )
+
+ # Header jurnal
parameters_header = {
'ref': ref_name,
'date': current_time,
- 'journal_id': 13
+ 'journal_id': 13,
+ # ⬇️ Tambahkan jika tahu invoice asal (name = ID Bill)
+ 'bill_id': int(line.name) if line.name and line.name.isdigit() else False,
}
account_move = self.env['account.move'].create([parameters_header])
_logger.info('Success Reklas with %s' % account_move.name)
- parameter_debit = {
+ # Simpan info asal (optional)
+ account_move.invoice_origin = line.name
+
+ # Simpan juga ke `reklas_misc_id` jika ditemukan invoice valid
+ if line.name and line.name.isdigit():
+ invoice_id = self.env['account.move'].browse(int(line.name))
+ if invoice_id.exists():
+ invoice_id.reklas_misc_id = account_move.id
+
+ # Buat debit kredit line
+ debit_line = {
'move_id': account_move.id,
- 'account_id': 668, # uang muka penjualan
- 'partner_id': invoice.partner_id.id,
+ 'account_id': 668, # akun penerimaan belum alokasi
+ 'partner_id': line.partner_id.id,
'currency_id': 12,
- 'debit': invoice.pay_amt,
+ 'debit': line.pay_amt,
'credit': 0,
'name': ref_name
}
- parameter_credit = {
+ credit_line = {
'move_id': account_move.id,
- 'account_id': 395,
- 'partner_id': invoice.partner_id.id,
+ 'account_id': 395, # akun pengurang
+ 'partner_id': line.partner_id.id,
'currency_id': 12,
'debit': 0,
- 'credit': invoice.pay_amt,
+ 'credit': line.pay_amt,
'name': ref_name
}
- self.env['account.move.line'].create([parameter_debit, parameter_credit])
+
+ self.env['account.move.line'].create([debit_line, credit_line])
account_move_ids.append(account_move.id)
- invoice.unlink()
-
- self.unlink()
+
+ line.unlink() # bersihkan line setelah selesai
+
+ self.unlink() # hapus wizard utama setelah selesai
+
+ # Tampilkan hasil jurnal reklas
return {
'name': _('Journal Entries'),
'view_mode': 'tree,form',
@@ -63,6 +90,7 @@ class InvoiceReklasPenjualan(models.TransientModel):
'domain': [('id', 'in', account_move_ids)],
}
+
class InvoiceReklasPenjualanLine(models.TransientModel):
_name = 'invoice.reklas.penjualan.line'
_description = "digunakan untuk reklas Uang Muka Penjualan"
diff --git a/indoteknik_custom/models/mail_mail.py b/indoteknik_custom/models/mail_mail.py
index 82b1fcca..792b97b7 100644
--- a/indoteknik_custom/models/mail_mail.py
+++ b/indoteknik_custom/models/mail_mail.py
@@ -1,12 +1,27 @@
from odoo import fields, models, api, _
+from datetime import timedelta
+import logging
+_logger = logging.getLogger(__name__)
class MailMail(models.Model):
_inherit = 'mail.mail'
+ @api.model
def retry_send_mail(self):
- mails = self.env['mail.mail'].search([
- ('state', 'in', ['exception', 'cancel']),
+ now = fields.Datetime.now()
+ seven_days_ago = now - timedelta(days=7)
+
+ # Filter hanya email gagal dalam 7 hari terakhir
+ mails = self.search([
+ ('state', 'in', ['exception']),
+ ('create_date', '>=', seven_days_ago),
+ ('create_date', '<=', now),
], limit=250)
+
+ _logger.info("Found %s failed emails in last 7 days to retry.", len(mails))
+
for mail in mails:
+ _logger.info("Retrying email ID %s - To: %s - Subject: %s",
+ mail.id, mail.email_to, mail.subject)
mail.state = 'outgoing'
diff --git a/indoteknik_custom/models/manufacturing.py b/indoteknik_custom/models/manufacturing.py
index 715d8513..aea01362 100644
--- a/indoteknik_custom/models/manufacturing.py
+++ b/indoteknik_custom/models/manufacturing.py
@@ -11,6 +11,9 @@ class Manufacturing(models.Model):
def action_confirm(self):
if self._name != 'mrp.production':
return super(Manufacturing, self).action_confirm()
+
+ if not self.env.user.is_purchasing_manager:
+ raise UserError("Hanya bisa di confirm oleh Purchasing Manager")
# if self.location_src_id.id != 75:
# raise UserError('Component Location hanya bisa di AS/Stock')
diff --git a/indoteknik_custom/models/mrp_production.py b/indoteknik_custom/models/mrp_production.py
index 14821f27..7977bdf7 100644
--- a/indoteknik_custom/models/mrp_production.py
+++ b/indoteknik_custom/models/mrp_production.py
@@ -110,8 +110,9 @@ class MrpProduction(models.Model):
'picking_type_id': 28, # indoteknik bandengan receipts
'date_order': current_time,
'product_bom_id': self.product_id.id,
- # 'sale_order_id': self.sale_order_id.id,
- 'note_description': 'from Manufacturing Order'
+ 'sale_order_id': self.sale_order.id,
+ 'manufacturing_id': self.id,
+ 'note_description': 'from Manufacturing Order',
}
domain = [
@@ -155,6 +156,8 @@ class MrpProduction(models.Model):
'order_id': new_po.id
}])
+ new_po.button_confirm()
+
self.is_po = True
return po_ids
diff --git a/indoteknik_custom/models/patch/__init__.py b/indoteknik_custom/models/patch/__init__.py
new file mode 100644
index 00000000..051b6537
--- /dev/null
+++ b/indoteknik_custom/models/patch/__init__.py
@@ -0,0 +1 @@
+from . import http_override \ No newline at end of file
diff --git a/indoteknik_custom/models/patch/http_override.py b/indoteknik_custom/models/patch/http_override.py
new file mode 100644
index 00000000..6bec1343
--- /dev/null
+++ b/indoteknik_custom/models/patch/http_override.py
@@ -0,0 +1,45 @@
+import odoo.http
+import json
+import logging
+from werkzeug.exceptions import BadRequest
+import functools
+
+_logger = logging.getLogger(__name__)
+
+class CustomJsonRequest(odoo.http.JsonRequest):
+ def __init__(self, httprequest):
+ super(odoo.http.JsonRequest, self).__init__(httprequest)
+
+ self.params = {}
+ request_data_raw = self.httprequest.get_data().decode(self.httprequest.charset)
+
+ self.jsonrequest = {}
+ if request_data_raw.strip():
+ try:
+ self.jsonrequest = json.loads(request_data_raw)
+ except ValueError:
+ msg = 'Invalid JSON data: %r' % (request_data_raw,)
+ _logger.info('%s: %s (Handled by CustomJsonRequest)', self.httprequest.path, msg)
+ raise BadRequest(msg)
+ else:
+ _logger.info("CustomJsonRequest: Received empty or whitespace-only JSON body. Treating as empty JSON for webhook.")
+
+ self.params = dict(self.jsonrequest.get("params", {}))
+ self.context = self.params.pop('context', dict(self.session.context))
+
+
+_original_get_request = odoo.http.Root.get_request
+
+@functools.wraps(_original_get_request)
+def _get_request_override(self, httprequest):
+ _logger.info("--- DEBUG: !!! _get_request_override IS CALLED !!! ---")
+ _logger.info(f"--- DEBUG: Request Mimetype: {httprequest.mimetype}, Path: {httprequest.path} ---")
+
+ if httprequest.mimetype in ("application/json", "application/json-rpc"):
+ _logger.debug("Odoo HTTP: Using CustomJsonRequest for mimetype: %s", httprequest.mimetype)
+ return CustomJsonRequest(httprequest)
+ else:
+ _logger.debug("Odoo HTTP: Using original get_request for mimetype: %s", httprequest.mimetype)
+ return _original_get_request(self, httprequest)
+
+odoo.http.Root.get_request = _get_request_override \ No newline at end of file
diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py
index 3bb54f44..f59bea6b 100755
--- a/indoteknik_custom/models/product_template.py
+++ b/indoteknik_custom/models/product_template.py
@@ -349,6 +349,9 @@ class ProductTemplate(models.Model):
'search_key':[item_code],
}
response = requests.post(url, headers=headers, json=json_data)
+ if response.status_code != 200:
+ return 0
+
datas = json.loads(response.text)['data']
qty = 0
for data in datas:
@@ -1146,7 +1149,7 @@ class ProductProduct(models.Model):
def _get_qty_free_bandengan(self):
for product in self:
- qty_free = product.qty_onhand_bandengan - product.qty_outgoing_bandengan
+ qty_free = product.qty_onhand_bandengan - product.qty_outgoing_bandengan - product.qty_outgoing_mo_bandengan
product.qty_free_bandengan = qty_free
def _get_max_qty_reordering_rule(self):
diff --git a/indoteknik_custom/models/purchase_order.py b/indoteknik_custom/models/purchase_order.py
index cbfd4acd..45134939 100755
--- a/indoteknik_custom/models/purchase_order.py
+++ b/indoteknik_custom/models/purchase_order.py
@@ -17,6 +17,7 @@ _logger = logging.getLogger(__name__)
class PurchaseOrder(models.Model):
_inherit = 'purchase.order'
+ vcm_id = fields.Many2one('tukar.guling.po', string='Doc VCM', readonly=True, compute='_has_vcm', copy=False)
order_sales_match_line = fields.One2many('purchase.order.sales.match', 'purchase_order_id', string='Sales Match Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True)
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
procurement_status = fields.Char(string='Procurement Status', compute='get_procurement_status', readonly=True)
@@ -65,7 +66,7 @@ class PurchaseOrder(models.Model):
sale_order = fields.Char(string='Sale Order')
matches_so = fields.Many2many('sale.order', string='Matches SO', compute='_compute_matches_so')
is_create_uangmuka = fields.Boolean(string='Uang Muka?')
- move_id = fields.Many2one('account.move', string='Account Move')
+ move_id = fields.Many2one('account.move', string='Journal Entries Uang Muka', domain=[('move_type', '=', 'entry')])
logbook_bill_id = fields.Many2one('report.logbook.bill', string='Logbook Bill')
status_printed = fields.Selection([
('not_printed', 'Belum Print'),
@@ -89,6 +90,112 @@ class PurchaseOrder(models.Model):
store_name = fields.Char(string='Nama Toko')
purchase_order_count = fields.Integer('Purchase Order Count', related='partner_id.purchase_order_count')
+ is_cab_visible = fields.Boolean(string='Tampilkan Tombol CAB', compute='_compute_is_cab_visible')
+
+ # picking_ids = fields.One2many('stock.picking', 'purchase_id', string='Pickings')
+
+ bu_related_count = fields.Integer(
+ string="BU Related Count",
+ compute='_compute_bu_related_count'
+ )
+ manufacturing_id = fields.Many2one('mrp.production', string='Manufacturing Orders')
+
+ def _has_vcm(self):
+ if self.id:
+ self.vcm_id = self.env['tukar.guling.po'].search([('origin', '=', self.name)], limit=1)
+
+ @api.depends('name')
+ def _compute_bu_related_count(self):
+ StockPicking = self.env['stock.picking']
+ for order in self:
+ if not order.name:
+ order.bu_related_count = 0
+ continue
+
+ # Ambil semua BU awal dari PO
+ base_bu = StockPicking.search([
+ ('name', 'ilike', 'BU/'),
+ ('origin', 'ilike', order.name)
+ ])
+
+ all_bu = base_bu
+ seen_names = set(base_bu.mapped('name'))
+
+ # Loop rekursif untuk mencari seluruh return BU
+ while True:
+ next_bu = StockPicking.search([
+ ('name', 'ilike', 'BU/'),
+ ('origin', 'in', ['Return of %s' % name for name in seen_names])
+ ])
+ next_names = set(next_bu.mapped('name'))
+
+ if not next_names - seen_names:
+ break
+
+ all_bu |= next_bu
+ seen_names |= next_names
+
+ order.bu_related_count = len(all_bu)
+
+
+ def action_view_related_bu(self):
+ self.ensure_one()
+
+ StockPicking = self.env['stock.picking']
+
+ # Step 1: cari semua BU pertama (PUT, INT) yang berasal dari PO ini
+ base_bu = StockPicking.search([
+ ('name', 'ilike', 'BU/'),
+ ('origin', 'ilike', self.name)
+ ])
+
+ all_bu = base_bu
+ seen_names = set(base_bu.mapped('name'))
+
+ # Step 2: Loop rekursif cari BU dengan origin 'Return of {name}'
+ while True:
+ next_bu = StockPicking.search([
+ ('name', 'ilike', 'BU/'),
+ ('origin', 'in', ['Return of %s' % name for name in seen_names])
+ ])
+ next_names = set(next_bu.mapped('name'))
+
+ if not next_names - seen_names:
+ break
+
+ all_bu |= next_bu
+ seen_names |= next_names
+
+ return {
+ 'name': 'Related BU (INT/PRT/PUT/VRT)',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'stock.picking',
+ 'view_mode': 'tree,form',
+ 'target': 'current',
+ 'domain': [('id', 'in', list(all_bu.ids))],
+ }
+
+
+ @api.depends('move_id.state')
+ def _compute_is_cab_visible(self):
+ for order in self:
+ move = order.move_id
+ order.is_cab_visible = bool(move and move.state == 'posted')
+
+ def action_view_journal_uangmuka(self):
+ self.ensure_one()
+ if not self.move_id:
+ raise UserError("Journal Uang Muka belum tersedia.")
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Journal Entry',
+ 'res_model': 'account.move',
+ 'res_id': self.move_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
# cek payment term
def _check_payment_term(self):
_logger.info("Check Payment Term Terpanggil")
@@ -430,6 +537,18 @@ class PurchaseOrder(models.Model):
'company_id': self.company_id.id,
'payment_schedule': payment_schedule
}
+
+ receipt = self.env['stock.picking'].search([
+ ('purchase_id', '=', self.id),
+ ('state', '=', 'done'),
+ ('picking_type_code', '=', 'incoming'),
+ ('date_done', '!=', False)
+ ], order='date_done desc', limit=1)
+
+ if receipt:
+ invoice_vals['invoice_date'] = receipt.date_done
+ invoice_vals['date'] = receipt.date_done
+
return invoice_vals
def _compute_matches_so(self):
@@ -558,6 +677,13 @@ class PurchaseOrder(models.Model):
for order in self:
order.has_active_invoice = any(invoice.state != 'cancel' for invoice in order.invoice_ids)
+ # def _compute_has_active_invoice(self):
+ # for order in self:
+ # related_invoices = order.invoice_ids.filtered(
+ # lambda inv: inv.purchase_order_id.id == order.id and inv.move_type == 'in_invoice' and inv.state != 'cancel'
+ # )
+ # order.has_active_invoice = bool(related_invoices)
+
def add_product_to_pricelist(self):
i = 0
for line in self.order_line:
@@ -808,16 +934,20 @@ class PurchaseOrder(models.Model):
# test = line.product_uom_qty
# test2 = line.product_id.plafon_qty
# test3 = test2 + line.product_uom_qty
- if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
- raise UserError('Product '+line.product_id.name+' melebihi plafon, harus Approval MD')
+ if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and not self.env.user.id == 21:
+ raise UserError('Product '+line.product_id.name+' melebihi plafon, harus Approval Rafly')
def check_different_vendor_so_po(self):
vendor_po = self.partner_id.id
for line in self.order_line:
if not line.so_line_id:
continue
- if line.so_line_id.vendor_id.id != vendor_po and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
- raise UserError("Produk "+line.product_id.name+" memiliki vendor berbeda dengan SO (Vendor PO: "+str(self.partner_id.name)+", Vendor SO: "+str(line.so_line_id.vendor_id.name)+")")
+ if line.so_line_id.vendor_id.id != vendor_po:
+ self.env.user.notify_danger(
+ title='WARNING!!!',
+ message="Produk "+line.product_id.name+" memiliki vendor berbeda dengan SO (Vendor PO: "+str(self.partner_id.name)+", Vendor SO: "+str(line.so_line_id.vendor_id.name)+")",
+ sticky=True
+ )
def button_confirm(self):
# self._check_payment_term() # check payment term
@@ -830,23 +960,23 @@ class PurchaseOrder(models.Model):
if self.amount_untaxed >= 50000000 and not self.env.user.id == 21:
raise UserError("Hanya Rafly Hanggara yang bisa approve")
- if self.total_percent_margin < self.total_so_percent_margin and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader:
+ if self.total_percent_margin < self.total_so_percent_margin:
self.env.user.notify_danger(
title='WARNING!!!',
message='Beda Margin dengan Sale Order',
sticky=True
)
- if len(self.order_sales_match_line) == 0 and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader:
- self.env.user.notify_danger(
- title='WARNING!!!',
- message='Tidak ada matches SO',
- sticky=True
- )
+ # if len(self.order_sales_match_line) == 0 and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader:
+ # self.env.user.notify_danger(
+ # title='WARNING!!!',
+ # message='Tidak ada matches SO',
+ # sticky=True
+ # )
if not self.from_apo:
- if not self.matches_so and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader:
- raise UserError("Tidak ada link dengan SO, harus approval Merchandise")
+ if (not self.matches_so or not self.sale_order_id) and not self.env.user.is_purchasing_manager and not self.env.user.is_leader and not self.manufacturing_id:
+ raise UserError("Tidak ada link dengan SO, harus di confirm oleh Purchasing Manager")
send_email = False
if not self.not_update_purchasepricelist:
@@ -891,6 +1021,12 @@ class PurchaseOrder(models.Model):
if self.product_bom_id:
self._remove_product_bom()
+ # Tambahan: redirect ke BU hanya untuk single PO yang berhasil dikonfirmasi
+ _logger.info("Jumlah PO: %s | State: %s", len(self), self.state)
+ if len(self) == 1:
+ _logger.info("Redirecting ke BU")
+ return self.action_view_related_bu()
+
return res
def _remove_product_bom(self):
@@ -912,7 +1048,7 @@ class PurchaseOrder(models.Model):
for line in self.order_line:
if line.taxes_id != reference_taxes:
- raise UserError("PPN harus sama untuk semua baris pada line.")
+ raise UserError(f"PPN harus sama untuk semua baris pada line {line.product_id.name}")
def check_data_vendor(self):
vendor = self.partner_id
@@ -998,7 +1134,7 @@ class PurchaseOrder(models.Model):
self.approval_status_unlock = 'approvedFinance'
else:
raise UserError("Bisa langsung Confirm, menunggu persetujuan Finance jika ingin unlock PO")
- elif self.env.user.is_leader or self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
+ elif self.env.user.is_leader or self.env.user.has_group('indoteknik_custom.group_role_purchasing'):
raise UserError("Bisa langsung Confirm")
elif self.total_percent_margin == self.total_so_percent_margin and self.matches_so and not greater_than_plafon and not different_vendor_message:
raise UserError("Bisa langsung Confirm")
diff --git a/indoteknik_custom/models/purchase_order_sales_match.py b/indoteknik_custom/models/purchase_order_sales_match.py
index 0bd0092b..b18864f3 100644
--- a/indoteknik_custom/models/purchase_order_sales_match.py
+++ b/indoteknik_custom/models/purchase_order_sales_match.py
@@ -28,6 +28,20 @@ class PurchaseOrderSalesMatch(models.Model):
purchase_price_po = fields.Float('Purchase Price PO', compute='_compute_purchase_price_po')
purchase_line_id = fields.Many2one('purchase.order.line', string='Purchase Line', compute='_compute_purchase_line_id')
hold_outgoing_so = fields.Boolean(string='Hold Outgoing SO', related='sale_id.hold_outgoing')
+ bu_pick = fields.Many2one('stock.picking', string='BU Pick', compute='compute_bu_pick')
+
+ def compute_bu_pick(self):
+ for rec in self:
+ stock_move = self.env['stock.move'].search([
+ ('reference', 'ilike', 'BU/PICK'),
+ ('state', 'in', ['confirmed','waiting','partially_available']),
+ ('product_id', '=', rec.product_id.id),
+ ('sale_line_id', '=', rec.sale_line_id.id),
+ ])
+ if stock_move:
+ rec.bu_pick = stock_move.picking_id.id
+ else:
+ rec.bu_pick = None
def _compute_purchase_line_id(self):
for line in self:
diff --git a/indoteknik_custom/models/purchasing_job.py b/indoteknik_custom/models/purchasing_job.py
index ea2f46cb..db733b5a 100644
--- a/indoteknik_custom/models/purchasing_job.py
+++ b/indoteknik_custom/models/purchasing_job.py
@@ -26,6 +26,46 @@ class PurchasingJob(models.Model):
purchase_representative_id = fields.Many2one('res.users', string="Purchase Representative", readonly=True)
note = fields.Char(string="Note Detail")
date_po = fields.Datetime(string='Date PO', copy=False)
+ so_number = fields.Text(string='SO Number', copy=False)
+ check_pj = fields.Boolean(compute='_get_check_pj', string='Linked')
+
+ def action_open_job_detail(self):
+ self.ensure_one()
+ Seen = self.env['purchasing.job.seen']
+ seen = Seen.search([
+ ('user_id', '=', self.env.uid),
+ ('product_id', '=', self.product_id.id)
+ ], limit=1)
+
+ if seen:
+ seen.so_snapshot = self.so_number
+ seen.seen_date = fields.Datetime.now()
+ else:
+ Seen.create({
+ 'user_id': self.env.uid,
+ 'product_id': self.product_id.id,
+ 'so_snapshot': self.so_number,
+ })
+
+ return {
+ 'name': 'Purchasing Job Detail',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'v.purchasing.job',
+ 'res_id': self.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+
+ @api.depends('so_number')
+ def _get_check_pj(self):
+ for rec in self:
+ seen = self.env['purchasing.job.seen'].search([
+ ('user_id', '=', self.env.uid),
+ ('product_id', '=', rec.product_id.id)
+ ], limit=1)
+ rec.check_pj = bool(seen and seen.so_snapshot == rec.so_number)
+
def unlink(self):
# Example: Delete related records from the underlying model
@@ -66,6 +106,7 @@ class PurchasingJob(models.Model):
max(pjs.status_apo::text) AS status_apo,
max(pjs.note::text) AS note,
max(pjs.date_po::text) AS date_po,
+ pmp.so_number,
CASE
WHEN pmp.brand IN ('Tekiro', 'RYU', 'Rexco', 'RYU (Sparepart)') THEN 27
WHEN sub.vendor_id = 9688 THEN 397
@@ -83,7 +124,7 @@ class PurchasingJob(models.Model):
group by vso.product_id
) sub ON sub.product_id = pmp.product_id
WHERE pmp.action = 'kurang'::text AND sub.vendor_id IS NOT NULL
- GROUP BY pmp.product_id, pmp.brand, pmp.item_code, pmp.product, pmp.action, sub.vendor_id;
+ GROUP BY pmp.product_id, pmp.brand, pmp.item_code, pmp.product, pmp.action, sub.vendor_id, pmp.so_number;
""" % self._table)
def open_form_multi_generate_request_po(self):
@@ -197,3 +238,17 @@ class OutstandingSales(models.Model):
and sp.name like '%OUT%'
)
""")
+
+class PurchasingJobSeen(models.Model):
+ _name = 'purchasing.job.seen'
+ _description = 'User Seen SO Snapshot'
+ _rec_name = 'product_id'
+
+ user_id = fields.Many2one('res.users', required=True, ondelete='cascade')
+ product_id = fields.Many2one('product.product', required=True, ondelete='cascade')
+ so_snapshot = fields.Text("Last Seen SO")
+ seen_date = fields.Datetime(default=fields.Datetime.now)
+
+ _sql_constraints = [
+ ('user_product_unique', 'unique(user_id, product_id)', 'User already tracked this product.')
+ ]
diff --git a/indoteknik_custom/models/refund_sale_order.py b/indoteknik_custom/models/refund_sale_order.py
new file mode 100644
index 00000000..11bfd07f
--- /dev/null
+++ b/indoteknik_custom/models/refund_sale_order.py
@@ -0,0 +1,653 @@
+from odoo import fields, models, api, _
+from datetime import date, datetime
+from terbilang import Terbilang
+from odoo.exceptions import UserError, ValidationError
+from markupsafe import escape as html_escape
+import pytz
+from lxml import etree
+
+
+class RefundSaleOrder(models.Model):
+ _name = 'refund.sale.order'
+ _description = 'Refund Sales Order'
+ _inherit = ['mail.thread']
+ _rec_name = 'name'
+
+ name = fields.Char(string='Refund Number', default='New', copy=False, readonly=True)
+ note_refund = fields.Text(string='Note Refund')
+ sale_order_ids = fields.Many2many('sale.order', string='Sales Order Numbers')
+ uang_masuk = fields.Float(string='Uang Masuk', required=True)
+ total_invoice = fields.Float(string='Total Invoice')
+ ongkir = fields.Float(string='Ongkir', required=True, default=0.0)
+ amount_refund = fields.Float(string='Total Refund', required=True)
+ amount_refund_text = fields.Char(string='Total Refund Text', compute='_compute_refund_text')
+ user_ids = fields.Many2many('res.users', string='Salespersons', compute='_compute_user_ids', domain=[('active', 'in', [True, False])])
+ create_uid = fields.Many2one('res.users', string='Created By', readonly=True)
+ created_date = fields.Date(string='Tanggal Request Refund', readonly=True)
+ status = fields.Selection([
+ ('draft', 'Draft'),
+ ('pengajuan1', 'Approval Sales Manager'),
+ ('pengajuan2', 'Approval AR'),
+ ('pengajuan3', 'Approval Pimpinan'),
+ ('reject', 'Cancel'),
+ ('refund', 'Approved')
+ ], string='Status Refund', default='draft', tracking=True)
+
+ status_payment = fields.Selection([
+ ('pending', 'Pending'),
+ ('reject', 'Cancel'),
+ ('done', 'Payment')
+ ], string='Status Payment', default='pending', tracking=True)
+
+ reason_reject = fields.Text(string='Reason Cancel')
+ refund_date = fields.Date(string='Tanggal Refund')
+ invoice_ids = fields.Many2many('account.move', string='Invoices')
+ bank = fields.Char(string='Bank', required=True)
+ account_name = fields.Char(string='Account Name', required=True)
+ account_no = fields.Char(string='Account No', required=True)
+ finance_note = fields.Text(string='Finance Note')
+ invoice_names = fields.Html(string="Group Invoice Number", compute="_compute_invoice_names")
+ so_names = fields.Html(string="Group SO Number", compute="_compute_so_names")
+
+ refund_type = fields.Selection([
+ ('barang_kosong_sebagian', 'Refund Barang Kosong Sebagian'),
+ ('barang_kosong', 'Refund Barang Kosong Full'),
+ ('uang', 'Refund Lebih Bayar'),
+ ('retur_half', 'Refund Retur Sebagian'),
+ ('retur', 'Refund Retur Full'),
+ ('lainnya', 'Lainnya')
+ ], string='Refund Type', required=True)
+
+ refund_type_display = fields.Char(string="Refund Type Label", compute="_compute_refund_type_display")
+
+ line_ids = fields.One2many('refund.sale.order.line', 'refund_id', string='Refund Lines')
+ invoice_line_ids = fields.One2many(
+ comodel_name='account.move.line',
+ inverse_name='move_id',
+ string='Invoice Lines',
+ compute='_compute_invoice_lines'
+ )
+
+ approved_by = fields.Text(string='Approved By', readonly=True)
+ date_approved_sales = fields.Datetime(string='Date Approved (Sales Manager)', readonly=True)
+ date_approved_ar = fields.Datetime(string='Date Approved (AR)', readonly=True)
+ date_approved_pimpinan = fields.Datetime(string='Date Approved (Pimpinan)', readonly=True)
+ position_sales = fields.Char(string='Position Sales', readonly=True)
+ position_ar = fields.Char(string='Position AR', readonly=True)
+ position_pimpinan = fields.Char(string='Position Pimpinan', readonly=True)
+
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Customer',
+ required=True
+ )
+ advance_move_names = fields.Html(string="Group Journal SO", compute="_compute_advance_move_names")
+ uang_masuk_type = fields.Selection([
+ ('pdf', 'PDF'),
+ ('image', 'Image'),
+ ], string="Attachment Type", default='image')
+ bukti_refund_type = fields.Selection([
+ ('pdf', 'PDF'),
+ ('image', 'Image'),
+ ], string="Attachment Type", default='image')
+ bukti_uang_masuk_image = fields.Binary(string="Upload Bukti Uang Masuk")
+ bukti_transfer_refund_image = fields.Binary(string="Upload Bukti Transfer Refund")
+ bukti_uang_masuk_pdf = fields.Binary(string="Upload Bukti Uang Masuk")
+ bukti_transfer_refund_pdf = fields.Binary(string="Upload Bukti Transfer Refund")
+ journal_refund_move_id = fields.Many2one(
+ 'account.move',
+ string='Journal Refund',
+ compute='_compute_journal_refund_move_id',
+ )
+ journal_refund_state = fields.Selection(
+ related='journal_refund_move_id.state',
+ string='Journal Refund State',
+ )
+
+ is_locked = fields.Boolean(string="Locked", compute="_compute_is_locked")
+ sale_order_names_jasper = fields.Char(string='Sales Order List', compute='_compute_order_invoice_names')
+ invoice_names_jasper = fields.Char(string='Invoice List', compute='_compute_order_invoice_names')
+
+
+
+ @api.depends('refund_type')
+ def _compute_refund_type_display(self):
+ for rec in self:
+ rec.refund_type_display = dict(self.fields_get(allfields=['refund_type'])['refund_type']['selection']).get(rec.refund_type, '')
+
+
+ @api.model
+ def create(self, vals):
+ allowed_user_ids = [23, 19, 688, 7]
+ if not (
+ self.env.user.has_group('indoteknik_custom.group_role_sales') or
+ self.env.user.has_group('indoteknik_custom.group_role_fat') or
+ self.env.user.id not in allowed_user_ids
+ ):
+ raise UserError("❌ Hanya user Sales dan Finance yang boleh membuat refund.")
+
+
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code('refund.sale.order') or 'New'
+
+ vals['created_date'] = fields.Date.context_today(self)
+ vals['create_uid'] = self.env.user.id
+
+ if 'sale_order_ids' in vals:
+ so_cmd = vals['sale_order_ids']
+ so_ids = so_cmd[0][2] if so_cmd and so_cmd[0][0] == 6 else []
+ if so_ids:
+ sale_orders = self.env['sale.order'].browse(so_ids)
+ vals['partner_id'] = sale_orders[0].partner_id.id
+
+ invoices = sale_orders.mapped('invoice_ids').filtered(
+ lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel'
+ )
+ if invoices:
+ vals['invoice_ids'] = [(6, 0, invoices.ids)]
+
+
+ refund_type = vals.get('refund_type')
+ invoice_ids_data = vals.get('invoice_ids', [])
+ invoice_ids = invoice_ids_data[0][2] if invoice_ids_data and invoice_ids_data[0][0] == 6 else []
+
+ if invoice_ids and refund_type and refund_type not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']:
+ raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice")
+
+ if not invoice_ids and refund_type and refund_type in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']:
+ raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice")
+
+
+ if not so_ids and refund_type != 'lainnya':
+ raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.")
+
+ refund = refund_type in ['retur', 'retur_half']
+ if refund and so_ids:
+ so = self.env['sale.order'].browse(so_ids)
+ pickings = self.env['stock.picking'].search([
+ ('state', '=', 'done'),
+ ('picking_type_id', '=', 73),
+ ('sale_id', 'in', so_ids)
+ ])
+ if not pickings:
+ raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.")
+
+ if refund_type == 'retur_half' and not invoice_ids:
+ raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk Retur Sebagian.")
+
+ total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total')) if invoice_ids else 0.0
+ uang_masuk = vals.get('uang_masuk', 0.0)
+ ongkir = vals.get('ongkir', 0.0)
+ pengurangan = total_invoice + ongkir
+
+ if uang_masuk > pengurangan:
+ vals['amount_refund'] = uang_masuk - pengurangan
+ else:
+ raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir untuk melakukan refund")
+
+ return super().create(vals)
+
+
+ def write(self, vals):
+ allowed_user_ids = [23, 19, 688, 7]
+ if not (
+ self.env.user.has_group('indoteknik_custom.group_role_sales') or
+ self.env.user.has_group('indoteknik_custom.group_role_fat') or
+ self.env.user.id in allowed_user_ids
+ ):
+ raise UserError("❌ Hanya user Sales dan Finance yang boleh mengedit refund.")
+
+ for rec in self:
+ if 'sale_order_ids' in vals:
+ so_commands = vals['sale_order_ids']
+ so_ids = []
+ for cmd in so_commands:
+ if cmd[0] == 6:
+ so_ids = cmd[2]
+ elif cmd[0] == 4:
+ so_ids.append(cmd[1])
+ elif cmd[0] == 3:
+ if cmd[1] in so_ids:
+ so_ids.remove(cmd[1])
+
+ if so_ids:
+ sale_orders = self.env['sale.order'].browse(so_ids)
+ vals['partner_id'] = sale_orders[0].partner_id.id
+
+ sale_orders = self.env['sale.order'].browse(so_ids)
+
+ valid_invoices = sale_orders.mapped('invoice_ids').filtered(
+ lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel'
+ )
+ vals['invoice_ids'] = [(6, 0, valid_invoices.ids)]
+ vals['ongkir'] = sum(so.delivery_amt or 0.0 for so in sale_orders)
+ else:
+ so_ids = rec.sale_order_ids.ids
+
+ sale_orders = self.env['sale.order'].browse(so_ids)
+
+
+ refund_type = vals.get('refund_type', rec.refund_type)
+
+ if not so_ids and refund_type != 'lainnya':
+ raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.")
+
+
+ invoice_ids = vals.get('invoice_ids', False)
+ if invoice_ids:
+ final_invoice_ids = []
+ for cmd in invoice_ids:
+ if cmd[0] == 6:
+ final_invoice_ids = cmd[2]
+ elif cmd[0] == 4:
+ final_invoice_ids.append(cmd[1])
+ invoice_ids = final_invoice_ids
+ else:
+ invoice_ids = rec.invoice_ids.ids
+
+ if invoice_ids and vals.get('refund_type', rec.refund_type) not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']:
+ raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice")
+
+ if not invoice_ids and vals.get('refund_type', rec.refund_type) in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']:
+ raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice")
+
+ if refund_type in ['retur', 'retur_half'] and so_ids:
+ so = self.env['sale.order'].browse(so_ids)
+ pickings = self.env['stock.picking'].search([
+ ('state', '=', 'done'),
+ ('picking_type_id', '=', 73),
+ ('sale_id', 'in', so_ids)
+ ])
+
+ if not pickings:
+ raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.")
+
+ if refund_type == 'retur_half' and not invoice_ids:
+ raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk retur sebagian.")
+
+ if any(field in vals for field in ['uang_masuk', 'invoice_ids', 'ongkir', 'sale_order_ids']):
+ total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total'))
+ uang_masuk = vals.get('uang_masuk', rec.uang_masuk)
+ ongkir = vals.get('ongkir', rec.ongkir)
+
+ if uang_masuk <= (total_invoice + ongkir):
+ raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir")
+ vals['amount_refund'] = uang_masuk - (total_invoice + ongkir)
+
+ if vals.get('status') == 'refund' and not vals.get('refund_date'):
+ vals['refund_date'] = fields.Date.context_today(self)
+
+ return super().write(vals)
+
+ @api.depends('status_payment')
+ def _compute_is_locked(self):
+ for rec in self:
+ rec.is_locked = rec.status_payment in ['done', 'reject']
+
+ @api.depends('sale_order_ids.name', 'invoice_ids.name')
+ def _compute_order_invoice_names(self):
+ for rec in self:
+ rec.sale_order_names_jasper = ', '.join(rec.sale_order_ids.mapped('name')) or ''
+ rec.invoice_names_jasper = ', '.join(rec.invoice_ids.mapped('name')) or ''
+
+ @api.depends('sale_order_ids')
+ def _compute_advance_move_names(self):
+ for rec in self:
+ move_links = []
+ moves = self.env['account.move'].search([
+ ('sale_id', 'in', rec.sale_order_ids.ids),
+ ('journal_id', '=', 11),
+ ('state', '=', 'posted')
+ ])
+ for move in moves:
+ url = f"/web#id={move.id}&model=account.move&view_type=form"
+ name = html_escape(move.name or 'Unnamed')
+ move_links.append(f'<a href="{url}" target="_blank">{name}</a>')
+ rec.advance_move_names = ', '.join(move_links) if move_links else "-"
+
+ @api.depends('sale_order_ids.user_id')
+ def _compute_user_ids(self):
+ for rec in self:
+ user_ids = list({so.user_id.id for so in rec.sale_order_ids if so.user_id})
+ rec.user_ids = [(6, 0, user_ids)]
+
+ @api.onchange('sale_order_ids')
+ def _onchange_sale_order_ids(self):
+ self.invoice_ids = [(5, 0, 0)]
+ self.line_ids = [(5, 0, 0)]
+ self.ongkir = 0.0
+ all_invoices = self.env['account.move']
+ total_invoice = 0.0
+
+ for so in self.sale_order_ids:
+ self.ongkir += so.delivery_amt or 0.0
+ valid_invoices = so.invoice_ids.filtered(
+ lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel'
+ )
+ all_invoices |= valid_invoices
+ total_invoice += sum(valid_invoices.mapped('amount_total'))
+
+
+ self.invoice_ids = all_invoices
+ self.total_invoice = total_invoice
+ self.refund_type = 'uang' if all_invoices else False
+
+ pengurangan = total_invoice + self.ongkir
+ if self.uang_masuk > pengurangan:
+ self.amount_refund = self.uang_masuk - pengurangan
+ else:
+ self.amount_refund = 0.0
+
+ if self.sale_order_ids:
+ self.partner_id = self.sale_order_ids[0].partner_id
+
+
+ @api.onchange('refund_type')
+ def _onchange_refund_type(self):
+ self.line_ids = [(5, 0, 0)]
+ if self.refund_type in ['barang_kosong_sebagian', 'barang_kosong'] and self.sale_order_ids:
+ line_vals = []
+ for so in self.sale_order_ids:
+ for line in so.order_line:
+ if line.qty_delivered == 0:
+ line_vals.append((0, 0, {
+ 'product_id': line.product_id.id,
+ 'quantity': line.product_uom_qty,
+ 'reason': '',
+ }))
+
+ self.line_ids = line_vals
+
+ elif self.refund_type in ['retur', 'retur_half'] and self.sale_order_ids:
+ line_vals = []
+ StockPicking = self.env['stock.picking']
+ for so in self.sale_order_ids:
+ pickings = StockPicking.search([
+ ('state', '=', 'done'),
+ ('picking_type_id', '=', 73),
+ ('sale_id', 'in', so.ids)
+ ])
+
+ for picking in pickings:
+ for move in picking.move_lines:
+ line_vals.append((0, 0, {
+ 'product_id': move.product_id.id,
+ 'quantity': move.product_uom_qty,
+ 'reason': '',
+ }))
+ self.line_ids = line_vals
+
+
+ @api.depends('invoice_ids')
+ def _compute_invoice_lines(self):
+ for rec in self:
+ lines = self.env['account.move.line']
+ for inv in rec.invoice_ids:
+ lines |= inv.invoice_line_ids
+ rec.invoice_line_ids = lines
+
+ @api.depends('amount_refund')
+ def _compute_refund_text(self):
+ tb = Terbilang()
+ for record in self:
+ res = ''
+ try:
+ if record.amount_refund > 0:
+ tb.parse(int(record.amount_refund))
+ res = tb.getresult().title()
+ record.amount_refund_text = res + ' Rupiah'
+ except:
+ record.amount_refund_text = ''
+
+ def unlink(self):
+ not_draft = self.filtered(lambda r: r.status != 'draft')
+ if not_draft:
+ names = ', '.join(not_draft.mapped('name'))
+ raise UserError(f"Refund hanya bisa dihapus jika statusnya masih draft.\nTidak bisa hapus: {names}")
+ return super().unlink()
+
+ @api.depends('invoice_ids')
+ def _compute_invoice_names(self):
+ for rec in self:
+ names = []
+ for inv in rec.invoice_ids:
+ url = f"/web#id={inv.id}&model=account.move&view_type=form"
+ name = html_escape(inv.name)
+ names.append(f'<a href="{url}" target="_blank">{name}</a>')
+ rec.invoice_names = ', '.join(names)
+
+
+ @api.depends('sale_order_ids')
+ def _compute_so_names(self):
+ for rec in self:
+ so_links = []
+ for so in rec.sale_order_ids:
+ url = f"/web#id={so.id}&model=sale.order&view_type=form"
+ name = html_escape(so.name)
+ so_links.append(f'<a href="{url}" target="_blank">{name}</a>')
+ rec.so_names = ', '.join(so_links) if so_links else "-"
+
+ @api.onchange('uang_masuk', 'total_invoice', 'ongkir')
+ def _onchange_amount_refund(self):
+ for rec in self:
+ pengurangan = rec.total_invoice + rec.ongkir
+ refund = rec.uang_masuk - pengurangan
+ rec.amount_refund = refund if refund > 0 else 0.0
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ sale_order_id = self.env.context.get('default_sale_order_id')
+ if sale_order_id:
+ so = self.env['sale.order'].browse(sale_order_id)
+ res['sale_order_ids'] = [(6, 0, [so.id])]
+ invoice_ids = so.invoice_ids.filtered(
+ lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel'
+ ).ids
+ res['invoice_ids'] = [(6, 0, invoice_ids)]
+ res['uang_masuk'] = 0.0
+ res['ongkir'] = so.delivery_amt or 0.0
+ line_vals = []
+ for line in so.order_line:
+ line_vals.append((0, 0, {
+ 'product_id': line.product_id.id,
+ 'quantity': line.product_uom_qty,
+ 'reason': '',
+ }))
+ res['line_ids'] = line_vals
+ res['refund_type'] = 'uang' if invoice_ids else False
+ return res
+
+ @api.onchange('invoice_ids')
+ def _onchange_invoice_ids(self):
+ if self.invoice_ids:
+ if self.refund_type not in ['uang', 'barang_kosong']:
+ self.refund_type = False
+
+ self.total_invoice = sum(self.invoice_ids.mapped('amount_total'))
+
+ def action_ask_approval(self):
+ for rec in self:
+ if rec.status == 'draft':
+ rec.status = 'pengajuan1'
+
+
+ def _get_status_label(self, code):
+ status_dict = dict(self.fields_get(allfields=['status'])['status']['selection'])
+ return status_dict.get(code, code)
+
+ def action_approve_flow(self):
+ jakarta_tz = pytz.timezone('Asia/Jakarta')
+ now = datetime.now(jakarta_tz).replace(tzinfo=None)
+
+ for rec in self:
+ user_name = self.env.user.name
+
+ if not rec.status or rec.status == 'draft':
+ rec.status = 'pengajuan1'
+
+ elif rec.status == 'pengajuan1' and self.env.user.id == 19:
+ rec.status = 'pengajuan2'
+ rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name
+ rec.date_approved_sales = now
+ rec.position_sales = 'Sales Manager'
+
+ elif rec.status == 'pengajuan2' and self.env.user.id == 688:
+ rec.status = 'pengajuan3'
+ rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name
+ rec.date_approved_ar = now
+ rec.position_ar = 'AR'
+
+ elif rec.status == 'pengajuan3' and self.env.user.id == 7:
+ rec.status = 'refund'
+ rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name
+ rec.date_approved_pimpinan = now
+ rec.position_pimpinan = 'Pimpinan'
+ rec.refund_date = fields.Date.context_today(self)
+
+ else:
+ raise UserError("❌ Hanya bisa diapproved oleh yang bersangkutan.")
+
+ def action_trigger_cancel(self):
+ is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat')
+ allowed_user_ids = [19, 688, 7]
+ for rec in self:
+ if self.user.id not in allowed_user_ids and not is_fat:
+ raise UserError("❌ Hanya user yang bersangkutan atau Finance (FAT) yang bisa melakukan penolakan.")
+ if rec.status not in ['refund', 'reject']:
+ rec.status = 'reject'
+ rec.status_payment = 'reject'
+
+ @api.constrains('status', 'reason_reject')
+ def _check_reason_if_rejected(self):
+ for rec in self:
+ if rec.status == 'reject' and not rec.reason_reject:
+ raise ValidationError("Alasan pembatalan harus diisi ketika status Reject.")
+
+ def action_confirm_refund(self):
+ is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat')
+ for rec in self:
+ if not is_fat:
+ raise UserError("Hanya Finance yang dapat mengkonfirmasi refund.")
+ if rec.status_payment == 'pending':
+ rec.status_payment = 'done'
+ rec.refund_date = fields.Date.context_today(self)
+ else:
+ raise UserError("Refund hanya bisa dikonfirmasi setelah Approval Pimpinan.")
+
+ def _compute_approval_label(self):
+ for rec in self:
+ label = 'Approval Done'
+ if rec.status == 'draft':
+ label = 'Approval Sales Manager'
+ elif rec.status == 'pengajuan1':
+ label = 'Approval AR'
+ elif rec.status == 'pengajuan2':
+ label = 'Approval Pimpinan'
+ elif rec.status == 'pengajuan3':
+ label = 'Confirm Refund'
+ rec.approval_button_label = label
+
+ def action_create_journal_refund(self):
+ is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat')
+ if not is_fat:
+ raise UserError("❌ Akses ditolak. Hanya Finance yang dapat membuat journal refund.")
+
+ for refund in self:
+ current_time = fields.Datetime.now()
+ has_invoice = any(refund.sale_order_ids.mapped('invoice_ids'))
+ # Penentuan partner (dari SO atau partner_id langsung)
+ partner = (
+ refund.sale_order_ids[0].partner_id.parent_id or
+ refund.sale_order_ids[0].partner_id
+ ) if refund.sale_order_ids else refund.partner_id
+
+ # Ambil label refund type
+ refund_type_label = dict(
+ self.fields_get(allfields=['refund_type'])['refund_type']['selection']
+ ).get(refund.refund_type, '').replace("Refund ", "").upper()
+
+
+
+ if not partner:
+ raise UserError("❌ Partner tidak ditemukan.")
+
+ # Ref format
+ ref_text = f"REFUND {refund_type_label} {refund.name or ''} {partner.display_name}".upper()
+
+ # Buat Account Move (Journal Entry)
+ account_move = self.env['account.move'].create({
+ 'ref': ref_text,
+ 'date': current_time,
+ 'journal_id': 11,
+ 'refund_id': refund.id,
+ 'refund_so_ids': [(6, 0, refund.sale_order_ids.ids)],
+ 'partner_id': partner.id,
+ })
+
+ amount = refund.amount_refund
+
+ second_account_id = 450 if has_invoice else 668
+
+ debit_line = {
+ 'move_id': account_move.id,
+ 'account_id': second_account_id,
+ 'partner_id': partner.id,
+ 'currency_id': 12,
+ 'debit': amount,
+ 'credit': 0.0,
+ 'name': ref_text,
+ }
+
+ credit_line = {
+ 'move_id': account_move.id,
+ 'account_id': 389, # Intransit BCA
+ 'partner_id': partner.id,
+ 'currency_id': 12,
+ 'debit': 0.0,
+ 'credit': amount,
+ 'name': ref_text,
+ }
+
+ self.env['account.move.line'].create([debit_line, credit_line])
+
+ return {
+ 'name': _('Journal Entries'),
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': account_move.id,
+ 'target': 'current'
+ }
+
+ def _compute_journal_refund_move_id(self):
+ for rec in self:
+ move = self.env['account.move'].search([
+ ('refund_id', '=', rec.id)
+ ], limit=1)
+ rec.journal_refund_move_id = move
+
+ def action_open_journal_refund(self):
+ self.ensure_one()
+ if self.journal_refund_move_id:
+ return {
+ 'name': _('Journal Refund'),
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'res_id': self.journal_refund_move_id.id,
+ 'target': 'current'
+ }
+
+
+
+
+class RefundSaleOrderLine(models.Model):
+ _name = 'refund.sale.order.line'
+ _description = 'Refund Sales Order Line'
+ _inherit = ['mail.thread']
+
+ refund_id = fields.Many2one('refund.sale.order', string='Refund Ref')
+ product_id = fields.Many2one('product.product', string='Product')
+ quantity = fields.Float(string='Qty')
+ reason = fields.Char(string='Reason')
diff --git a/indoteknik_custom/models/requisition.py b/indoteknik_custom/models/requisition.py
index 74236850..bcdafb12 100644
--- a/indoteknik_custom/models/requisition.py
+++ b/indoteknik_custom/models/requisition.py
@@ -48,8 +48,8 @@ class Requisition(models.Model):
is_po = fields.Boolean(string='Is PO')
requisition_match = fields.One2many('requisition.purchase.match', 'requisition_id', string='Matches', auto_join=True)
sale_order_id = fields.Many2one('sale.order', string='SO', help='harus diisi nomor SO yang ingin digenerate')
- sales_approve = fields.Boolean(string='Sales Approve', tracking=3, copy=False)
- merchandise_approve = fields.Boolean(string='Merchandise Approve', tracking=3, copy=False)
+ sales_approve = fields.Boolean(string='Approval Status', tracking=3, copy=False)
+ merchandise_approve = fields.Boolean(string='Approval Status', tracking=3, copy=False)
def generate_requisition_from_so(self):
state = ['done', 'sale']
@@ -82,20 +82,16 @@ class Requisition(models.Model):
state = ['done', 'sale']
if self.sale_order_id.state in state:
raise UserError('SO sudah Confirm, akan berakibat double Purchase melalui PJ')
- if self.env.user.id not in [377, 19, 28]:
- raise UserError('Hanya Vita dan Darren Yang Bisa Approve')
- if self.env.user.id == 377 or self.env.user.id == 28:
+ if self.env.user.id not in [21, 19, 28]:
+ raise UserError('Hanya Rafly dan Darren Yang Bisa Approve')
+ if self.env.user.id == 19 or self.env.user.id == 28:
self.sales_approve = True
- elif self.env.user.id == 19 or self.env.user.id == 28:
- if not self.sales_approve:
- raise UserError('Vita Belum Approve')
+ elif self.env.user.id == 21 or self.env.user.id == 28:
self.merchandise_approve = True
def create_po_from_requisition(self):
- if not self.sales_approve:
- raise UserError('Harus Di Approve oleh Vita')
- if not self.merchandise_approve:
- raise UserError('Harus Di Approve oleh Darren')
+ if not self.sales_approve and not self.merchandise_approve:
+ raise UserError('Harus Di Approve oleh Darren atau Rafly')
if not self.requisition_lines:
raise UserError('Tidak ada Lines, belum bisa create PO')
if self.is_po:
diff --git a/indoteknik_custom/models/res_partner.py b/indoteknik_custom/models/res_partner.py
index 191a44c9..236df16f 100644
--- a/indoteknik_custom/models/res_partner.py
+++ b/indoteknik_custom/models/res_partner.py
@@ -3,6 +3,9 @@ from odoo.exceptions import UserError, ValidationError
from datetime import datetime
from odoo.http import request
import re
+import requests
+import logging
+_logger = logging.getLogger(__name__)
class GroupPartner(models.Model):
_name = 'group.partner'
@@ -145,7 +148,8 @@ class ResPartner(models.Model):
date_payment_terms_purchase = fields.Datetime(string='Date Update Payment Terms')
longtitude = fields.Char(string='Longtitude')
latitude = fields.Char(string='Latitude')
- address_map = fields.Char(string='Address Map')
+ map_view = fields.Char(string='Map')
+ address_map = fields.Char(string='Address Map', help='Alamat ini diisi otomatis berdasarkan koordinat pin pada peta. Silakan koreksi dan ubah jika terdapat ketidaksesuaian', tracking=3)
company_type = fields.Selection(string='Company Type',
selection=[('person', 'Individual'), ('company', 'Company')],
compute='_compute_company_type', inverse='_write_company_type', tracking=3)
@@ -160,6 +164,14 @@ class ResPartner(models.Model):
"Set its value to 0.00 to disable "
"this feature", tracking=3)
telegram_id = fields.Char(string="Telegram")
+ avg_aging= fields.Float(string='Average Aging')
+ payment_difficulty = fields.Selection([('bermasalah', 'Bermasalah'),('sulit', 'Sulit'),('agak_sulit', 'Agak Sulit'),('normal', 'Normal')], string='Payment Difficulty', tracking=3)
+ payment_history_url = fields.Text(string='Payment History URL')
+
+ # no compute
+ # payment_diff = fields.Selection([('bermasalah', 'Bermasalah'),('sulit', 'Sulit'),('agak_sulit', 'Agak Sulit'),('normal', 'Normal')], string='Payment Difficulty', tracking=3)
+
+ # tidak terpakai
@api.model
def _default_payment_term(self):
@@ -184,6 +196,14 @@ class ResPartner(models.Model):
def write(self, vals):
res = super(ResPartner, self).write(vals)
+
+ for rec in self:
+ if 'latitude' in vals or 'longtitude' in vals:
+ rec._update_address_from_coords()
+
+ # Sinkronisasi payment_difficulty ke semua anak jika partner ini adalah parent
+ if not rec.parent_id and 'payment_difficulty' in vals:
+ rec.child_ids.write({'payment_difficulty': vals['payment_difficulty']})
#
# # if 'property_payment_term_id' in vals:
# # if not self.env.user.is_accounting and vals['property_payment_term_id'] != 26:
@@ -195,6 +215,16 @@ class ResPartner(models.Model):
# # raise UserError('You name it')
#
return res
+
+ @api.model
+ def create(self, vals):
+ records = super().create(vals)
+ for rec in records:
+ if vals.get('latitude') and vals.get('longtitude'):
+ rec._update_address_from_coords()
+ if rec.parent_id and not vals.get('payment_difficulty'):
+ rec.payment_difficulty = rec.parent_id.payment_difficulty
+ return records
@api.constrains('name')
def _check_duplicate_name(self):
@@ -235,182 +265,182 @@ class ResPartner(models.Model):
raise ValidationError("Digit NPWP yang dimasukkan tidak sesuai. Pastikan NPWP memiliki 15 digit dengan format tertentu (99.999.999.9-999.999) atau 16 digit tanpa tanda hubung.")
- def write(self, vals):
- # Fungsi rekursif untuk meng-update semua child, termasuk child dari child
- def update_children_recursively(partner, vals_for_child):
- # Lakukan update pada partner saat ini hanya dengan field yang diizinkan
- partner.write(vals_for_child)
-
- # Untuk setiap child dari partner ini, update juga child-nya
- for child in partner.child_ids:
- update_children_recursively(child, vals_for_child)
-
- # Jika self tidak memiliki parent_id, artinya self adalah parent
- if not self.parent_id:
- # Ambil semua child dari parent ini
- children = self.child_ids
-
- # Perbarui vals dengan nilai dari parent jika tidak ada dalam vals
- vals['customer_type'] = vals.get('customer_type', self.customer_type)
- vals['nama_wajib_pajak'] = vals.get('nama_wajib_pajak', self.nama_wajib_pajak)
- vals['npwp'] = vals.get('npwp', self.npwp)
- vals['sppkp'] = vals.get('sppkp', self.sppkp)
- vals['alamat_lengkap_text'] = vals.get('alamat_lengkap_text', self.alamat_lengkap_text)
- vals['industry_id'] = vals.get('industry_id', self.industry_id.id if self.industry_id else None)
- vals['company_type_id'] = vals.get('company_type_id', self.company_type_id.id if self.company_type_id else None)
-
- # Referensi
- vals['supplier_ids'] = vals.get('supplier_ids', self.supplier_ids)
-
- # informasi perusahaan
- vals['name_tempo'] = vals.get('name_tempo', self.name_tempo)
- vals['industry_id_tempo'] = vals.get('industry_id_tempo', self.industry_id_tempo)
- vals['street_tempo'] = vals.get('street_tempo', self.street_tempo)
- vals['state_id_tempo'] = vals.get('state_id_tempo', self.state_id_tempo)
- vals['city_id_tempo'] = vals.get('city_id_tempo', self.city_id_tempo)
- vals['zip_tempo'] = vals.get('zip_tempo', self.zip_tempo)
- vals['bank_name_tempo'] = vals.get('bank_name_tempo', self.bank_name_tempo)
- vals['account_name_tempo'] = vals.get('account_name_tempo', self.account_name_tempo)
- vals['account_number_tempo'] = vals.get('account_number_tempo', self.account_number_tempo)
- vals['website_tempo'] = vals.get('website_tempo', self.website_tempo)
- vals['portal'] = vals.get('portal', self.portal)
- vals['estimasi_tempo'] = vals.get('estimasi_tempo', self.estimasi_tempo)
- vals['tempo_duration'] = vals.get('tempo_duration', self.tempo_duration)
- vals['tempo_limit'] = vals.get('tempo_limit', self.tempo_limit)
- vals['category_produk_ids'] = vals.get('category_produk_ids', self.category_produk_ids)
-
- # Kontak Perusahaan
- vals['direktur_name'] = vals.get('direktur_name', self.direktur_name)
- vals['direktur_mobile'] = vals.get('direktur_mobile', self.direktur_mobile)
- vals['direktur_email'] = vals.get('direktur_email', self.direktur_email)
- vals['purchasing_name'] = vals.get('purchasing_name', self.purchasing_name)
- vals['purchasing_mobile'] = vals.get('purchasing_mobile', self.purchasing_mobile)
- vals['purchasing_email'] = vals.get('purchasing_email', self.purchasing_email)
- vals['finance_name'] = vals.get('finance_name', self.finance_name)
- vals['finance_mobile'] = vals.get('finance_mobile', self.finance_mobile)
- vals['finance_email'] = vals.get('finance_email', self.finance_email)
-
- # Pengiriman
- vals['pic_name'] = vals.get('pic_name', self.pic_name)
- vals['pic_mobile'] = vals.get('pic_mobile', self.pic_mobile)
- vals['street_pengiriman'] = vals.get('street_pengiriman', self.street_pengiriman)
- vals['state_id_pengiriman'] = vals.get('state_id_pengiriman', self.state_id_pengiriman)
- vals['city_id_pengiriman'] = vals.get('city_id_pengiriman', self.city_id_pengiriman)
- vals['district_id_pengiriman'] = vals.get('district_id_pengiriman', self.district_id_pengiriman)
- vals['subDistrict_id_pengiriman'] = vals.get('subDistrict_id_pengiriman', self.subDistrict_id_pengiriman)
- vals['zip_pengiriman'] = vals.get('zip_pengiriman', self.zip_pengiriman)
- vals['invoice_pic'] = vals.get('invoice_pic', self.invoice_pic)
- vals['invoice_pic_mobile'] = vals.get('invoice_pic_mobile', self.invoice_pic_mobile)
- vals['street_invoice'] = vals.get('street_invoice', self.street_invoice)
- vals['state_id_invoice'] = vals.get('state_id_invoice', self.state_id_invoice)
- vals['city_id_invoice'] = vals.get('city_id_invoice', self.city_id_invoice)
- vals['district_id_invoice'] = vals.get('district_id_invoice', self.district_id_invoice)
- vals['subDistrict_id_invoice'] = vals.get('subDistrict_id_invoice', self.subDistrict_id_invoice)
- vals['zip_invoice'] = vals.get('zip_invoice', self.zip_invoice)
- vals['tukar_invoice'] = vals.get('tukar_invoice', self.tukar_invoice)
- vals['jadwal_bayar'] = vals.get('jadwal_bayar', self.jadwal_bayar)
- vals['dokumen_prosedur'] = vals.get('dokumen_prosedur', self.dokumen_prosedur)
- vals['dokumen_pengiriman'] = vals.get('dokumen_pengiriman', self.dokumen_pengiriman)
- vals['dokumen_pengiriman_input'] = vals.get('dokumen_pengiriman_input', self.dokumen_pengiriman_input)
- vals['dokumen_invoice'] = vals.get('dokumen_invoice', self.dokumen_invoice)
-
- # Dokumen
- vals['dokumen_npwp'] = vals.get('dokumen_npwp', self.dokumen_npwp)
- vals['dokumen_sppkp'] = vals.get('dokumen_sppkp', self.dokumen_sppkp)
- vals['dokumen_nib'] = vals.get('dokumen_nib', self.dokumen_nib)
- vals['dokumen_siup'] = vals.get('dokumen_siup', self.dokumen_siup)
- vals['dokumen_tdp'] = vals.get('dokumen_tdp', self.dokumen_tdp)
- vals['dokumen_skdp'] = vals.get('dokumen_skdp', self.dokumen_skdp)
- vals['dokumen_skt'] = vals.get('dokumen_skt', self.dokumen_skt)
- vals['dokumen_akta_perubahan'] = vals.get('dokumen_akta_perubahan', self.dokumen_akta_perubahan)
- vals['dokumen_ktp_dirut'] = vals.get('dokumen_ktp_dirut', self.dokumen_ktp_dirut)
- vals['dokumen_akta_pendirian'] = vals.get('dokumen_akta_pendirian', self.dokumen_akta_pendirian)
- vals['dokumen_laporan_keuangan'] = vals.get('dokumen_laporan_keuangan', self.dokumen_laporan_keuangan)
- vals['dokumen_foto_kantor'] = vals.get('dokumen_foto_kantor', self.dokumen_foto_kantor)
- vals['dokumen_tempat_bekerja'] = vals.get('dokumen_tempat_bekerja', self.dokumen_tempat_bekerja)
-
- # Simpan hanya field yang perlu di-update pada child
- vals_for_child = {
- 'customer_type': vals.get('customer_type'),
- 'nama_wajib_pajak': vals.get('nama_wajib_pajak'),
- 'npwp': vals.get('npwp'),
- 'sppkp': vals.get('sppkp'),
- 'alamat_lengkap_text': vals.get('alamat_lengkap_text'),
- 'industry_id': vals.get('industry_id'),
- 'company_type_id': vals.get('company_type_id'),
- 'supplier_ids': vals.get('supplier_ids'),
- 'name_tempo': vals.get('name_tempo'),
- 'industry_id_tempo': vals.get('industry_id_tempo'),
- 'street_tempo': vals.get('street_tempo'),
- 'state_id_tempo': vals.get('state_id_tempo'),
- 'city_id_tempo': vals.get('city_id_tempo'),
- 'zip_tempo': vals.get('zip_tempo'),
- 'bank_name_tempo': vals.get('bank_name_tempo'),
- 'account_name_tempo': vals.get('account_name_tempo'),
- 'account_number_tempo': vals.get('account_number_tempo'),
- 'website_tempo': vals.get('website_tempo'),
- 'portal': vals.get('portal'),
- 'estimasi_tempo': vals.get('estimasi_tempo'),
- 'tempo_duration': vals.get('tempo_duration'),
- 'tempo_limit': vals.get('tempo_limit'),
- 'category_produk_ids': vals.get('category_produk_ids'),
- 'direktur_name': vals.get('direktur_name'),
- 'direktur_mobile': vals.get('direktur_mobile'),
- 'direktur_email': vals.get('direktur_email'),
- 'purchasing_name': vals.get('purchasing_name'),
- 'purchasing_mobile': vals.get('purchasing_mobile'),
- 'purchasing_email': vals.get('purchasing_email'),
- 'finance_name': vals.get('finance_name'),
- 'finance_mobile': vals.get('finance_mobile'),
- 'finance_email': vals.get('finance_email'),
- 'pic_name': vals.get('pic_name'),
- 'pic_mobile': vals.get('pic_mobile'),
- 'street_pengiriman': vals.get('street_pengiriman'),
- 'state_id_pengiriman': vals.get('state_id_pengiriman'),
- 'city_id_pengiriman': vals.get('city_id_pengiriman'),
- 'district_id_pengiriman': vals.get('district_id_pengiriman'),
- 'subDistrict_id_pengiriman': vals.get('subDistrict_id_pengiriman'),
- 'zip_pengiriman': vals.get('zip_pengiriman'),
- 'invoice_pic': vals.get('invoice_pic'),
- 'invoice_pic_mobile': vals.get('invoice_pic_mobile'),
- 'street_invoice': vals.get('street_invoice'),
- 'state_id_invoice': vals.get('state_id_invoice'),
- 'city_id_invoice': vals.get('city_id_invoice'),
- 'district_id_invoice': vals.get('district_id_invoice'),
- 'subDistrict_id_invoice': vals.get('subDistrict_id_invoice'),
- 'zip_invoice': vals.get('zip_invoice'),
- 'tukar_invoice': vals.get('tukar_invoice'),
- 'jadwal_bayar': vals.get('jadwal_bayar'),
- 'dokumen_prosedur': vals.get('dokumen_prosedur'),
- 'dokumen_pengiriman': vals.get('dokumen_pengiriman'),
- 'dokumen_pengiriman_input': vals.get('dokumen_pengiriman_input'),
- 'dokumen_invoice': vals.get('dokumen_invoice'),
- 'dokumen_npwp': vals.get('dokumen_npwp'),
- 'dokumen_sppkp': vals.get('dokumen_sppkp'),
- 'dokumen_nib': vals.get('dokumen_nib'),
- 'dokumen_siup': vals.get('dokumen_siup'),
- 'dokumen_tdp': vals.get('dokumen_tdp'),
- 'dokumen_skdp': vals.get('dokumen_skdp'),
- 'dokumen_skt': vals.get('dokumen_skt'),
- 'dokumen_akta_perubahan': vals.get('dokumen_akta_perubahan'),
- 'dokumen_ktp_dirut': vals.get('dokumen_ktp_dirut'),
- 'dokumen_akta_pendirian': vals.get('dokumen_akta_pendirian'),
- 'dokumen_laporan_keuangan': vals.get('dokumen_laporan_keuangan'),
- 'dokumen_foto_kantor': vals.get('dokumen_foto_kantor'),
- 'dokumen_tempat_bekerja': vals.get('dokumen_tempat_bekerja'),
-
- # internal_notes
- 'comment': vals.get('comment')
- }
-
- # Lakukan update pada semua child secara rekursif
- for child in children:
- update_children_recursively(child, vals_for_child)
-
- # Lakukan write untuk parent dengan vals asli
- res = super(ResPartner, self).write(vals)
-
- return res
+ # def write(self, vals):
+ # # Fungsi rekursif untuk meng-update semua child, termasuk child dari child
+ # def update_children_recursively(partner, vals_for_child):
+ # # Lakukan update pada partner saat ini hanya dengan field yang diizinkan
+ # partner.write(vals_for_child)
+ #
+ # # Untuk setiap child dari partner ini, update juga child-nya
+ # for child in partner.child_ids:
+ # update_children_recursively(child, vals_for_child)
+ #
+ # # Jika self tidak memiliki parent_id, artinya self adalah parent
+ # if not self.parent_id:
+ # # Ambil semua child dari parent ini
+ # children = self.child_ids
+ #
+ # # Perbarui vals dengan nilai dari parent jika tidak ada dalam vals
+ # vals['customer_type'] = vals.get('customer_type', self.customer_type)
+ # vals['nama_wajib_pajak'] = vals.get('nama_wajib_pajak', self.nama_wajib_pajak)
+ # vals['npwp'] = vals.get('npwp', self.npwp)
+ # vals['sppkp'] = vals.get('sppkp', self.sppkp)
+ # vals['alamat_lengkap_text'] = vals.get('alamat_lengkap_text', self.alamat_lengkap_text)
+ # vals['industry_id'] = vals.get('industry_id', self.industry_id.id if self.industry_id else None)
+ # vals['company_type_id'] = vals.get('company_type_id', self.company_type_id.id if self.company_type_id else None)
+ #
+ # # Referensi
+ # vals['supplier_ids'] = vals.get('supplier_ids', self.supplier_ids)
+ #
+ # # informasi perusahaan
+ # vals['name_tempo'] = vals.get('name_tempo', self.name_tempo)
+ # vals['industry_id_tempo'] = vals.get('industry_id_tempo', self.industry_id_tempo)
+ # vals['street_tempo'] = vals.get('street_tempo', self.street_tempo)
+ # vals['state_id_tempo'] = vals.get('state_id_tempo', self.state_id_tempo)
+ # vals['city_id_tempo'] = vals.get('city_id_tempo', self.city_id_tempo)
+ # vals['zip_tempo'] = vals.get('zip_tempo', self.zip_tempo)
+ # vals['bank_name_tempo'] = vals.get('bank_name_tempo', self.bank_name_tempo)
+ # vals['account_name_tempo'] = vals.get('account_name_tempo', self.account_name_tempo)
+ # vals['account_number_tempo'] = vals.get('account_number_tempo', self.account_number_tempo)
+ # vals['website_tempo'] = vals.get('website_tempo', self.website_tempo)
+ # vals['portal'] = vals.get('portal', self.portal)
+ # vals['estimasi_tempo'] = vals.get('estimasi_tempo', self.estimasi_tempo)
+ # vals['tempo_duration'] = vals.get('tempo_duration', self.tempo_duration)
+ # vals['tempo_limit'] = vals.get('tempo_limit', self.tempo_limit)
+ # vals['category_produk_ids'] = vals.get('category_produk_ids', self.category_produk_ids)
+ #
+ # # Kontak Perusahaan
+ # vals['direktur_name'] = vals.get('direktur_name', self.direktur_name)
+ # vals['direktur_mobile'] = vals.get('direktur_mobile', self.direktur_mobile)
+ # vals['direktur_email'] = vals.get('direktur_email', self.direktur_email)
+ # vals['purchasing_name'] = vals.get('purchasing_name', self.purchasing_name)
+ # vals['purchasing_mobile'] = vals.get('purchasing_mobile', self.purchasing_mobile)
+ # vals['purchasing_email'] = vals.get('purchasing_email', self.purchasing_email)
+ # vals['finance_name'] = vals.get('finance_name', self.finance_name)
+ # vals['finance_mobile'] = vals.get('finance_mobile', self.finance_mobile)
+ # vals['finance_email'] = vals.get('finance_email', self.finance_email)
+ #
+ # # Pengiriman
+ # vals['pic_name'] = vals.get('pic_name', self.pic_name)
+ # vals['pic_mobile'] = vals.get('pic_mobile', self.pic_mobile)
+ # vals['street_pengiriman'] = vals.get('street_pengiriman', self.street_pengiriman)
+ # vals['state_id_pengiriman'] = vals.get('state_id_pengiriman', self.state_id_pengiriman)
+ # vals['city_id_pengiriman'] = vals.get('city_id_pengiriman', self.city_id_pengiriman)
+ # vals['district_id_pengiriman'] = vals.get('district_id_pengiriman', self.district_id_pengiriman)
+ # vals['subDistrict_id_pengiriman'] = vals.get('subDistrict_id_pengiriman', self.subDistrict_id_pengiriman)
+ # vals['zip_pengiriman'] = vals.get('zip_pengiriman', self.zip_pengiriman)
+ # vals['invoice_pic'] = vals.get('invoice_pic', self.invoice_pic)
+ # vals['invoice_pic_mobile'] = vals.get('invoice_pic_mobile', self.invoice_pic_mobile)
+ # vals['street_invoice'] = vals.get('street_invoice', self.street_invoice)
+ # vals['state_id_invoice'] = vals.get('state_id_invoice', self.state_id_invoice)
+ # vals['city_id_invoice'] = vals.get('city_id_invoice', self.city_id_invoice)
+ # vals['district_id_invoice'] = vals.get('district_id_invoice', self.district_id_invoice)
+ # vals['subDistrict_id_invoice'] = vals.get('subDistrict_id_invoice', self.subDistrict_id_invoice)
+ # vals['zip_invoice'] = vals.get('zip_invoice', self.zip_invoice)
+ # vals['tukar_invoice'] = vals.get('tukar_invoice', self.tukar_invoice)
+ # vals['jadwal_bayar'] = vals.get('jadwal_bayar', self.jadwal_bayar)
+ # vals['dokumen_prosedur'] = vals.get('dokumen_prosedur', self.dokumen_prosedur)
+ # vals['dokumen_pengiriman'] = vals.get('dokumen_pengiriman', self.dokumen_pengiriman)
+ # vals['dokumen_pengiriman_input'] = vals.get('dokumen_pengiriman_input', self.dokumen_pengiriman_input)
+ # vals['dokumen_invoice'] = vals.get('dokumen_invoice', self.dokumen_invoice)
+ #
+ # # Dokumen
+ # vals['dokumen_npwp'] = vals.get('dokumen_npwp', self.dokumen_npwp)
+ # vals['dokumen_sppkp'] = vals.get('dokumen_sppkp', self.dokumen_sppkp)
+ # vals['dokumen_nib'] = vals.get('dokumen_nib', self.dokumen_nib)
+ # vals['dokumen_siup'] = vals.get('dokumen_siup', self.dokumen_siup)
+ # vals['dokumen_tdp'] = vals.get('dokumen_tdp', self.dokumen_tdp)
+ # vals['dokumen_skdp'] = vals.get('dokumen_skdp', self.dokumen_skdp)
+ # vals['dokumen_skt'] = vals.get('dokumen_skt', self.dokumen_skt)
+ # vals['dokumen_akta_perubahan'] = vals.get('dokumen_akta_perubahan', self.dokumen_akta_perubahan)
+ # vals['dokumen_ktp_dirut'] = vals.get('dokumen_ktp_dirut', self.dokumen_ktp_dirut)
+ # vals['dokumen_akta_pendirian'] = vals.get('dokumen_akta_pendirian', self.dokumen_akta_pendirian)
+ # vals['dokumen_laporan_keuangan'] = vals.get('dokumen_laporan_keuangan', self.dokumen_laporan_keuangan)
+ # vals['dokumen_foto_kantor'] = vals.get('dokumen_foto_kantor', self.dokumen_foto_kantor)
+ # vals['dokumen_tempat_bekerja'] = vals.get('dokumen_tempat_bekerja', self.dokumen_tempat_bekerja)
+ #
+ # # Simpan hanya field yang perlu di-update pada child
+ # vals_for_child = {
+ # 'customer_type': vals.get('customer_type'),
+ # 'nama_wajib_pajak': vals.get('nama_wajib_pajak'),
+ # 'npwp': vals.get('npwp'),
+ # 'sppkp': vals.get('sppkp'),
+ # 'alamat_lengkap_text': vals.get('alamat_lengkap_text'),
+ # 'industry_id': vals.get('industry_id'),
+ # 'company_type_id': vals.get('company_type_id'),
+ # 'supplier_ids': vals.get('supplier_ids'),
+ # 'name_tempo': vals.get('name_tempo'),
+ # 'industry_id_tempo': vals.get('industry_id_tempo'),
+ # 'street_tempo': vals.get('street_tempo'),
+ # 'state_id_tempo': vals.get('state_id_tempo'),
+ # 'city_id_tempo': vals.get('city_id_tempo'),
+ # 'zip_tempo': vals.get('zip_tempo'),
+ # 'bank_name_tempo': vals.get('bank_name_tempo'),
+ # 'account_name_tempo': vals.get('account_name_tempo'),
+ # 'account_number_tempo': vals.get('account_number_tempo'),
+ # 'website_tempo': vals.get('website_tempo'),
+ # 'portal': vals.get('portal'),
+ # 'estimasi_tempo': vals.get('estimasi_tempo'),
+ # 'tempo_duration': vals.get('tempo_duration'),
+ # 'tempo_limit': vals.get('tempo_limit'),
+ # 'category_produk_ids': vals.get('category_produk_ids'),
+ # 'direktur_name': vals.get('direktur_name'),
+ # 'direktur_mobile': vals.get('direktur_mobile'),
+ # 'direktur_email': vals.get('direktur_email'),
+ # 'purchasing_name': vals.get('purchasing_name'),
+ # 'purchasing_mobile': vals.get('purchasing_mobile'),
+ # 'purchasing_email': vals.get('purchasing_email'),
+ # 'finance_name': vals.get('finance_name'),
+ # 'finance_mobile': vals.get('finance_mobile'),
+ # 'finance_email': vals.get('finance_email'),
+ # 'pic_name': vals.get('pic_name'),
+ # 'pic_mobile': vals.get('pic_mobile'),
+ # 'street_pengiriman': vals.get('street_pengiriman'),
+ # 'state_id_pengiriman': vals.get('state_id_pengiriman'),
+ # 'city_id_pengiriman': vals.get('city_id_pengiriman'),
+ # 'district_id_pengiriman': vals.get('district_id_pengiriman'),
+ # 'subDistrict_id_pengiriman': vals.get('subDistrict_id_pengiriman'),
+ # 'zip_pengiriman': vals.get('zip_pengiriman'),
+ # 'invoice_pic': vals.get('invoice_pic'),
+ # 'invoice_pic_mobile': vals.get('invoice_pic_mobile'),
+ # 'street_invoice': vals.get('street_invoice'),
+ # 'state_id_invoice': vals.get('state_id_invoice'),
+ # 'city_id_invoice': vals.get('city_id_invoice'),
+ # 'district_id_invoice': vals.get('district_id_invoice'),
+ # 'subDistrict_id_invoice': vals.get('subDistrict_id_invoice'),
+ # 'zip_invoice': vals.get('zip_invoice'),
+ # 'tukar_invoice': vals.get('tukar_invoice'),
+ # 'jadwal_bayar': vals.get('jadwal_bayar'),
+ # 'dokumen_prosedur': vals.get('dokumen_prosedur'),
+ # 'dokumen_pengiriman': vals.get('dokumen_pengiriman'),
+ # 'dokumen_pengiriman_input': vals.get('dokumen_pengiriman_input'),
+ # 'dokumen_invoice': vals.get('dokumen_invoice'),
+ # 'dokumen_npwp': vals.get('dokumen_npwp'),
+ # 'dokumen_sppkp': vals.get('dokumen_sppkp'),
+ # 'dokumen_nib': vals.get('dokumen_nib'),
+ # 'dokumen_siup': vals.get('dokumen_siup'),
+ # 'dokumen_tdp': vals.get('dokumen_tdp'),
+ # 'dokumen_skdp': vals.get('dokumen_skdp'),
+ # 'dokumen_skt': vals.get('dokumen_skt'),
+ # 'dokumen_akta_perubahan': vals.get('dokumen_akta_perubahan'),
+ # 'dokumen_ktp_dirut': vals.get('dokumen_ktp_dirut'),
+ # 'dokumen_akta_pendirian': vals.get('dokumen_akta_pendirian'),
+ # 'dokumen_laporan_keuangan': vals.get('dokumen_laporan_keuangan'),
+ # 'dokumen_foto_kantor': vals.get('dokumen_foto_kantor'),
+ # 'dokumen_tempat_bekerja': vals.get('dokumen_tempat_bekerja'),
+ #
+ # # internal_notes
+ # 'comment': vals.get('comment')
+ # }
+ #
+ # # Lakukan update pada semua child secara rekursif
+ # for child in children:
+ # update_children_recursively(child, vals_for_child)
+ #
+ # # Lakukan write untuk parent dengan vals asli
+ # res = super(ResPartner, self).write(vals)
+ #
+ # return res
# if self.company_type == 'person' and not partner.parent_id:
# if self.parent_id:
@@ -521,4 +551,144 @@ class ResPartner(models.Model):
@api.onchange('name')
def _onchange_name(self):
if self.company_type == 'person':
- self.nama_wajib_pajak = self.name \ No newline at end of file
+ self.nama_wajib_pajak = self.name
+
+ def action_open_full_form(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Partner',
+ 'res_model': 'res.partner',
+ 'res_id': self.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def geocode_address(self):
+ for rec in self:
+ # Daftar field penting
+ required_fields = {
+ 'Alamat Jalan (street)': rec.street,
+ 'Kelurahan': rec.kelurahan_id.name if rec.kelurahan_id else '',
+ 'Kecamatan': rec.kecamatan_id.name if rec.kecamatan_id else '',
+ 'Kota': rec.kota_id.name if rec.kota_id else '',
+ 'Kode Pos': rec.zip,
+ 'Provinsi': rec.state_id.name if rec.state_id else '',
+ }
+
+ # Cek jika ada yang kosong
+ missing = [label for label, val in required_fields.items() if not val]
+ if missing:
+ raise UserError(
+ "Alamat tidak lengkap. Mohon lengkapi field berikut:\n- " + "\n- ".join(missing)
+ )
+
+ # Susun alamat lengkap
+ address = ', '.join([
+ required_fields['Alamat Jalan (street)'],
+ required_fields['Kelurahan'],
+ required_fields['Kecamatan'],
+ required_fields['Kota'],
+ required_fields['Kode Pos'],
+ required_fields['Provinsi'],
+ ])
+
+ # Ambil API Key
+ api_key = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE"
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key')
+ # if not api_key:
+ # raise UserError("API Key Google Maps belum dikonfigurasi. Silakan isi melalui Settings.")
+
+ # Request ke Google Maps
+ url = f'https://maps.googleapis.com/maps/api/geocode/json?address={address}&key={api_key}'
+ response = requests.get(url)
+
+ if response.ok:
+ result = response.json()
+ if result.get('results'):
+ location = result['results'][0]['geometry']['location']
+ formatted_address = result['results'][0].get('formatted_address', '')
+
+ rec.latitude = location['lat']
+ rec.longtitude = location['lng']
+ rec.address_map = formatted_address # ✅ Simpan alamat lengkap
+ else:
+ raise UserError("Tidak ditemukan hasil geocode untuk alamat tersebut.")
+ else:
+ raise UserError("Permintaan ke Google Maps gagal. Periksa koneksi internet atau API Key.")
+
+ def _update_address_from_coords(self):
+ for rec in self:
+ if rec.latitude and rec.longtitude:
+ try:
+ components, formatted, parsed = rec._reverse_geocode(rec.latitude, rec.longtitude)
+ if not parsed:
+ continue
+
+ updates = {
+ 'street': parsed.get('road') or '',
+ 'zip': parsed.get('postcode') or '',
+ 'address_map': formatted or '',
+ }
+
+ state = self.env['res.country.state'].search([('name', 'ilike', parsed.get('state'))], limit=1)
+ if state:
+ updates['state_id'] = state.id
+
+ kota = self.env['vit.kota'].search([('name', 'ilike', parsed.get('city'))], limit=1)
+ if kota:
+ updates['kota_id'] = kota.id
+
+ kec = self.env['vit.kecamatan'].search([('name', 'ilike', parsed.get('district'))], limit=1)
+ if kec:
+ updates['kecamatan_id'] = kec.id
+
+ kel = self.env['vit.kelurahan'].search([('name', 'ilike', parsed.get('suburb'))], limit=1)
+ if kel:
+ updates['kelurahan_id'] = kel.id
+
+ rec.update(updates)
+
+ except Exception as e:
+ raise UserError(f"Gagal update alamat dari koordinat: {str(e)}")
+
+
+ def _reverse_geocode(self, lat, lng):
+ api_key = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE"
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key')
+ # if not api_key:
+ # raise UserError("API Key Google Maps belum dikonfigurasi.")
+
+ url = f'https://maps.googleapis.com/maps/api/geocode/json?latlng={lat},{lng}&key={api_key}'
+ response = requests.get(url)
+ if response.ok:
+ result = response.json()
+ if result.get('results'):
+ components = result['results'][0]['address_components']
+ formatted = result['results'][0]['formatted_address']
+ return components, formatted, self._parse_google_address(components)
+ return {}, '', {}
+
+ def _parse_google_address(self, components):
+ def get(types):
+ for comp in components:
+ if types in comp['types']:
+ return comp['long_name']
+ return ''
+
+ street_number = get('street_number')
+ route = get('route')
+ neighborhood = get('neighborhood') # Bisa jadi nama RW
+ subpremise = get('subpremise') # Bisa jadi no kamar/ruko
+
+ # Gabungkan informasi jalan
+ road = " ".join(filter(None, [route, street_number, subpremise, neighborhood]))
+
+ return {
+ 'road': road.strip(),
+ 'postcode': get('postal_code'),
+ 'state': get('administrative_area_level_1'),
+ 'city': get('administrative_area_level_2') or get('locality'),
+ 'district': get('administrative_area_level_3'),
+ 'suburb': get('administrative_area_level_4'),
+ 'formatted': get('formatted_address'),
+ }
diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py
index f89dfb10..7be0e8ff 100755
--- a/indoteknik_custom/models/sale_order.py
+++ b/indoteknik_custom/models/sale_order.py
@@ -2,11 +2,13 @@ from re import search
from odoo import fields, models, api, _
from odoo.exceptions import UserError, ValidationError
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone, time
import logging, random, string, requests, math, json, re, qrcode, base64
import pytz
from io import BytesIO
from collections import defaultdict
+import pytz
+from lxml import etree
_logger = logging.getLogger(__name__)
@@ -65,6 +67,7 @@ class ShippingOption(models.Model):
price = fields.Float(string="Price", required=True)
provider = fields.Char(string="Provider")
etd = fields.Char(string="Estimated Delivery Time")
+ courier_service_code = fields.Char(string="Courier Service Code")
sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete="cascade")
@@ -72,6 +75,7 @@ class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def unlink(self):
+
lines_to_reject = []
for line in self:
if line.order_id:
@@ -121,6 +125,7 @@ class SaleOrderLine(models.Model):
class SaleOrder(models.Model):
_inherit = "sale.order"
+ ccm_id = fields.Many2one('tukar.guling', string='Doc. CCM', readonly=True, compute='_has_ccm', copy=False)
ongkir_ke_xpdc = fields.Float(string='Ongkir ke Ekspedisi', help='Biaya ongkir ekspedisi', copy=False, index=True,
tracking=3)
@@ -148,8 +153,8 @@ class SaleOrder(models.Model):
help="Total Margin in Sales Order Header")
total_percent_margin = fields.Float('Total Percent Margin', compute='_compute_total_percent_margin',
help="Total % Margin in Sales Order Header")
- total_margin_excl_third_party = fields.Float('Before Margin', help="Before Margin in Sales Order Header",
- compute='_compute_total_margin_excl_third_party')
+ total_margin_excl_third_party = fields.Float('Before Margin', help="Before Margin in Sales Order Header")
+
approval_status = fields.Selection([
('pengajuan1', 'Approval Manager'),
('pengajuan2', 'Approval Pimpinan'),
@@ -324,8 +329,13 @@ class SaleOrder(models.Model):
string="Attachment Bukti Cancel", readonly=False,
)
nomor_so_pengganti = fields.Char(string='Nomor SO Pengganti', copy=False, tracking=3)
- shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option",
- domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]")
+
+ shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option", domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]")
+ select_shipping_option = fields.Selection([
+ ('biteship', 'Biteship'),
+ ('custom', 'Custom'),
+ ], string='Shipping Option', help="Select shipping option for delivery", tracking=True, default='custom')
+
hold_outgoing = fields.Boolean('Hold Outgoing SO', tracking=3)
state_ask_cancel = fields.Selection([
('hold', 'Hold'),
@@ -340,16 +350,364 @@ class SaleOrder(models.Model):
date_unhold = fields.Datetime(string='Date Unhold', tracking=True, readonly=True, help='Waktu ketika SO di Unhold'
)
- def _compute_total_margin_excl_third_party(self):
+ et_products = fields.Datetime(string='ET Products', compute='_compute_et_products', help="Leadtime produk berdasarkan SLA vendor, tanpa logistik.")
+
+ eta_date_reserved = fields.Datetime(
+ string="Date Reserved",
+ compute="_compute_eta_date_reserved",
+ help="Tanggal pertama kali barang berhasil di-reservasi pada DO (BU/PICK/) yang berstatus Siap Dikirim."
+ )
+ refund_ids = fields.Many2many('refund.sale.order', compute='_compute_refund_ids', string='Refunds')
+ has_refund = fields.Boolean(string='Has Refund', compute='_compute_has_refund')
+ refund_count = fields.Integer(string='Refund Count', compute='_compute_refund_count')
+ advance_payment_move_id = fields.Many2one(
+ 'account.move',
+ compute='_compute_advance_payment_move',
+ string='Advance Payment Move',
+ )
+ advance_payment_move_ids = fields.Many2many(
+ 'account.move',
+ compute='_compute_advance_payment_moves',
+ string='All Advance Payment Moves',
+ )
+
+ advance_payment_move_count = fields.Integer(
+ string='Jumlah Jurnal Uang Muka',
+ compute='_compute_advance_payment_moves',
+ store=False
+ )
+
+ def _has_ccm(self):
+ if self.id:
+ self.ccm_id = self.env['tukar.guling'].search([('origin', 'ilike', self.name)], limit=1)
+
+ @api.depends('order_line.product_id', 'date_order')
+ def _compute_et_products(self):
+ jakarta = pytz.timezone("Asia/Jakarta")
for order in self:
- if order.amount_untaxed == 0:
- order.total_margin_excl_third_party = 0
+ if not order.order_line or not order.date_order:
+ order.et_products = False
continue
- # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
- order.total_margin_excl_third_party = round((order.total_before_margin / (order.amount_untaxed)) * 100, 2)
- # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
+ # Ambil tanggal order sebagai basis
+ base_date = order.date_order
+ if base_date.tzinfo is None:
+ base_date = jakarta.localize(base_date)
+ else:
+ base_date = base_date.astimezone(jakarta)
+
+ # Ambil nilai SLA vendor dalam hari
+ sla_data = order.calculate_sla_by_vendor(order.order_line)
+ sla_days = sla_data.get('slatime', 1)
+
+ # Hitung ETA produk (tanpa logistik)
+ eta_datetime = base_date + timedelta(days=sla_days)
+ # Simpan ke field sebagai UTC-naive datetime (standar Odoo)
+ order.et_products = eta_datetime.astimezone(pytz.utc).replace(tzinfo=None)
+
+ @api.depends('picking_ids.state', 'picking_ids.date_done')
+ def _compute_eta_date_reserved(self):
+ for order in self:
+ pickings = order.picking_ids.filtered(
+ lambda p: p.state in ('assigned', 'done') and p.date_reserved and 'BU/PICK/' in (p.name or '')
+ )
+ done_dates = [d for d in pickings.mapped('date_done') if d]
+ order.eta_date_reserved = min(done_dates) if done_dates else False
+ # order.eta_date_reserved = min(pickings.mapped('date_done')) if pickings else False
+
+ @api.onchange('shipping_cost_covered')
+ def _onchange_shipping_cost_covered(self):
+ if self.shipping_cost_covered == 'indoteknik' and self.select_shipping_option == 'biteship':
+ self.shipping_cost_covered = 'customer'
+ return {
+ 'warning': {
+ 'title': "Biteship Tidak Diizinkan",
+ 'message': (
+ "Biaya pengiriman ditanggung Indoteknik, sehingga tidak diizinkan menggunakan metode Biteship. "
+ "Pilihan penanggung biaya akan dikembalikan sebelumnya"
+ )
+ }
+ }
+
+ def get_biteship_carrier_ids(self):
+ courier_codes = tuple(self._get_biteship_courier_codes() or [])
+ if not courier_codes:
+ return []
+
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id
+ FROM rajaongkir_kurir
+ WHERE name IN %s AND delivery_carrier_id IS NOT NULL
+ """, (courier_codes,))
+ result = self.env.cr.fetchall()
+ carrier_ids = [row[0] for row in result if row[0]]
+ return carrier_ids
+
+ # @api.model
+ # def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
+ # res = super(SaleOrder, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
+
+ # if view_type == 'form':
+ # doc = etree.XML(res['arch'])
+
+ # # Ambil semua delivery_carrier_id dari mapping rajaongkir_kurir
+ # biteship_ids = self.env['rajaongkir.kurir'].search([]).mapped('delivery_carrier_id.id')
+ # biteship_ids = list(set(filter(None, biteship_ids))) # pastikan unik dan bukan None
+
+ # all_ids = self.env['delivery.carrier'].search([]).ids
+ # custom_ids = list(set(all_ids) - set(biteship_ids))
+
+ # # Format sebagai string Python list
+ # biteship_ids_str = ','.join(str(i) for i in biteship_ids) or '-1'
+ # custom_ids_str = ','.join(str(i) for i in custom_ids) or '-1'
+
+ # # Terapkan domain ke field carrier_id
+ # for node in doc.xpath("//field[@name='carrier_id']"):
+ # # Domain tergantung select_shipping_option
+ # node.set(
+ # 'domain',
+ # "[('id', 'in', [%s]) if select_shipping_option == 'biteship' else ('id', 'in', [%s])]" %
+ # (biteship_ids_str, custom_ids_str)
+ # )
+
+ # # Simpan kembali hasil XML ke arsitektur form
+ # res['arch'] = etree.tostring(doc, encoding='unicode')
+
+ # return res
+
+ # @api.onchange('shipping_option_id')
+ # def _onchange_shipping_option_id(self):
+ # if self.shipping_option_id:
+ # self.delivery_amt = self.shipping_option_id.price
+ # self.delivery_service_type = self.shipping_option_id.courier_service_code
+
+ def _get_biteship_courier_codes(self):
+ return [
+ 'gojek','grab','deliveree','lalamove','jne','tiki','ninja','lion','rara','sicepat','jnt','pos','idexpress','rpx','wahana','jdl','pos','anteraja','sap','paxel','borzo'
+ ]
+
+ @api.onchange('carrier_id')
+ def _onchange_carrier_id(self):
+ if not self._origin or not self._origin.id:
+ return
+
+ sale_order_id = self._origin.id
+ self.shipping_option_id = False
+
+ if not self.carrier_id:
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # Ambil provider dari mapping
+ self.env.cr.execute("""
+ SELECT name FROM rajaongkir_kurir
+ WHERE delivery_carrier_id = %s LIMIT 1
+ """, (self.carrier_id.id,))
+ row = self.env.cr.fetchone()
+ provider = row[0].lower() if row and row[0] else (
+ self.carrier_id.name.lower().split()[0] if self.carrier_id.name else False
+ )
+
+ _logger.info(f"[Carrier Changed] {self.carrier_id.name}, Detected Provider: {provider}")
+
+ # ───────────────────────────────────────────────────────────────
+ # Validasi koordinat untuk kurir instan
+ # ───────────────────────────────────────────────────────────────
+ instan_kurir = ['gojek', 'grab', 'lalamove', 'borzo', 'rara', 'deliveree']
+ if provider in instan_kurir:
+ lat = self.real_shipping_id.latitude
+ lng = self.real_shipping_id.longtitude
+ def is_invalid(val):
+ try:
+ return not val or float(val) == 0.0
+ except (ValueError, TypeError):
+ return True
+
+ if is_invalid(lat) or is_invalid(lng):
+ self.carrier_id = self._origin.carrier_id
+ self.shipping_option_id = self._origin.shipping_option_id or False
+ return {
+ 'warning': {
+ 'title': "Alamat Belum Pin Point",
+ 'message': (
+ "Kurir instan seperti Gojek, Grab, Lalamove, Borzo, Rara, dan Deliveree "
+ "membutuhkan alamat pengiriman yang sudah Pin Point.\n\n"
+ "Silakan tentukan lokasi dengan tepat pada Pin Point Location yang tersedia di kontak."
+ )
+ },
+ 'domain': {'shipping_option_id': [('id', '=', -1)]}
+ }
+
+ # ───────────────────────────────────────────────────────────────
+ # Baru cek apakah shipping option sudah ada
+ # ───────────────────────────────────────────────────────────────
+ total_so_options = self.env['shipping.option'].search_count([
+ ('sale_order_id', '=', sale_order_id)
+ ])
+
+ if total_so_options == 0:
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # Validasi: apakah shipping option ada untuk provider ini?
+ matched = self.env['shipping.option'].search_count([
+ ('sale_order_id', '=', sale_order_id),
+ ('provider', 'ilike', provider),
+ ])
+ if self.select_shipping_option == 'biteship' and matched == 0:
+ self.carrier_id = self._origin.carrier_id
+ self.shipping_option_id = self._origin.shipping_option_id or False
+ return {
+ 'warning': {
+ 'title': "Shipping Option Tidak Ditemukan",
+ 'message': (
+ "Layanan kurir ini tidak tersedia pada pengiriman ini. "
+ "Pilihan dikembalikan ke sebelumnya."
+ )
+ },
+ 'domain': {'shipping_option_id': [('id', '=', -1)]}
+ }
+
+ # Kalau semua valid, kembalikan domain normal
+ domain = [
+ '|',
+ '&', ('sale_order_id', '=', sale_order_id), ('provider', 'ilike', f'%{provider}%'),
+ '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
+ ]
+ return {'domain': {'shipping_option_id': domain}}
+
+ @api.onchange('shipping_option_id')
+ def _onchange_shipping_option_id(self):
+ if not self.shipping_option_id:
+ return
+
+ if not self.carrier_id:
+ # Jika belum pilih carrier, tetap update harga dan service type
+ self.delivery_amt = self.shipping_option_id.price
+ self.delivery_service_type = self.shipping_option_id.courier_service_code
+ return
+
+ # Ambil provider dari carrier
+ self.env.cr.execute("""
+ SELECT name FROM rajaongkir_kurir
+ WHERE delivery_carrier_id = %s LIMIT 1
+ """, (self.carrier_id.id,))
+ row = self.env.cr.fetchone()
+ provider = row[0].lower() if row and row[0] else self.carrier_id.name.lower().split()[0]
+
+ selected_provider = (self.shipping_option_id.provider or '').lower()
+
+ if provider not in selected_provider:
+ warning_msg = {
+ 'title': "Opsi Tidak Valid",
+ 'message': f"Opsi pengiriman '{self.shipping_option_id.name}' tidak cocok dengan metode '{self.carrier_id.name}'. Dikembalikan ke sebelumnya."
+ }
+
+ # Kembalikan ke nilai lama (jika record sudah disimpan)
+ self.shipping_option_id = self._origin.shipping_option_id if self._origin else False
+ return {'warning': warning_msg}
+
+ # Jika valid
+ self.delivery_amt = self.shipping_option_id.price
+ self.delivery_service_type = self.shipping_option_id.courier_service_code
+
+ def _update_delivery_service_type_from_shipping_option(self, vals):
+ shipping_option_id = vals.get('shipping_option_id') or self.shipping_option_id.id
+ if shipping_option_id:
+ shipping_option = self.env['shipping.option'].browse(shipping_option_id)
+ if shipping_option.exists():
+ courier_service = shipping_option.courier_service_code
+ vals['delivery_service_type'] = courier_service
+ _logger.info("Set delivery_service_type: %s from shipping_option_id: %s", courier_service, shipping_option_id)
+ else:
+ _logger.warning("shipping_option_id %s not found or invalid.", shipping_option_id)
+ else:
+ _logger.info("shipping_option_id not found in vals or record.")
+
+ # @api.model
+ # def fields_get(self, allfields=None, attributes=None):
+ # res = super().fields_get(allfields=allfields, attributes=attributes)
+
+ # # Aktifkan hanya kalau sedang buka form Sales Order (safety check)
+ # if self.env.context.get('params', {}).get('model') == 'sale.order' and \
+ # self.env.context.get('params', {}).get('id'):
+
+ # sale_id = self.env.context['params']['id']
+
+ # # Ambil carrier_id dari SO yang sedang dibuka
+ # self.env.cr.execute("SELECT carrier_id FROM sale_order WHERE id = %s", (sale_id,))
+ # row = self.env.cr.fetchone()
+ # carrier_id = row[0] if row else None
+
+ # provider = None
+ # if carrier_id:
+ # self.env.cr.execute("""
+ # SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1
+ # """, (carrier_id,))
+ # row = self.env.cr.fetchone()
+ # if row and row[0]:
+ # provider = row[0].lower()
+ # else:
+ # self.env.cr.execute("SELECT name FROM delivery_carrier WHERE id = %s", (carrier_id,))
+ # row = self.env.cr.fetchone()
+ # provider = row[0].lower().split()[0] if row and row[0] else ''
+
+ # if provider:
+ # domain = [
+ # '|',
+ # '&', ('sale_order_id', '=', sale_id), ('provider', 'ilike', f'%{provider}%'),
+ # '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
+ # ]
+
+ # if 'shipping_option_id' in res:
+ # res['shipping_option_id']['domain'] = domain
+ # _logger.info(f"fields_get - Injected domain for shipping_option_id: {domain}")
+ # return res
+
+
+ @api.onchange('select_shipping_option')
+ def _onchange_select_shipping_option(self):
+ self.shipping_option_id = False
+ self.delivery_service_type = False
+ self.carrier_id = False
+ self.delivery_amt = 0
+
+ biteship_carrier_ids = []
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id
+ FROM rajaongkir_kurir
+ WHERE name IN %s
+ """, (tuple(self._get_biteship_courier_codes()),))
+ biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]]
+
+ if self.select_shipping_option == 'biteship':
+ if self.shipping_cost_covered == 'indoteknik':
+ self.select_shipping_option = self._origin.select_shipping_option if self._origin else 'custom'
+ return {
+ 'warning': {
+ 'title': "Biteship Tidak Diizinkan",
+ 'message': (
+ "Biaya pengiriman ditanggung Indoteknik. Tidak diizinkan memilih metode Biteship. "
+ "Opsi pengiriman dikembalikan ke sebelumnya."
+ )
+ }
+ }
+
+ domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else [('id', '=', -1)]
+ else:
+ domain = [] # tampilkan semua
+
+ return {'domain': {'carrier_id': domain}}
+
+ # def _compute_total_margin_excl_third_party(self):
+ # for order in self:
+ # if order.amount_untaxed == 0:
+ # order.total_margin_excl_third_party = 0
+ # continue
+ #
+ # # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
+ # order.total_margin_excl_third_party = round((order.total_before_margin / (order.amount_untaxed)) * 100, 2)
+ # # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
+ #
def ask_retur_cancel_purchasing(self):
for rec in self:
if self.env.user.has_group('indoteknik_custom.group_role_purchasing'):
@@ -403,24 +761,18 @@ class SaleOrder(models.Model):
if len(tax_sets) > 1:
raise ValidationError("Semua produk dalam Sales Order harus memiliki kombinasi pajak yang sama.")
- # @api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain')
+ # @api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain', 'ongkir_ke_xpdc')
# def _check_total_margin_excl_third_party(self):
# for rec in self:
# if rec.fee_third_party == 0 and rec.total_margin_excl_third_party != rec.total_percent_margin:
# # Gunakan direct SQL atau flag context untuk menghindari rekursi
# self.env.cr.execute("""
- # UPDATE sale_order
- # SET total_margin_excl_third_party = %s
+ # UPDATE sale_order
+ # SET total_margin_excl_third_party = %s
# WHERE id = %s
# """, (rec.total_percent_margin, rec.id))
# self.invalidate_cache()
- @api.constrains('shipping_option_id')
- def _check_shipping_option(self):
- for rec in self:
- if rec.shipping_option_id:
- rec.delivery_amt = rec.shipping_option_id.price
-
def _compute_shipping_method_picking(self):
for order in self:
if order.picking_ids:
@@ -493,17 +845,93 @@ class SaleOrder(models.Model):
)
def action_estimate_shipping(self):
- if self.carrier_id.id in [1, 151]:
- self.action_indoteknik_estimate_shipping()
- return
+ # if self.carrier_id.id in [1, 151]:
+ # self.action_indoteknik_estimate_shipping()
+ # return
+
+ if self.select_shipping_option == 'biteship':
+ return self.action_estimate_shipping_biteship()
+ elif self.carrier_id.id in [1, 151]: # ID untuk Indoteknik Delivery
+ return self.action_indoteknik_estimate_shipping()
+ else:
+ total_weight = 0
+ missing_weight_products = []
+
+ for line in self.order_line:
+ if line.weight > 0:
+ total_weight += line.weight * line.product_uom_qty
+ line.product_id.weight = line.weight
+ else:
+ missing_weight_products.append(line.product_id.name)
+
+ if missing_weight_products:
+ product_names = '<br/>'.join(missing_weight_products)
+ self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}")
+
+ if total_weight == 0:
+ raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
+
+ kecamatan_name = self.real_shipping_id.kecamatan_id.name
+ kota_name = self.real_shipping_id.kota_id.name
+ kelurahan_name = self.real_shipping_id.kelurahan_id.name
+
+ destination_subsdistrict_id = self._get_subdistrict_id_from_komerce(kecamatan_name, kota_name, kelurahan_name)
+
+ # destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id
+ if not destination_subsdistrict_id:
+ raise UserError("Gagal mendapatkan ID kota tujuan.")
+ result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id)
+ if not result or not result.get('data'):
+ raise UserError(_("Kurir %s tidak tersedia untuk tujuan ini. Silakan pilih kurir lain.") % self.carrier_id.name)
+
+ if result:
+ shipping_options = []
+
+ for cost in result.get('data', []):
+ service = cost.get('service')
+ description = cost.get('description')
+ etd = cost.get('etd', '')
+ value = cost.get('cost', 0)
+ provider = cost.get('code')
+
+ shipping_options.append((service, description, etd, value, provider))
+
+ self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
+
+ _logger.info(f"Shipping options: {shipping_options}")
+
+ for service, description, etd, value, provider in shipping_options:
+ self.env["shipping.option"].create({
+ "name": service,
+ "price": value,
+ "provider": provider,
+ "etd": etd,
+ "sale_order_id": self.id,
+ })
+
+ self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id
+
+ _logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
+
+ self.message_post(
+ body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>"
+ f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]}, Cost: Rp {s[3]}' for s in shipping_options])}",
+ message_type="comment"
+ )
+ else:
+ raise UserError("Gagal mendapatkan estimasi ongkir.")
+
+ def _validate_for_shipping_estimate(self):
+ # Cek berat produk
total_weight = 0
missing_weight_products = []
for line in self.order_line:
- if line.weight > 0:
- total_weight += line.weight * line.product_uom_qty
- line.product_id.weight = line.weight
+ product_weight = line.product_id.weight or 0
+ if product_weight > 0:
+ total_weight += product_weight * line.product_uom_qty
+ line.weight = product_weight
else:
missing_weight_products.append(line.product_id.name)
@@ -512,71 +940,314 @@ class SaleOrder(models.Model):
self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}")
if total_weight == 0:
- raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
+ raise UserError("Tidak dapat mengestimasi ongkir tanpa karena berat produk = 0 kg.")
+
+ # Validasi alamat pengiriman
+ if not self.real_shipping_id:
+ raise UserError("Alamat pengiriman (Real Delivery Address) harus diisi.")
+
+ if not self.real_shipping_id.kota_id:
+ raise UserError("Kota pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.zip:
+ raise UserError("Kode pos pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.state_id:
+ raise UserError("Provinsi pada alamat pengiriman harus diisi.")
+
+ return total_weight
+
+ def action_estimate_shipping_biteship(self):
+ total_weight = self._validate_for_shipping_estimate()
+
+ weight_gram = int(total_weight * 1000)
+ if weight_gram < 100:
+ weight_gram = 100
+
+ value = int(self.amount_untaxed or sum(line.price_subtotal for line in self.order_line))
+
+ items = [{
+ "name": "Paket Pesanan",
+ "description": f"Sale Order {self.name}",
+ "value": value,
+ "weight": weight_gram,
+ "quantity": 1,
+ }]
+
+ shipping_address = self.real_shipping_id
+ _logger.info(f"Shipping Address: {shipping_address}")
+
+ origin_data = {
+ "origin_latitude": -6.3031123,
+ "origin_longitude": 106.7794934,
+ }
+
+ destination_data = {}
+ use_coordinate = False
+
+ if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'):
+ if shipping_address.latitude and shipping_address.longtitude:
+ try:
+ lat = float(shipping_address.latitude)
+ lng = float(shipping_address.longtitude)
+ destination_data = {
+ "destination_latitude": lat,
+ "destination_longitude": lng
+ }
+ use_coordinate = True
+ _logger.info(f"Using coordinates: lat={lat}, lng={lng}")
+ except (ValueError, TypeError):
+ _logger.warning(f"Invalid coordinates, falling back to postal code")
+ use_coordinate = False
+
+ if not use_coordinate:
+ if shipping_address.zip:
+ origin_data = {"origin_postal_code": 14440}
+ destination_data = {
+ "destination_postal_code": shipping_address.zip
+ }
+ _logger.info(f"Using postal code: {shipping_address.zip}")
+ else:
+ raise UserError("Tidak dapat mengestimasikan ongkir: Kode pos tujuan tidak tersedia.")
+
+ couriers = ','.join(self._get_biteship_courier_codes())
+
+ api_mode = "koordinat" if use_coordinate else "kode_pos"
+ _logger.info(f"Calling Biteship API with mode: {api_mode}")
+
+ result = self._call_biteship_api(origin_data, destination_data, items, couriers)
+
+ if not result:
+ raise UserError("Gagal mendapatkan estimasi ongkir dari Biteship.")
+
+ self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
+
+ shipping_options = []
+ courier_options = {}
+ shipping_services = result.get('pricing', [])
+
+ _logger.info(f"Ditemukan {len(shipping_services)} layanan pengiriman")
+
+ for service in shipping_services:
+ courier_code = service.get('courier_code', '').lower()
+ courier_name = service.get('courier_name', '')
+ service_name = service.get('courier_service_name', '')
+ raw_price = service.get('price', 0)
+ markup_price = int(raw_price * 1.1)
+ price = round(markup_price / 1000) * 1000
+
+ _logger.info(f"Layanan: {courier_name} - {service_name}, Harga: {price}")
+
+ if not price:
+ _logger.warning(f"Melewati layanan dengan harga 0: {courier_name} - {service_name}")
+ continue
+
+ duration = service.get('duration', '')
+ shipment_range = service.get('shipment_duration_range', '')
+ shipment_unit = service.get('shipment_duration_unit', 'days')
+
+ if duration:
+ etd = duration
+ elif shipment_range:
+ etd = f"{shipment_range} {shipment_unit}"
+ else:
+ etd = "1-3 days"
- destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id
- if not destination_subsdistrict_id:
- raise UserError("Gagal mendapatkan ID kota tujuan.")
-
- result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id)
- if result:
- shipping_options = []
- for courier in result['rajaongkir']['results']:
- for cost_detail in courier['costs']:
- service = cost_detail['service']
- description = cost_detail['description']
- etd = cost_detail['cost'][0]['etd']
- value = cost_detail['cost'][0]['value']
- shipping_options.append((service, description, etd, value, courier['code']))
-
- self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
-
- _logger.info(f"Shipping options: {shipping_options}")
-
- for service, description, etd, value, provider in shipping_options:
- self.env["shipping.option"].create({
- "name": service,
- "price": value,
- "provider": provider,
+ try:
+ shipping_option = self.env["shipping.option"].create({
+ "name": f"{courier_name} - {service_name}",
+ "price": price,
+ "provider": courier_code,
"etd": etd,
+ "courier_service_code": service.get('courier_service_code'),
"sale_order_id": self.id,
})
- self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id
+ shipping_options.append(shipping_option)
- _logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
+ courier_upper = courier_code.upper()
+ if courier_upper not in courier_options:
+ courier_options[courier_upper] = []
+ courier_options[courier_upper].append({
+ "name": service_name,
+ "etd": etd,
+ "price": price
+ })
- self.message_post(
- body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>"
- f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}",
- message_type="comment"
- )
+ _logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}")
+ except Exception as e:
+ _logger.error(f"Gagal membuat opsi pengiriman: {str(e)}")
+
+ if not shipping_options:
+ raise UserError(f"Tidak ada layanan pengiriman ditemukan untuk kode pos {destination_data.get('destination_postal_code', '')}. Mohon periksa kembali kode pos atau gunakan metode pengiriman lain.")
+
+ # Temukan shipping option yang cocok berdasarkan carrier_id
+ selected_option = None
- # self.message_post(body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}", message_type="comment")
+ if self.carrier_id:
+ rajaongkir_kurir = self.env['rajaongkir.kurir'].search([
+ ('delivery_carrier_id', '=', self.carrier_id.id)
+ ], limit=1)
+ if rajaongkir_kurir:
+ courier_code = rajaongkir_kurir.name.lower()
+ carrier_name = self.carrier_id.name.lower()
+
+ possible_codes = list({
+ courier_code,
+ carrier_name,
+ carrier_name.split()[0] if ' ' in carrier_name else carrier_name
+ })
+
+ _logger.info(f"[MATCHING] Mencari shipping option untuk kurir: {possible_codes}")
+
+ for option in shipping_options:
+ option_provider = (option.provider or '').lower()
+ option_name = (option.name or '').lower()
+
+ if any(code in option_provider or code in option_name for code in possible_codes):
+ selected_option = option
+ _logger.info(f"[MATCHED] Shipping option cocok: {option.name}")
+ break
+
+ if not selected_option and shipping_options:
+ selected_option = shipping_options[0]
+ _logger.info(f"[DEFAULT] Tidak ada yang cocok, pakai opsi pertama: {selected_option.name}")
+
+ # ❗ Ganti carrier_id hanya jika BELUM terisi sama sekali (contoh: user dari backend)
+ if not self.carrier_id:
+ provider = selected_option.provider.lower()
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id FROM rajaongkir_kurir
+ WHERE LOWER(name) = %s AND delivery_carrier_id IS NOT NULL
+ LIMIT 1
+ """, (provider,))
+ row = self.env.cr.fetchone()
+ matched_carrier_id = row[0] if row else False
+ if matched_carrier_id:
+ self.carrier_id = matched_carrier_id
+ _logger.info(f"[AUTO-SET] Carrier diisi otomatis ke ID {matched_carrier_id} (provider: {provider})")
+ else:
+ _logger.warning(f"[WARNING] Provider {provider} tidak ditemukan di rajaongkir_kurir")
+
+ # Set shipping option dan nilai ongkir
+ if selected_option:
+ self.shipping_option_id = selected_option.id
+ self.delivery_amt = selected_option.price
+ self.delivery_service_type = selected_option.courier_service_code
+ message_lines = [f"<b>Estimasi Ongkos Kirim Biteship:</b><br/>"]
+
+ for courier, options in courier_options.items():
+ message_lines.append(f"<b>{courier}:</b><br/>")
+ for opt in options:
+ message_lines.append(f"Service: {opt['name']}, ETD: {opt['etd']}, Cost: Rp {int(opt['price']):,}<br/>")
+ if courier != list(courier_options.keys())[-1]:
+ message_lines.append("<br/>")
+
+ origin_address = "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN"
+ destination_address = ', '.join(filter(None, [
+ shipping_address.street,
+ shipping_address.kelurahan_id.name if shipping_address.kelurahan_id else None,
+ shipping_address.kecamatan_id.name if shipping_address.kecamatan_id else None,
+ shipping_address.kota_id.name if shipping_address.kota_id else None,
+ shipping_address.state_id.name if shipping_address.state_id else None
+ ]))
+ if use_coordinate:
+ origin_suffix = f"(Koordinat: {origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})"
+ destination_suffix = f"(Koordinat: {destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})"
else:
- raise UserError("Gagal mendapatkan estimasi ongkir.")
+ origin_suffix = f"(Kode Pos: {origin_data.get('origin_postal_code')})"
+ destination_suffix = f"(Kode Pos: {destination_data.get('destination_postal_code')})"
+
+ message_lines.append("<br/><br/><br><b>Info Lokasi:</b><br/>")
+ message_lines.append(f"<b>Asal</b>: {origin_address} {origin_suffix}<br/>")
+ message_lines.append(f"<b>Tujuan</b>: {destination_address} {destination_suffix}<br/>")
+
+ message_body = "".join(message_lines)
+
+ self.message_post(
+ body=message_body,
+ message_type="comment"
+ )
+
+ # Simpan informasi untuk note ekspedisi
+ # selected_option = shipping_options[0] # Opsi pertama dipilih sebagai default
+ # self.note_ekspedisi = f"Pengiriman: {selected_option.name} - Rp {selected_option.price:,.0f} ({selected_option.etd}) [via {api_mode}]"
+
+
+ def _call_biteship_api(self, origin_data, destination_data, items, couriers=None):
+
+ url = "https://api.biteship.com/v1/rates/couriers"
+ api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
+ # api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live')
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test')
+ headers = {
+ 'Authorization': api_key,
+ 'Content-Type': 'application/json'
+ }
+
+ if not couriers:
+ couriers = ','.join(self._get_biteship_courier_codes())
+
+ # Persiapkan payload dengan menggabungkan origin, destination, dan items
+ payload = {
+ **origin_data,
+ **destination_data,
+ "couriers": couriers,
+ "items": items
+ }
+
+ api_mode = "koordinat" if "destination_latitude" in destination_data else "kode_pos"
+
+ try:
+ _logger.info(f"Calling Biteship API with mode: {api_mode}")
+ _logger.info(f"Payload: {payload}")
+
+ response = requests.post(url, headers=headers, json=payload, timeout=30)
+
+ _logger.info(f"Biteship API Status Code: {response.status_code}")
+ if response.status_code != 200:
+ _logger.error(f"Biteship API Error Response: {response.text}")
+
+ if response.status_code == 200:
+ result = response.json()
+ result['api_mode'] = api_mode # Tambahkan info mode API
+ return result
+ else:
+ error_msg = response.text
+ _logger.error(f"Error calling Biteship API: {response.status_code} - {error_msg}")
+ return False
+ except Exception as e:
+ _logger.error(f"Exception calling Biteship API: {str(e)}")
+ return False
+
def _call_rajaongkir_api(self, total_weight, destination_subsdistrict_id):
- url = 'https://pro.rajaongkir.com/api/cost'
+ url = 'https://rajaongkir.komerce.id/api/v1/calculate/domestic-cost'
headers = {
'key': '9b1310f644056d84d60b0af6bb21611a',
}
courier = self.carrier_id.name.lower()
data = {
- 'origin': 2127,
- 'originType': 'subdistrict',
+ 'origin': 17656,
+ # 'originType': 'subdistrict',
'destination': int(destination_subsdistrict_id),
- 'destinationType': 'subdistrict',
+ # 'destinationType': 'subdistrict',
'weight': int(total_weight * 1000),
'courier': courier,
}
- response = requests.post(url, headers=headers, data=data)
- if response.status_code == 200:
- return response.json()
- return None
+ try:
+ _logger.info(f"Calling RajaOngkir API with data: {data}")
+ response = requests.post(url, headers=headers, data=data)
+ _logger.info(f"RajaOngkir response: {response.status_code} - {response.text}")
+
+ if response.status_code == 200:
+ return response.json()
+ except Exception as e:
+ _logger.error(f"Exception while calling RajaOngkir: {str(e)}")
def _normalize_city_name(self, city_name):
city_name = city_name.lower()
@@ -590,37 +1261,82 @@ class SaleOrder(models.Model):
return city_name
- def _get_city_id_by_name(self, city_name):
- url = 'https://pro.rajaongkir.com/api/city'
+ # def _get_city_id_by_name(self, city_name):
+ # url = 'https://pro.rajaongkir.com/api/city'
+ # headers = {
+ # 'key': '9b1310f644056d84d60b0af6bb21611a',
+ # }
+
+ # normalized_city_name = self._normalize_city_name(city_name)
+
+ # response = requests.get(url, headers=headers)
+ # if response.status_code == 200:
+ # city_data = response.json()
+ # for city in city_data['rajaongkir']['results']:
+ # if city['city_name'].lower() == normalized_city_name:
+ # return city['city_id']
+ # return None
+
+ # def _get_subdistrict_id_by_name(self, city_id, subdistrict_name):
+ # url = f'https://pro.rajaongkir.com/api/subdistrict?city={city_id}'
+ # headers = {
+ # 'key': '9b1310f644056d84d60b0af6bb21611a',
+ # }
+
+ # response = requests.get(url, headers=headers)
+ # if response.status_code == 200:
+ # subdistrict_data = response.json()
+ # for subdistrict in subdistrict_data['rajaongkir']['results']:
+ # subsdistrict_1 = subdistrict['subdistrict_name'].lower()
+ # subsdistrict_2 = subdistrict_name.lower()
+
+ # if subsdistrict_1 == subsdistrict_2:
+ # return subdistrict['subdistrict_id']
+ # return None
+
+ def _get_subdistrict_id_from_komerce(self, kecamatan_name, kota_name, kelurahan_name=None):
+ url = 'https://rajaongkir.komerce.id/api/v1/destination/domestic-destination'
headers = {
'key': '9b1310f644056d84d60b0af6bb21611a',
}
- normalized_city_name = self._normalize_city_name(city_name)
-
- response = requests.get(url, headers=headers)
- if response.status_code == 200:
- city_data = response.json()
- for city in city_data['rajaongkir']['results']:
- if city['city_name'].lower() == normalized_city_name:
- return city['city_id']
- return None
+ if kelurahan_name:
+ search = f"{kelurahan_name} {kecamatan_name} {kota_name}"
+ else:
+ search = f"{kecamatan_name} {kota_name}"
- def _get_subdistrict_id_by_name(self, city_id, subdistrict_name):
- url = f'https://pro.rajaongkir.com/api/subdistrict?city={city_id}'
- headers = {
- 'key': '9b1310f644056d84d60b0af6bb21611a',
+ params = {
+ 'search': search,
+ 'limit': 5
}
- response = requests.get(url, headers=headers)
- if response.status_code == 200:
- subdistrict_data = response.json()
- for subdistrict in subdistrict_data['rajaongkir']['results']:
- subsdistrict_1 = subdistrict['subdistrict_name'].lower()
- subsdistrict_2 = subdistrict_name.lower()
+ try:
+ response = requests.get(url, headers=headers, params=params, timeout=10)
+ if response.status_code == 200:
+ data = response.json().get('data', [])
+ _logger.info(f"[Komerce] Fetched {len(data)} subdistricts for search '{search}'")
+ _logger.info(f"[Komerce] Response: {data}")
+
+ normalized_kota = self._normalize_city_name(kota_name)
+
+ for item in data:
+ match_kelurahan = (
+ not kelurahan_name or
+ item.get('subdistrict_name', '').lower() == kelurahan_name.lower()
+ )
+ if (
+ match_kelurahan and
+ item.get('district_name', '').lower() == kecamatan_name.lower() and
+ item.get('city_name', '').lower() == normalized_kota
+ ):
+ return item.get('id')
+
+ _logger.warning(f"[Komerce] No match for '{kecamatan_name}' in city '{kota_name}' with kelurahan '{kelurahan_name}'")
+ else:
+ _logger.error(f"[Komerce] HTTP Error {response.status_code}: {response.text}")
+ except Exception as e:
+ _logger.error(f"[Komerce] Exception: {e}")
- if subsdistrict_1 == subsdistrict_2:
- return subdistrict['subdistrict_id']
return None
def _compute_type_promotion(self):
@@ -684,38 +1400,102 @@ class SaleOrder(models.Model):
rec.compute_fullfillment = True
- @api.depends('date_order', 'estimated_arrival_days', 'state', 'estimated_arrival_days_start')
+ @api.depends('expected_ready_to_ship', 'shipping_option_id.etd', 'state')
def _compute_eta_date(self):
- current_date = datetime.now().date()
for rec in self:
- if rec.date_order and rec.state not in [
- 'cancel'] and rec.estimated_arrival_days and rec.estimated_arrival_days_start:
- rec.eta_date = current_date + timedelta(days=rec.estimated_arrival_days)
- rec.eta_date_start = current_date + timedelta(days=rec.estimated_arrival_days_start)
+ if rec.expected_ready_to_ship and rec.shipping_option_id and rec.shipping_option_id.etd and rec.state not in ['cancel']:
+ etd_text = rec.shipping_option_id.etd.strip().lower()
+ match = re.match(r"(\d+)\s*-\s*(\d+)\s*(days?|hours?)", etd_text)
+ single_match = re.match(r"(\d+)\s*(days?|hours?)", etd_text)
+
+ if match:
+ start_val = int(match.group(1))
+ end_val = int(match.group(2))
+ unit = match.group(3)
+
+ if 'hour' in unit:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=start_val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=end_val)
+ else:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=start_val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(days=end_val)
+
+ elif single_match:
+ val = int(single_match.group(1))
+ unit = single_match.group(2)
+
+ if 'hour' in unit:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=val)
+ else:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(days=val)
+
+ else:
+ rec.eta_date_start = False
+ rec.eta_date = False
else:
- rec.eta_date = False
rec.eta_date_start = False
-
+ rec.eta_date = False
+
+
def get_days_until_next_business_day(self, start_date=None, *args, **kwargs):
- today = start_date or datetime.today().date()
- offset = 0 # Counter jumlah hari yang ditambahkan
- holiday = self.env['hr.public.holiday']
+ jakarta = pytz.timezone("Asia/Jakarta")
+ now = datetime.now(jakarta)
- while True:
- today += timedelta(days=1)
- offset += 1
+ if start_date is None:
+ start_date = now
- if today.weekday() >= 5:
- continue
+ if start_date.tzinfo is None:
+ start_date = jakarta.localize(start_date)
- is_holiday = holiday.search([("start_date", "=", today)])
- if is_holiday:
- continue
+ holiday = self.env['hr.public.holiday']
+ batas_waktu = datetime.strptime("15:00", "%H:%M").time()
+ current_day = start_date.date()
+ offset = 0
+ is3pm = False
+
+ # Step 1: Lewat jam 15 → Tambah 1 hari
+ if start_date.time() > batas_waktu:
+ is3pm = True
+ offset += 1
- break
+ # Step 2: Hitung hari libur selama offset itu
+ i = 0
+ total_days = 0
+ while i < offset:
+ current_day += timedelta(days=1)
+ total_days += 1
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if not is_weekend and not is_holiday:
+ i += 1 # hanya hitung hari kerja
+
+ # Step 3: Tambah 1 hari masa persiapan gudang
+ i = 0
+ while i < 1:
+ current_day += timedelta(days=1)
+ total_days += 1
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if not is_weekend and not is_holiday:
+ i += 1
+
+ # Step 4: Kalau current_day ternyata weekend/libur, cari hari kerja berikutnya
+ while True:
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if is_weekend or is_holiday:
+ current_day += timedelta(days=1)
+ total_days += 1
+ else:
+ break
+
+ offset = (current_day - start_date.date()).days
+ return offset, is3pm
- return offset
+
def calculate_sla_by_vendor(self, products):
product_ids = products.mapped('product_id.id') # Kumpulkan semua ID produk
include_instant = True # Default True, tetapi bisa menjadi False
@@ -724,7 +1504,7 @@ class SaleOrder(models.Model):
all_fast_products = all(
product.product_id.qty_free_bandengan >= product.product_uom_qty for product in products)
if all_fast_products:
- return {'slatime': 1, 'include_instant': include_instant}
+ return {'slatime': 0, 'include_instant': include_instant}
# Cari semua vendor pemenang untuk produk yang diberikan
vendors = self.env['purchase.pricelist'].search([
@@ -758,48 +1538,109 @@ class SaleOrder(models.Model):
if not rec.date_order:
rec.expected_ready_to_ship = False
return
-
- current_date = datetime.now().date()
-
+
+ jakarta = pytz.timezone("Asia/Jakarta")
+ current_date = datetime.now(jakarta)
+
max_slatime = 1 # Default SLA jika tidak ada
slatime = self.calculate_sla_by_vendor(rec.order_line)
max_slatime = max(max_slatime, slatime['slatime'])
-
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
+
+ offset , is3pm = self.get_days_until_next_business_day(current_date)
+ sum_days = max_slatime + offset
+ sum_days -= 1
if not rec.estimated_arrival_days:
rec.estimated_arrival_days = sum_days
eta_date = current_date + timedelta(days=sum_days)
+ if is3pm:
+ eta_date = datetime.combine(eta_date, time(10, 0)) # jam 10:00
+ eta_date = jakarta.localize(eta_date).astimezone(timezone.utc) # ubah ke UTC
+
+
+ eta_date = eta_date.astimezone(timezone.utc).replace(tzinfo=None)
rec.commitment_date = eta_date
rec.expected_ready_to_ship = eta_date
@api.depends("order_line.product_id", "date_order")
def _compute_etrts_date(self): # Function to calculate Estimated Ready To Ship Date
self._calculate_etrts_date()
+
+
+ # def _validate_expected_ready_ship_date(self):
+ # for rec in self:
+ # if not rec.order_line:
+ # _logger.info("⏩ Lewati validasi ERTS karena belum ada produk.")
+ # return # Lewati validasi jika belum ada produk
+
+ # now = fields.Datetime.now()
+ # expected_date = rec.expected_ready_to_ship and rec.expected_ready_to_ship.date() or None
+ # if not expected_date:
+ # return # Tidak validasi jika tidak ada input sama sekali
+
+ # sla = rec.calculate_sla_by_vendor()
+ # offset_day, lewat_jam_3 = rec.get_days_until_next_business_day()
+ # eta_minimum = now + timedelta(days=sla + offset_day)
+
+ # if expected_date < eta_minimum.date():
+ # rec.expected_ready_to_ship = eta_minimum
+ # raise ValidationError(
+ # "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
+ # .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
+ # )
def _validate_expected_ready_ship_date(self):
+ """
+ Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum.
+ Dipanggil setiap onchange / simpan SO.
+ """
for rec in self:
- if rec.expected_ready_to_ship and rec.commitment_date:
- current_date = datetime.now().date()
- # Hanya membandingkan tanggal saja, tanpa jam
- expected_date = rec.expected_ready_to_ship.date()
-
- max_slatime = 1 # Default SLA jika tidak ada
- slatime = self.calculate_sla_by_vendor(rec.order_line)
- max_slatime = max(max_slatime, slatime['slatime'])
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
- eta_minimum = current_date + timedelta(days=sum_days)
-
- if expected_date < eta_minimum:
- rec.expected_ready_to_ship = eta_minimum
- raise ValidationError(
- "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
- .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
- )
- else:
- rec.commitment_date = rec.expected_ready_to_ship
+ # ─────────────────────────────────────────────────────
+ # 1. Hanya validasi kalau field sudah terisi
+ # (quotation baru / belum ada tanggal → abaikan)
+ # ─────────────────────────────────────────────────────
+ if not rec.expected_ready_to_ship:
+ continue
+
+ current_date = datetime.now()
+
+ # ─────────────────────────────────────────────────────
+ # 2. Hitung SLA berdasarkan product lines (jika ada)
+ # ─────────────────────────────────────────────────────
+ products = rec.order_line
+ if products:
+ sla_data = rec.calculate_sla_by_vendor(products)
+ max_sla_time = sla_data.get('slatime', 1)
+ else:
+ # belum ada item → gunakan default 1 hari
+ max_sla_time = 1
+
+ # offset hari libur / weekend
+ offset, is3pm = rec.get_days_until_next_business_day(current_date)
+ min_days = max_sla_time + offset - 1
+ eta_minimum = current_date + timedelta(days=min_days)
+
+ # ─────────────────────────────────────────────────────
+ # 3. Validasi - raise error bila terlalu cepat
+ # ─────────────────────────────────────────────────────
+ if rec.expected_ready_to_ship.date() < eta_minimum.date():
+ # set otomatis ke tanggal minimum supaya user tidak perlu
+ # menekan Save dua kali
+ rec.expected_ready_to_ship = eta_minimum
+
+ raise ValidationError(
+ _("Tanggal 'Expected Ready to Ship' tidak boleh "
+ "lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.")
+ % {'tgl': eta_minimum.strftime('%d-%m-%Y')}
+ )
+ else:
+ # sinkronkan ke field commitment_date
+ rec.commitment_date = rec.expected_ready_to_ship
- @api.onchange('expected_ready_to_ship') # Hangle Onchange form Expected Ready to Ship
+
+
+
+ @api.onchange('expected_ready_to_ship') #Hangle Onchange form Expected Ready to Ship
def _onchange_expected_ready_ship_date(self):
self._validate_expected_ready_ship_date()
@@ -834,6 +1675,7 @@ class SaleOrder(models.Model):
'campaign_id': self.campaign_id.id,
'medium_id': self.medium_id.id,
'source_id': self.source_id.id,
+ 'down_payment': 229625 in [line.product_id.id for line in self.order_line],
'user_id': self.user_id.id,
'sale_id': self.id,
'invoice_user_id': self.user_id.id,
@@ -866,7 +1708,6 @@ class SaleOrder(models.Model):
def _validate_delivery_amt(self):
is_indoteknik = self.carrier_id.id == 1 or self.shipping_cost_covered == 'indoteknik'
is_active_id = not self.env.context.get('active_id', [])
-
if is_indoteknik and is_active_id:
if self.delivery_amt == 0:
if self.carrier_id.id == 1:
@@ -1035,11 +1876,11 @@ class SaleOrder(models.Model):
line_no += 1
line.line_no = line_no
- def write(self, vals):
- if 'carrier_id' in vals:
- for picking in self.picking_ids:
- if picking.state == 'assigned':
- picking.carrier_id = self.carrier_id
+ # def write(self, vals):
+ # if 'carrier_id' in vals:
+ # for picking in self.picking_ids:
+ # if picking.state == 'assigned':
+ # picking.carrier_id = self.carrier_id
def calculate_so_status(self):
so_state = ['sale']
@@ -1157,12 +1998,12 @@ class SaleOrder(models.Model):
helper_ids_str = self.env['ir.config_parameter'].sudo().get_param('sale.order.user_helper_ids')
return helper_ids_str.split(', ')
- def write(self, values):
- helper_ids = self._get_helper_ids()
- if str(self.env.user.id) in helper_ids:
- values['helper_by_id'] = self.env.user.id
-
- return super(SaleOrder, self).write(values)
+ # def write(self, values):
+ # helper_ids = self._get_helper_ids()
+ # if str(self.env.user.id) in helper_ids:
+ # values['helper_by_id'] = self.env.user.id
+ #
+ # return super(SaleOrder, self).write(values)
def check_due(self):
"""To show the due amount and warning stage"""
@@ -1236,9 +2077,9 @@ class SaleOrder(models.Model):
confirmed_bom = search_bom.filtered(lambda x: x.state == 'confirmed' or x.state == 'done')
if not confirmed_bom:
raise UserError(
- "Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi MD.")
+ "Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi Purchasing.")
else:
- raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi MD")
+ raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi Purchasing")
def check_duplicate_product(self):
for order in self:
@@ -1259,6 +2100,7 @@ class SaleOrder(models.Model):
self._validate_order()
for order in self:
+ order._validate_delivery_amt()
order._validate_uniform_taxes()
order.order_line.validate_line()
order.check_data_real_delivery_address()
@@ -1501,6 +2343,7 @@ class SaleOrder(models.Model):
def action_confirm(self):
for order in self:
+ order._validate_delivery_amt()
order._validate_uniform_taxes()
order.check_duplicate_product()
order.check_product_bom()
@@ -1691,20 +2534,95 @@ class SaleOrder(models.Model):
total_before_margin = sum(line.item_before_margin for line in order.order_line if line.product_id)
order.total_before_margin = total_before_margin
+ # Perhitungan Lama
+ # def _compute_total_percent_margin(self):
+ # for order in self:
+ # if order.amount_untaxed == 0:
+ # order.total_percent_margin = 0
+ # continue
+ # if order.shipping_cost_covered == 'indoteknik':
+ # delivery_amt = order.delivery_amt
+ # else:
+ # delivery_amt = 0
+ #
+ # net_margin = order.total_margin - order.biaya_lain_lain
+ #
+ # order.total_percent_margin = round(
+ # (net_margin / (order.amount_untaxed - order.fee_third_party)) * 100, 2)
+
+ # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
+ # order.total_percent_margin = round(
+ # (order.total_margin / (order.amount_untaxed - order.fee_third_party - order.biaya_lain_lain)) * 100, 2)
+ # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
+
def _compute_total_percent_margin(self):
for order in self:
if order.amount_untaxed == 0:
order.total_percent_margin = 0
continue
+
if order.shipping_cost_covered == 'indoteknik':
delivery_amt = order.delivery_amt
else:
delivery_amt = 0
- # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
- order.total_percent_margin = round(
- (order.total_margin / (order.amount_untaxed - order.fee_third_party - order.biaya_lain_lain)) * 100, 2)
- # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
+ net_margin = order.total_margin - order.fee_third_party - order.biaya_lain_lain
+
+
+ if order.amount_untaxed > 0:
+ order.total_percent_margin = round((net_margin / order.amount_untaxed) * 100, 2)
+ else:
+ order.total_percent_margin = 0
+
+ # @api.onchange('biaya_lain_lain')
+ # def _onchange_biaya_lain_lain(self):
+ # """Ketika biaya_lain_lain berubah, simpan nilai margin sebelumnya"""
+ # if hasattr(self, '_origin') and self._origin.id:
+ # # Hitung margin sebelum biaya_lain_lain ditambahkan
+ # if self.amount_untaxed > 0:
+ # original_net_margin = self.total_margin # tanpa biaya_lain_lain
+ # self.total_margin_excl_third_party = round(
+ # (original_net_margin / (self.amount_untaxed - self.fee_third_party)) * 100, 2)
+
+ def _prepare_before_margin_values(self, vals):
+ margin_sebelumnya = {}
+
+ margin_affecting_fields = [
+ 'biaya_lain_lain', 'fee_third_party', 'delivery_amt',
+ 'ongkir_ke_xpdc', 'shipping_cost_covered', 'order_line'
+ ]
+
+ if not any(field in vals for field in margin_affecting_fields):
+ return {}
+
+ for order in self:
+ if order.amount_untaxed <= 0:
+ continue
+
+ current_before = order.total_margin_excl_third_party or 0
+
+ # CASE 1: Before margin masih kosong → ambil dari item_percent_margin
+ if current_before == 0:
+ line_margin = 0
+ for line in order.order_line:
+ if line.item_percent_margin is not None:
+ line_margin = line.item_percent_margin
+ break
+ margin_sebelumnya[order.id] = line_margin
+ _logger.info(f"[BEFORE] SO {order.name}: Before margin kosong, ambil dari order line: {line_margin}%")
+ else:
+ # CASE 2: Ada perubahan field yang mempengaruhi margin
+ for field in margin_affecting_fields:
+ if field in vals:
+ old_val = getattr(order, field, 0) or 0
+ new_val = vals[field] or 0
+ if old_val != new_val:
+ margin_sebelumnya[order.id] = order.total_percent_margin
+ _logger.info(
+ f"[BEFORE] SO {order.name}: {field} berubah dari {old_val} ke {new_val}, simpan {order.total_percent_margin}%")
+ break
+
+ return margin_sebelumnya
@api.onchange('sales_tax_id')
def onchange_sales_tax_id(self):
@@ -1956,13 +2874,82 @@ class SaleOrder(models.Model):
order_line.discount = discount
order_line.order_id.use_button = True
+ def _auto_set_shipping_from_website(self):
+ if not self.env.context.get('from_website_checkout'):
+ return
+
+ for order in self:
+ # Validasi source website
+ if not order.source_id or order.source_id.id != 59:
+ continue
+
+ # Skip jika Self Pick Up
+ if int(order.carrier_id.id or 0) == 32:
+ _logger.info(f"[Checkout] Skip estimasi: Self Pickup untuk SO {order.name}")
+ order.select_shipping_option = 'custom'
+ continue
+
+ # Simpan pilihan user sebelum estimasi
+ user_carrier_id = order.carrier_id.id if order.carrier_id else None
+ user_service = order.delivery_service_type
+ user_amount = order.delivery_amt
+
+ # Jalankan estimasi untuk refresh data
+ order.select_shipping_option = 'biteship'
+ order.action_estimate_shipping()
+
+ # Restore pilihan user setelah estimasi
+ if user_carrier_id and user_service:
+ # Dapatkan provider
+ self.env.cr.execute("SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1", (user_carrier_id,))
+ result = self.env.cr.fetchone()
+ provider = result[0].lower() if result else order.env['delivery.carrier'].browse(user_carrier_id).name.lower().split()[0]
+
+ # Cari opsi yang cocok (prioritas: service code > nama > harga > fallback)
+ domain_options = [
+ [('courier_service_code', '=', user_service), ('provider', 'ilike', provider)], # exact service
+ [('name', 'ilike', user_service), ('provider', 'ilike', provider)], # nama service
+ [('price', '=', user_amount), ('provider', 'ilike', provider)] if user_amount > 0 else None, # harga sama
+ [('provider', 'ilike', provider)] # fallback
+ ]
+
+ matched_option = None
+ for domain in domain_options:
+ if domain:
+ matched_option = self.env['shipping.option'].search([('sale_order_id', '=', order.id)] + domain, limit=1)
+ if matched_option:
+ break
+
+ # Set opsi yang cocok atau buat manual
+ if matched_option:
+ order.shipping_option_id = matched_option.id
+ order.delivery_amt = matched_option.price
+ order.delivery_service_type = matched_option.courier_service_code
+
+ # Notif jika harga berubah
+ if user_amount > 0 and abs(matched_option.price - user_amount) > 1000:
+ order.message_post(body=f"Harga shipping berubah dari Rp {user_amount:,} ke Rp {matched_option.price:,}")
+
+ elif user_amount > 0:
+ # Buat opsi manual jika tidak ada yang cocok
+ manual_option = self.env['shipping.option'].create({
+ 'name': f"{provider.upper()} - {user_service}",
+ 'price': user_amount,
+ 'provider': provider,
+ 'courier_service_code': user_service,
+ 'sale_order_id': order.id,
+ })
+ order.shipping_option_id = manual_option.id
+
@api.model
def create(self, vals):
# Ensure partner details are updated when a sale order is created
order = super(SaleOrder, self).create(vals)
+ # _logger.info(f"[CREATE CONTEXT] {self.env.context}")
+ # order._auto_set_shipping_from_website()
order._compute_etrts_date()
order._validate_expected_ready_ship_date()
- order._validate_delivery_amt()
+ # order._validate_delivery_amt()
# order._check_total_margin_excl_third_party()
# order._update_partner_details()
return order
@@ -1995,31 +2982,42 @@ class SaleOrder(models.Model):
'customer_type': partner.customer_type,
})
- def write(self, vals):
- for order in self:
- if order.state in ['sale', 'cancel']:
- if 'order_line' in vals:
- new_lines = vals.get('order_line', [])
- for command in new_lines:
- if command[0] == 0: # A new line is being added
- raise UserError(
- "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.")
-
- res = super(SaleOrder, self).write(vals)
- # self._check_total_margin_excl_third_party()
- if any(fields in vals for fields in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']):
- self._validate_delivery_amt()
- if any(field in vals for field in ["order_line", "client_order_ref"]):
- self._calculate_etrts_date()
- return res
+ # def write(self, vals):
+ # for order in self:
+ # if order.state in ['sale', 'cancel']:
+ # if 'order_line' in vals:
+ # new_lines = vals.get('order_line', [])
+ # for command in new_lines:
+ # if command[0] == 0: # A new line is being added
+ # raise UserError(
+ # "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.")
+ #
+ # res = super(SaleOrder, self).write(vals)
+ # # self._check_total_margin_excl_third_party()
+ # if any(fields in vals for fields in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']):
+ # self._validate_delivery_amt()
+ # if any(field in vals for field in ["order_line", "client_order_ref"]):
+ # self._calculate_etrts_date()
+ # return res
# @api.depends('commitment_date')
def _compute_ready_to_ship_status_detail(self):
+ def is_empty(val):
+ """Helper untuk cek data kosong yang umum di Odoo."""
+ return val is None or val == "" or val == [] or val == {}
+
for order in self:
+ order.ready_to_ship_status_detail = 'On Track' # Default value
+
+ # Skip if no commitment date
+ if is_empty(order.commitment_date):
+ continue
+
eta = order.commitment_date
match_lines = self.env['purchase.order.sales.match'].search([
('sale_id', '=', order.id)
])
+
if match_lines:
for match in match_lines:
po = match.purchase_order_id
@@ -2028,17 +3026,219 @@ class SaleOrder(models.Model):
('order_id', '=', po.id),
('product_id', '=', product.id)
], limit=1)
+
+ if is_empty(po_line):
+ continue
+
stock_move = self.env['stock.move'].search([
('purchase_line_id', '=', po_line.id)
], limit=1)
+
+ if is_empty(stock_move) or is_empty(stock_move.picking_id):
+ continue
+
picking_in = stock_move.picking_id
- result_date = picking_in.date_done if picking_in else None
- if result_date:
- status = "Early" if result_date < eta else "Delay"
- result_date_str = result_date.strftime('%m/%d/%Y')
- eta_str = eta.strftime('%m/%d/%Y')
- order.ready_to_ship_status_detail = f"Expected: {eta_str} | Realtime: {result_date_str} | {status}"
- else:
- order.ready_to_ship_status_detail = "On Track"
- else:
- order.ready_to_ship_status_detail = 'On Track' \ No newline at end of file
+ result_date = picking_in.date_done
+
+ if is_empty(result_date):
+ continue
+
+ try:
+ if result_date < eta:
+ order.ready_to_ship_status_detail = f"Early (Actual: {result_date.strftime('%m/%d/%Y')})"
+ else:
+ order.ready_to_ship_status_detail = f"Delay (Actual: {result_date.strftime('%m/%d/%Y')})"
+ except Exception as e:
+ _logger.error(f"Error computing ready to ship status: {str(e)}")
+ continue
+
+ def write(self, vals):
+
+ margin_sebelumnya = self._prepare_before_margin_values(vals)
+
+ for order in self:
+ if order.state in ['sale', 'cancel']:
+ if 'order_line' in vals:
+ for command in vals.get('order_line', []):
+ if command[0] == 0:
+ raise UserError(
+ "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.")
+
+ order._update_delivery_service_type_from_shipping_option(vals)
+
+ if 'carrier_id' in vals:
+ for order in self:
+ for picking in order.picking_ids:
+ if picking.state == 'assigned':
+ picking.carrier_id = vals['carrier_id']
+
+ for picking in order.picking_ids:
+ if picking.state not in ['done', 'cancel', 'assigned']:
+ picking.write({'carrier_id': vals['carrier_id']})
+
+ try:
+ helper_ids = self._get_helper_ids()
+ if str(self.env.user.id) in helper_ids:
+ vals['helper_by_id'] = self.env.user.id
+ except:
+ pass
+
+ res = super(SaleOrder, self).write(vals)
+
+ # Update before margin setelah write
+ if margin_sebelumnya:
+ for order_id, margin_value in margin_sebelumnya.items():
+ _logger.info(f"[UPDATE] SO ID {order_id}: Set before margin ke {margin_value}%")
+ self.env.cr.execute("""
+ UPDATE sale_order
+ SET total_margin_excl_third_party = %s
+ WHERE id = %s
+ """, (margin_value, order_id))
+
+ self.env.cr.commit()
+ self.invalidate_cache(['total_margin_excl_third_party'])
+
+ # Validasi setelah write
+ if any(field in vals for field in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']):
+ self._validate_delivery_amt()
+
+ if any(field in vals for field in ["order_line", "client_order_ref"]):
+ self._calculate_etrts_date()
+
+ return res
+
+ def button_refund(self):
+ self.ensure_one()
+
+ invoice_ids = self.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
+
+ return {
+ 'name': 'Refund Sale Order',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'refund.sale.order',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': {
+ 'default_sale_order_ids': [(6, 0, [self.id])],
+ 'default_invoice_ids': [(6, 0, invoice_ids.ids)],
+ 'default_uang_masuk': sum(invoice_ids.mapped('amount_total')) + (self.delivery_amt or 0.0) + 1000,
+ 'default_ongkir': self.delivery_amt or 0.0,
+ 'default_bank': '', # bisa isi default bank kalau mau
+ 'default_account_name': '',
+ 'default_account_no': '',
+ 'default_refund_type': '',
+ },
+ }
+
+ def open_form_multi_create_refund(self):
+ if not self:
+ raise UserError("Tidak ada Sale Order yang dipilih.")
+
+ partner_set = set(self.mapped('partner_id.id'))
+ if len(partner_set) > 1:
+ raise UserError("Tidak dapat membuat refund untuk Multi SO dengan Customer berbeda. Harus memiliki Customer yang sama.")
+
+ invoice_status_set = set(self.mapped('invoice_status'))
+ if len(invoice_status_set) > 1:
+ raise UserError("Tidak dapat membuat refund untuk SO dengan status invoice berbeda. Harus memiliki status invoice yang sama.")
+
+ already_refunded = self.filtered(lambda so: so.has_refund)
+ if already_refunded:
+ so_names = ', '.join(already_refunded.mapped('name'))
+ raise UserError(f"❌ Tidak bisa refund ulang. {so_names} sudah melakukan refund.")
+
+ invoice_ids = self.mapped('invoice_ids').filtered(lambda inv: inv.state != 'cancel')
+ delivery_total = sum(self.mapped('delivery_amt'))
+ total_invoice = sum(invoice_ids.mapped('amount_total'))
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Create Refund',
+ 'res_model': 'refund.sale.order',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': {
+ 'default_sale_order_ids': [(6, 0, self.ids)],
+ 'default_invoice_ids': [(6, 0, invoice_ids.ids)],
+ 'default_uang_masuk': total_invoice + delivery_total + 1000,
+ 'default_ongkir': delivery_total,
+ 'default_bank': '',
+ 'default_account_name': '',
+ 'default_account_no': '',
+ 'default_refund_type': '',
+ }
+ }
+
+ @api.depends('refund_ids')
+ def _compute_has_refund(self):
+ for so in self:
+ so.has_refund = bool(so.refund_ids)
+
+ def action_view_related_refunds(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Refunds',
+ 'res_model': 'refund.sale.order',
+ 'view_mode': 'tree,form',
+ 'domain': [('sale_order_ids', 'in', [self.id])],
+ 'context': {'default_sale_order_ids': [self.id]},
+ }
+
+ def _compute_refund_ids(self):
+ for order in self:
+ refunds = self.env['refund.sale.order'].search([
+ ('sale_order_ids', 'in', [order.id])
+ ])
+ order.refund_ids = refunds
+
+ def _compute_refund_count(self):
+ for order in self:
+ order.refund_count = self.env['refund.sale.order'].search_count([
+ ('sale_order_ids', 'in', order.id)
+ ])
+
+ @api.depends('invoice_ids')
+ def _compute_advance_payment_move(self):
+ for order in self:
+ move = self.env['account.move'].search([
+ ('sale_id', '=', order.id),
+ ('journal_id', '=', 11),
+ ('state', '=', 'posted'),
+ ], limit=1, order="id desc")
+ order.advance_payment_move_id = move
+
+ @api.depends('invoice_ids')
+ def _compute_advance_payment_moves(self):
+ for order in self:
+ moves = self.env['account.move'].search([
+ ('sale_id', '=', order.id),
+ ('journal_id', '=', 11),
+ ('state', '=', 'posted'),
+ ])
+ order.advance_payment_move_ids = moves
+
+ @api.depends('invoice_ids')
+ def _compute_advance_payment_moves(self):
+ for order in self:
+ moves = self.env['account.move'].search([
+ ('sale_id', '=', order.id),
+ ('journal_id', '=', 11),
+ ('state', '=', 'posted'),
+ ])
+ order.advance_payment_move_ids = moves
+ order.advance_payment_move_count = len(moves)
+
+ def action_open_advance_payment_moves(self):
+ self.ensure_one()
+ moves = self.advance_payment_move_ids
+ if not moves:
+ return
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Journals Sales Order',
+ 'res_model': 'account.move',
+ 'view_mode': 'tree,form',
+ 'domain': [('id', 'in', moves.ids)],
+ 'target': 'current',
+ } \ No newline at end of file
diff --git a/indoteknik_custom/models/sale_order_delay.py b/indoteknik_custom/models/sale_order_delay.py
new file mode 100644
index 00000000..dfd94650
--- /dev/null
+++ b/indoteknik_custom/models/sale_order_delay.py
@@ -0,0 +1,31 @@
+from odoo import api, fields, models
+
+
+class SaleOrderDelay(models.Model):
+ _name = 'sale.order.delay'
+ _description = 'Sale Order Delay'
+ _primary_key = 'so_number'
+
+ so_number = fields.Char(string="SO Number", required=True)
+ days_delayed = fields.Integer(string="Day Delayed or Erly")
+ status = fields.Selection([
+ ('delayed', 'Delayed'),
+ ('on track', 'On Track'),
+ ('early', 'Early')
+ ], string='Status', required=True)
+
+ _sql_constraints = [
+ ('unique_so_number', 'unique(so_number)', 'SO Number must be unique!')
+ ]
+
+ def update_delay(self):
+ query = "SELECT check_so_delay();"
+ self.env.cr.execute(query)
+
+ @api.model
+ def create(self, vals):
+ return super(SaleOrderDelay, self).create(vals)
+
+ def write(self, vals):
+ return super(SaleOrderDelay, self).write(vals)
+ \ No newline at end of file
diff --git a/indoteknik_custom/models/sale_order_line.py b/indoteknik_custom/models/sale_order_line.py
index 9247d1c1..5e9fc362 100644
--- a/indoteknik_custom/models/sale_order_line.py
+++ b/indoteknik_custom/models/sale_order_line.py
@@ -5,20 +5,26 @@ from datetime import datetime, timedelta
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
+
item_margin = fields.Float('Margin', compute='compute_item_margin', help="Total Margin in Sales Order Header")
- item_before_margin = fields.Float('Before Margin', compute='compute_item_before_margin', help="Total Margin in Sales Order Header")
- item_percent_margin = fields.Float('%Margin', compute='compute_item_margin', help="Total % Margin in Sales Order Header")
+ item_before_margin = fields.Float('Before Margin', compute='compute_item_before_margin',
+ help="Total Margin in Sales Order Header")
+ item_percent_margin = fields.Float('%Margin', compute='compute_item_margin',
+ help="Total % Margin in Sales Order Header")
+ item_percent_margin_before = fields.Float('%Margin Before', compute='_compute_item_percent_margin_before',
+ help="Total % Margin excluding third party in Sales Order Header")
initial_discount = fields.Float('Initial Discount')
vendor_id = fields.Many2one(
'res.partner', string='Vendor', readonly=True,
change_default=True, index=True, tracking=1,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"
- )
- vendor_md_id = fields.Many2one('res.partner', string='MD Vendor')
+ )
+ vendor_md_id = fields.Many2one('res.partner', string='MD Vendor')
purchase_price = fields.Float('Purchase', required=True, digits='Product Price', default=0.0)
purchase_price_md = fields.Float('MD Purchase')
- purchase_tax_id = fields.Many2one('account.tax', string='Tax', domain=['|', ('active', '=', False), ('active', '=', True)])
+ purchase_tax_id = fields.Many2one('account.tax', string='Tax',
+ domain=['|', ('active', '=', False), ('active', '=', True)])
delivery_amt_line = fields.Float('DeliveryAmtLine', compute='compute_delivery_amt_line')
fee_third_party_line = fields.Float('FeeThirdPartyLine', compute='compute_fee_third_party_line', default=0)
line_no = fields.Integer('No', default=0, copy=False)
@@ -28,13 +34,15 @@ class SaleOrderLine(models.Model):
('info_vendor', 'Info Vendor'),
('penggabungan', 'Penggabungan'),
], string='Note', help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring")
- note_procurement = fields.Char(string='Note Detail', help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring")
+ note_procurement = fields.Char(string='Note Detail',
+ help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring")
vendor_subtotal = fields.Float(string='Vendor Subtotal', compute="_compute_vendor_subtotal")
amount_voucher_disc = fields.Float(string='Voucher Discount')
qty_reserved = fields.Float(string='Qty Reserved', compute='_compute_qty_reserved')
- product_available_quantity = fields.Float(string='Qty pickup by user',)
+ product_available_quantity = fields.Float(string='Qty pickup by user', )
reserved_from = fields.Char(string='Reserved From', copy=False)
- item_percent_margin_without_deduction = fields.Float('Margin Without Deduction', compute='_compute_item_margin_without_deduction')
+ item_percent_margin_without_deduction = fields.Float('Margin Without Deduction',
+ compute='_compute_item_margin_without_deduction')
weight = fields.Float(string='Weight')
md_vendor_id = fields.Many2one('res.partner', string='MD Vendor', readonly=True)
margin_md = fields.Float(string='Margin MD')
@@ -45,7 +53,8 @@ class SaleOrderLine(models.Model):
outgoing_moves = self.env['stock.move']
incoming_moves = self.env['stock.move']
- for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id):
+ for move in self.move_ids.filtered(
+ lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id):
if move.location_dest_id.usage == "customer":
if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
outgoing_moves |= move
@@ -80,7 +89,7 @@ class SaleOrderLine(models.Model):
if not self.product_uom or not self.product_id:
self.price_unit = 0.0
return
-
+
self.price_unit = self.price_unit
def _compute_qty_reserved(self):
@@ -128,6 +137,29 @@ class SaleOrderLine(models.Model):
else:
line.item_percent_margin_without_deduction = 0
+ def _compute_item_percent_margin_before(self):
+ for line in self:
+ if not line.product_id or line.product_id.type == 'service' \
+ or line.price_unit <= 0 or line.product_uom_qty <= 0 \
+ or not line.vendor_id:
+ line.item_percent_margin_before = 0
+ continue
+
+ sales_price = line.price_reduce_taxexcl * line.product_uom_qty
+
+ purchase_price = line.purchase_price
+ if line.purchase_tax_id and line.purchase_tax_id.price_include:
+ purchase_price = line.purchase_price / 1.11
+
+ purchase_price = purchase_price * line.product_uom_qty
+
+ margin_before = sales_price - purchase_price
+
+ if sales_price > 0:
+ line.item_percent_margin_before = round((margin_before / sales_price), 2) * 100
+ else:
+ line.item_percent_margin_before = 0
+
def compute_item_margin(self):
for line in self:
if not line.product_id or line.product_id.type == 'service' \
@@ -141,8 +173,8 @@ class SaleOrderLine(models.Model):
# minus with delivery if covered by indoteknik
if line.order_id.shipping_cost_covered == 'indoteknik':
sales_price -= line.delivery_amt_line
- if line.order_id.fee_third_party > 0:
- sales_price -= line.fee_third_party_line
+ # if line.order_id.fee_third_party > 0:
+ # sales_price -= line.fee_third_party_line
purchase_price = line.purchase_price
if line.purchase_tax_id.price_include:
@@ -158,7 +190,7 @@ class SaleOrderLine(models.Model):
line.item_percent_margin = 0
if not line.margin_md:
- line.margin_md = line.item_percent_margin
+ line.margin_md = line.item_percent_margin
def compute_item_before_margin(self):
for line in self:
@@ -169,7 +201,7 @@ class SaleOrderLine(models.Model):
continue
# calculate margin without tax
sales_price = line.price_reduce_taxexcl * line.product_uom_qty
-
+
purchase_price = line.purchase_price
if line.purchase_tax_id.price_include:
purchase_price = line.purchase_price / 1.11
@@ -183,7 +215,7 @@ class SaleOrderLine(models.Model):
# TODO : need to change this logic @stephan
if not self.product_id or self.product_id.type == 'service':
return
- elif self.product_id.categ_id.id == 34: # finish good / manufacturing only
+ elif self.product_id.categ_id.id == 34: # finish good / manufacturing only
cost = self.product_id.standard_price
self.purchase_price = cost
elif self.product_id.x_manufacture.override_vendor_id:
@@ -195,12 +227,12 @@ class SaleOrderLine(models.Model):
self.purchase_price = price
self.purchase_tax_id = taxes
# else:
- # purchase_price = self.env['purchase.pricelist'].search(
- # [('vendor_id', '=', self.vendor_id.id), ('product_id', '=', self.product_id.id)],
- # limit=1, order='count_trx_po desc, count_trx_po_vendor desc')
- # price, taxes = self._get_valid_purchase_price(purchase_price)
- # self.purchase_price = price
- # self.purchase_tax_id = taxes
+ # purchase_price = self.env['purchase.pricelist'].search(
+ # [('vendor_id', '=', self.vendor_id.id), ('product_id', '=', self.product_id.id)],
+ # limit=1, order='count_trx_po desc, count_trx_po_vendor desc')
+ # price, taxes = self._get_valid_purchase_price(purchase_price)
+ # self.purchase_price = price
+ # self.purchase_tax_id = taxes
# def _calculate_selling_price(self):
# rec_purchase_price, rec_taxes, rec_vendor_id = self._get_purchase_price(self.product_id)
@@ -260,7 +292,7 @@ class SaleOrderLine(models.Model):
price = 0
taxes = 24
- vendor_id = ''
+ vendor_id = False
human_last_update = purchase_price.human_last_update or datetime.min
system_last_update = purchase_price.system_last_update or datetime.min
@@ -271,18 +303,18 @@ class SaleOrderLine(models.Model):
if delta_time > human_last_update:
price = 0
taxes = 24
- vendor_id = ''
+ vendor_id = False
if system_last_update > human_last_update:
- #if purchase_price.taxes_system_id.type_tax_use == 'purchase':
+ # if purchase_price.taxes_system_id.type_tax_use == 'purchase':
price = purchase_price.system_price
taxes = purchase_price.taxes_system_id.id or 24
vendor_id = purchase_price.vendor_id.id
if delta_time > system_last_update:
price = 0
taxes = 24
- vendor_id = ''
-
+ vendor_id = False
+
return price, taxes, vendor_id
@api.onchange('product_id')
@@ -302,11 +334,11 @@ class SaleOrderLine(models.Model):
line.tax_id = line.order_id.sales_tax_id
# price, taxes = line._get_valid_purchase_price(purchase_price)
line.purchase_price = price
- line.purchase_tax_id = taxes
+ line.purchase_tax_id = taxes
attribute_values = line.product_id.product_template_attribute_value_ids.mapped('name')
attribute_values_str = ', '.join(attribute_values) if attribute_values else ''
-
+
line_name = ('[' + line.product_id.default_code + ']' if line.product_id.default_code else '') + ' ' + \
(line.product_id.name if line.product_id.name else '') + ' ' + \
('(' + attribute_values_str + ')' if attribute_values_str else '') + ' ' + \
@@ -324,7 +356,7 @@ class SaleOrderLine(models.Model):
price, taxes, vendor_id = self._get_purchase_price(line.product_id)
line.vendor_md_id = vendor_id if vendor_id else None
line.margin_md = line.item_percent_margin
- line.purchase_price_md = price
+ line.purchase_price_md = price
def compute_delivery_amt_line(self):
for line in self:
@@ -363,11 +395,15 @@ class SaleOrderLine(models.Model):
fiscal_position=self.env.context.get('fiscal_position')
)
- product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id)
+ product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order,
+ uom=self.product_uom.id)
price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(
self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
- new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
+ new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id,
+ self.product_uom_qty,
+ self.product_uom,
+ self.order_id.pricelist_id.id)
new_list_price = product.web_price
if new_list_price != 0:
@@ -390,8 +426,8 @@ class SaleOrderLine(models.Model):
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
lambda ptav:
- ptav.price_extra and
- ptav not in product.product_template_attribute_value_ids
+ ptav.price_extra and
+ ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
@@ -401,10 +437,15 @@ class SaleOrderLine(models.Model):
if self.order_id.pricelist_id.discount_policy == 'with_discount':
return product.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price
- product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id)
-
- final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
- base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
+ product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order,
+ uom=self.product_uom.id)
+
+ final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(
+ product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
+ base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id,
+ self.product_uom_qty,
+ self.product_uom,
+ self.order_id.pricelist_id.id)
base_price = product.web_price
if currency != self.order_id.pricelist_id.currency_id:
base_price = currency._convert(
@@ -413,7 +454,7 @@ class SaleOrderLine(models.Model):
# negative discounts (= surcharge) are included in the display price
return max(base_price, final_price)
-
+
def validate_line(self):
for line in self:
if line.product_id.id in [385544, 224484, 417724]:
diff --git a/indoteknik_custom/models/sale_orders_multi_update.py b/indoteknik_custom/models/sale_orders_multi_update.py
index 95cfde21..962f60b5 100644
--- a/indoteknik_custom/models/sale_orders_multi_update.py
+++ b/indoteknik_custom/models/sale_orders_multi_update.py
@@ -11,6 +11,13 @@ class SaleOrdersMultiUpdate(models.TransientModel):
sale_ids = self._context['sale_ids']
sales = self.env['sale.order'].browse(sale_ids)
sales.action_multi_update_invoice_status()
+
+ for sale in sales:
+ sale.message_post(
+ body="Sales Order has been marked as Completed",
+ message_type="comment"
+ )
+
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
diff --git a/indoteknik_custom/models/shipment_group.py b/indoteknik_custom/models/shipment_group.py
index 87d222a6..4969c35a 100644
--- a/indoteknik_custom/models/shipment_group.py
+++ b/indoteknik_custom/models/shipment_group.py
@@ -19,11 +19,13 @@ class ShipmentGroup(models.Model):
def sync_api_shipping(self):
for rec in self.shipment_line:
- if rec.shipment_id.carrier_id == 173:
- rec.picking_id.action_get_kgx_pod()
-
- if rec.shipment_id.carrier_id == 151:
- rec.picking_id.track_envio_shipment()
+ picking_names = [lines.picking_id.name for lines in self.shipment_line]
+ if rec.shipment_id.carrier_id.id == 173:
+ rec.picking_id.action_get_kgx_pod(
+ shipment=f"{self.number}"
+ )
+ elif rec.shipment_id.carrier_id.id == 151:
+ rec.picking_id.track_envio_shipment(shipment=f"{self.number}")
@api.depends('shipment_line.total_colly')
def _compute_total_colly_line(self):
diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py
index 0fcb7ca1..3e152f10 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -19,15 +19,19 @@ import re
_logger = logging.getLogger(__name__)
_biteship_url = "https://api.biteship.com/v1"
-_biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
+biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
-# _biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
+# biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
class StockPicking(models.Model):
_inherit = 'stock.picking'
_order = 'final_seq ASC'
+ tukar_guling_id = fields.Many2one(
+ 'tukar.guling',
+ string='Tukar Guling Reference'
+ )
konfirm_koli_lines = fields.One2many('konfirm.koli', 'picking_id', string='Konfirm Koli', auto_join=True,
copy=False)
scan_koli_lines = fields.One2many('scan.koli', 'picking_id', string='Scan Koli', auto_join=True, copy=False)
@@ -121,7 +125,7 @@ class StockPicking(models.Model):
waybill_id = fields.One2many(comodel_name='airway.bill', inverse_name='do_id', string='Airway Bill')
purchase_representative_id = fields.Many2one('res.users', related='move_lines.purchase_line_id.order_id.user_id',
string="Purchase Representative")
- carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method')
+ carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method', tracking=3)
shipping_status = fields.Char(string='Shipping Status', compute="_compute_shipping_status")
date_reserved = fields.Datetime(string="Date Reserved", help='Tanggal ter-reserved semua barang nya', copy=False)
status_printed = fields.Selection([
@@ -170,6 +174,10 @@ class StockPicking(models.Model):
area_name = fields.Char(string="Area", compute="_compute_area_name")
+ # def _get_biteship_api_key(self):
+ # # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test')
+ # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live')
+
@api.depends('real_shipping_id.kecamatan_id', 'real_shipping_id.kota_id')
def _compute_area_name(self):
for record in self:
@@ -269,14 +277,27 @@ class StockPicking(models.Model):
# Biteship Section
biteship_id = fields.Char(string="Biteship Respon ID")
- biteship_tracking_id = fields.Char(string="Biteship Trackcking ID")
+ biteship_tracking_id = fields.Char(string="Biteship Tracking ID")
biteship_waybill_id = fields.Char(string="Biteship Waybill ID")
- # estimated_ready_ship_date = fields.Datetime(string='ET Ready to Ship', copy=False, related='sale_id.estimated_ready_ship_date')
- # countdown_hours = fields.Float(string='Countdown in Hours', compute='_callculate_sequance', default=False, store=False, compute_sudo=False)
- # countdown_ready_to_ship = fields.Char(string='Countdown Ready to Ship', compute='_callculate_sequance', store=False, compute_sudo=False)
+ biteship_driver_name = fields.Char('Biteship Driver Name')
+ biteship_driver_phone = fields.Char('Biteship Driver Phone')
+ biteship_driver_plate_number = fields.Char('Biteship Driver Plate Number')
+ biteship_courier_link = fields.Char('Biteship Courier Link')
+ biteship_shipping_status = fields.Char('Biteship Shipping Status', help="Status pengiriman dari Biteship")
+ biteship_shipping_price = fields.Monetary('Biteship Shipping Price', currency_field='currency_id',
+ help="Harga pengiriman dari Biteship")
+ currency_id = fields.Many2one('res.currency', related='sale_id.currency_id', string='Currency', readonly=True)
final_seq = fields.Float(string='Remaining Time')
- shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO',
- related='sale_id.carrier_id')
+ shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method', related='sale_id.carrier_id',
+ help="Shipping Method yang digunakan di SO", tracking=3)
+ shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option',
+ related='sale_id.shipping_option_id',
+ help="Shipping Option yang digunakan di SO", tracking=3)
+ select_shipping_option_so = fields.Selection([
+ ('biteship', 'Biteship'),
+ ('custom', 'Custom'),
+ ], string='Shipping Type', related='sale_id.select_shipping_option', help="Shipping Type yang digunakan di SO",
+ tracking=3)
state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')],
string='Packing Status')
approval_invoice_date_id = fields.Many2one('approval.invoice.date', string='Approval Invoice Date')
@@ -288,8 +309,8 @@ class StockPicking(models.Model):
self.ensure_one()
if not self.name or not self.origin:
return False
- return f"{self.name} {self.origin}"
-
+ return f"{self.name}"
+
def _download_pod_photo(self, url):
"""Mengunduh foto POD dari URL"""
try:
@@ -298,48 +319,53 @@ class StockPicking(models.Model):
return base64.b64encode(response.content)
except Exception as e:
raise UserError(f"Gagal mengunduh foto POD: {str(e)}")
-
+
def _parse_datetime(self, dt_str):
"""Parse datetime string dari format KGX"""
try:
from datetime import datetime
- # Hilangkan timezone jika ada masalah parsing
+
+ if not dt_str:
+ return False
+
if '+' in dt_str:
dt_str = dt_str.split('+')[0]
+
return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S')
except ValueError:
return False
-
- def action_get_kgx_pod(self):
+
+
+ def action_get_kgx_pod(self, shipment=False):
self.ensure_one()
-
- awb_number = self._get_kgx_awb_number()
+
+ awb_number = shipment or self._get_kgx_awb_number()
if not awb_number:
raise UserError("Nomor AWB tidak dapat dibuat, pastikan picking memiliki name dan origin")
-
+
url = "https://kgx.co.id/get_detail_awb"
headers = {'Content-Type': 'application/json'}
- payload = {"params" : {'awb_number': awb_number}}
-
+ payload = {"params": {'awb_number': awb_number}}
+
try:
response = requests.post(url, headers=headers, data=json.dumps(payload))
response.raise_for_status()
data = response.json()
-
+
if data.get('result', {}).get('data', []):
pod_data = data['result']['data'][0].get('connote_pod', {})
photo_url = pod_data.get('photo')
-
+
self.kgx_pod_photo_url = photo_url
self.kgx_pod_signature = pod_data.get('signature')
self.kgx_pod_receiver = pod_data.get('receiver')
self.kgx_pod_receive_time = self._parse_datetime(pod_data.get('timeReceive'))
self.driver_arrival_date = self._parse_datetime(pod_data.get('timeReceive'))
-
+
return data
else:
raise UserError(f"Tidak ditemukan data untuk AWB: {awb_number}")
-
+
except requests.exceptions.RequestException as e:
raise UserError(f"Gagal mengambil data POD: {str(e)}")
@@ -529,13 +555,7 @@ class StockPicking(models.Model):
record.kgx_pod_photo = "No image available."
def action_fetch_lalamove_order(self):
- pickings = self.env['stock.picking'].search([
- ('picking_type_code', '=', 'outgoing'),
- ('state', '=', 'done'),
- ('carrier_id', '=', 9),
- ('lalamove_order_id', '!=', False)
- ])
- for picking in pickings:
+ for picking in self:
try:
order_id = picking.lalamove_order_id
apikey = self.env['ir.config_parameter'].sudo().get_param('lalamove.apikey')
@@ -584,6 +604,7 @@ class StockPicking(models.Model):
self.lalamove_phone = phone
self.lalamove_status = pod.get("status")
self.lalamove_delivered_at = delivered_at_dt
+ self.driver_arrival_date = delivered_at_dt
return data
raise UserError("No delivered data found in Lalamove response.")
@@ -619,18 +640,19 @@ class StockPicking(models.Model):
except ValueError:
raise UserError(f"Format waktu tidak sesuai: {date_str}")
- def track_envio_shipment(self):
+ def track_envio_shipment(self, shipment=False):
pickings = self.env['stock.picking'].search([
('picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
('carrier_id', '=', 151)
])
- for picking in pickings:
+ for picking in self:
+ name = shipment or picking.name
if not picking.name:
raise UserError("Name pada stock.picking tidak ditemukan.")
# API URL dan headers
- url = f"https://api.envio.co.id/v1/tracking/distribution?code={picking.name}"
+ url = f"https://api.envio.co.id/v1/tracking/distribution?code={name}"
headers = {
'Authorization': 'Bearer JZ0Seh6qpYJAC3CJHdhF7sPqv8B/uSSfZe1VX5BL?vPYdo',
'Content-Type': 'application/json',
@@ -686,46 +708,56 @@ class StockPicking(models.Model):
raise UserError(f"Kesalahan tidak terduga: {str(e)}")
def action_send_to_biteship(self):
-
if self.biteship_tracking_id:
raise UserError(f"Order ini sudah dikirim ke Biteship. Dengan Tracking Id: {self.biteship_tracking_id}")
- # Mencari data sale.order.line berdasarkan sale_id
- products = self.env['sale.order.line'].search([('order_id', '=', self.sale_id.id)])
-
- # Fungsi untuk membangun items_data dari order lines
- def build_items_data(lines):
- return [{
- "name": line.product_id.name,
- "description": line.name,
- "value": line.price_unit,
- "quantity": line.product_uom_qty,
- "weight": line.weight
- } for line in lines]
-
- # Items untuk pengiriman standard
- items_data_standard = build_items_data(products)
+ if self.sale_id.select_shipping_option == 'custom':
+ raise UserError(
+ "Shipping Option pada Sales Order ini adalah *Custom*. Tidak dapat dikirim melalui Biteship.")
+
+ def is_courier_need_coordinates(service_code):
+ return service_code in [
+ "instant", "same_day", "instant_car",
+ "instant_bike", "motorcycle", "mpv", "van", "truck",
+ "cdd_bak", "cdd_box", "engkel_box", "engkel_bak"
+ ]
+
+ # ✅ Ambil item dari move_line_ids_with_package (qty_done > 0)
+ items = []
+ for ml in self.move_line_ids_without_package:
+ if ml.qty_done <= 0:
+ continue
- # Items untuk pengiriman instant, mengambil product_id dari move_line_ids_without_package
- items_data_instant = []
- for move_line in self.move_line_ids_without_package:
- # Mencari baris di sale.order.line berdasarkan product_id dari move_line
- order_line = self.env['sale.order.line'].search([
+ product = ml.product_id
+ weight = product.weight or 0.1 # default minimal
+ line = ml.move_id.sale_line_id or self.env['sale.order.line'].search([
('order_id', '=', self.sale_id.id),
- ('product_id', '=', move_line.product_id.id)
+ ('product_id', '=', ml.product_id.id)
], limit=1)
- if order_line:
- items_data_instant.append({
- "name": order_line.product_id.name,
- "description": order_line.name,
- "value": order_line.price_unit,
- "quantity": move_line.qty_done,
- "weight": order_line.weight
- })
+ value = line.price_unit if line else 0
+ description = line.name if line else product.name
+
+ items.append({
+ "name": product.name,
+ "description": description,
+ "value": value,
+ "quantity": ml.qty_done,
+ "weight": int(weight * 1000),
+ })
+
+ if not items:
+ raise UserError("Pengiriman tidak dapat dilakukan karena tidak ada barang yang divalidasi (qty_done = 0).")
+
+ shipping_partner = self.real_shipping_id
+ courier_service_code = self.sale_id.delivery_service_type or "reg"
payload = {
- "reference_id ": self.sale_id.name,
+ "origin_coordinate": {
+ "latitude": -6.3031123,
+ "longitude": 106.7794934999
+ },
+ "reference_id": self.name,
"shipper_contact_name": self.carrier_id.pic_name or '',
"shipper_contact_phone": self.carrier_id.pic_phone or '',
"shipper_organization": self.carrier_id.name,
@@ -733,41 +765,39 @@ class StockPicking(models.Model):
"origin_contact_phone": "081717181922",
"origin_address": "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN",
"origin_postal_code": 14440,
- "destination_contact_name": self.real_shipping_id.name,
- "destination_contact_phone": self.real_shipping_id.phone or self.real_shipping_id.mobile,
- "destination_address": self.real_shipping_id.street,
- "destination_postal_code": self.real_shipping_id.zip,
+ "destination_contact_name": shipping_partner.name,
+ "destination_contact_phone": shipping_partner.phone or shipping_partner.mobile,
+ "destination_address": shipping_partner.street,
+ "destination_postal_code": shipping_partner.zip,
"origin_note": "BELAKANG INDOMARET",
- "courier_type": self.sale_id.delivery_service_type or "reg",
+ "destination_note": f"SO: {self.sale_id.name}",
+ "order_note": f"SO: {self.sale_id.name}",
+ "courier_type": courier_service_code,
"courier_company": self.carrier_id.name.lower(),
"delivery_type": "now",
- "destination_postal_code": self.real_shipping_id.zip,
- "items": items_data_standard
+ "items": items
}
- # Cek jika pengiriman instant atau same_day
- if self.sale_id.delivery_service_type and (
- "instant" in self.sale_id.delivery_service_type or "same_day" in self.sale_id.delivery_service_type):
- payload.update({
- "origin_coordinate": {
- "latitude": -6.3031123,
- "longitude": 106.7794934999
- },
- "destination_coordinate": {
- "latitude": self.real_shipping_id.latitude,
- "longitude": self.real_shipping_id.longtitude,
- },
- "items": items_data_instant
- })
+ if is_courier_need_coordinates(courier_service_code):
+ if not shipping_partner.latitude or not shipping_partner.longtitude:
+ raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).")
+
+ payload["destination_coordinate"] = {
+ "latitude": shipping_partner.latitude,
+ "longitude": shipping_partner.longtitude,
+ }
- api_key = _biteship_api_key
+ _logger.info(f"Payload untuk Biteship: {payload}")
+
+ # Kirim ke Biteship
+ api_key = biteship_api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
- # Kirim request ke Biteship
response = requests.post(_biteship_url + '/orders', headers=headers, json=payload)
+ _logger.info(f"Response dari Biteship: {response.text}")
if response.status_code == 200:
data = response.json()
@@ -775,17 +805,27 @@ class StockPicking(models.Model):
self.biteship_id = data.get("id", "")
self.biteship_tracking_id = data.get("courier", {}).get("tracking_id", "")
self.biteship_waybill_id = data.get("courier", {}).get("waybill_id", "")
- self.delivery_tracking_no = data.get("courier", {}).get("waybill_id", "")
-
- waybill_id = data.get("courier", {}).get("waybill_id", "")
+ self.delivery_tracking_no = self.biteship_waybill_id
+
+ waybill_id = self.biteship_waybill_id
+
+ self.message_post(
+ body=f"Biteship berhasil dilakukan.<br/>"
+ f"Kurir: {self.carrier_id.name}<br/>"
+ f"Tracking ID: {self.biteship_tracking_id or '-'}<br/>"
+ f"Resi: {waybill_id or '-'}<br/>"
+ f"Reference: {self.name}<br/>"
+ f"SO: {self.sale_id.name}",
+ message_type="comment"
+ )
message = f"✅ Berhasil Order ke Biteship! Resi: {waybill_id}" if waybill_id else "⚠️ Order berhasil, tetapi tidak ada nomor resi."
return {
'effect': {
- 'fadeout': 'slow', # Efek menghilang perlahan
- 'message': message, # Pesan sukses
- 'type': 'rainbow_man', # Efek animasi lucu Odoo
+ 'fadeout': 'slow',
+ 'message': message,
+ 'type': 'rainbow_man',
}
}
else:
@@ -917,6 +957,9 @@ class StockPicking(models.Model):
pending_section = None
# Invoice values.
invoice_vals = order._prepare_invoice()
+ invoice_date = self.date_done
+ invoice_vals['date'] = invoice_date
+ invoice_vals['invoice_date'] = invoice_date
# Invoice line values (keep only necessary sections).
for line in self.move_ids_without_package:
po_line = self.env['purchase.order.line'].search(
@@ -1055,38 +1098,40 @@ class StockPicking(models.Model):
self.approval_receipt_status = 'pengajuan1'
def ask_return_approval(self):
- for pick in self:
- if self.env.user.is_accounting:
- pick.approval_return_status = 'approved'
- continue
- else:
- pick.approval_return_status = 'pengajuan1'
-
- action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_stock_return_note_wizard')
-
- if self.picking_type_code == 'outgoing':
- if self.env.user.id in [3988, 3401, 20] or (
- self.env.user.has_group(
- 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin
- ):
- action['context'] = {'picking_ids': [x.id for x in self]}
- return action
- elif not self.env.user.has_group(
- 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin:
- raise UserError('Harus Purchasing yang Ask Return')
- else:
- raise UserError('Harus Sales Admin yang Ask Return')
-
- elif self.picking_type_code == 'incoming':
- if self.env.user.has_group('indoteknik_custom.group_role_purchasing') or (
- self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin
- ):
- action['context'] = {'picking_ids': [x.id for x in self]}
- return action
- elif not self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin:
- raise UserError('Harus Sales Admin yang Ask Return')
- else:
- raise UserError('Harus Purchasing yang Ask Return')
+ pass
+ raise UserError("Bisa langsung Validate")
+ # for pick in self:
+ # if self.env.user.is_accounting:
+ # pick.approval_return_status = 'approved'
+ # continue
+ # else:
+ # pick.approval_return_status = 'pengajuan1'
+ #
+ # action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_stock_return_note_wizard')
+ #
+ # if self.picking_type_code == 'outgoing':
+ # if self.env.user.id in [3988, 3401, 20] or (
+ # self.env.user.has_group(
+ # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin
+ # ):
+ # action['context'] = {'picking_ids': [x.id for x in self]}
+ # return action
+ # elif not self.env.user.has_group(
+ # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin:
+ # raise UserError('Harus Purchasing yang Ask Return')
+ # else:
+ # raise UserError('Harus Sales Admin yang Ask Return')
+ #
+ # elif self.picking_type_code == 'incoming':
+ # if self.env.user.has_group('indoteknik_custom.group_role_purchasing') or (
+ # self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin
+ # ):
+ # action['context'] = {'picking_ids': [x.id for x in self]}
+ # return action
+ # elif not self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin:
+ # raise UserError('Harus Sales Admin yang Ask Return')
+ # else:
+ # raise UserError('Harus Purchasing yang Ask Return')
def calculate_line_no(self):
@@ -1181,6 +1226,10 @@ class StockPicking(models.Model):
def button_validate(self):
self.check_invoice_date()
+ _logger.info("Kode Picking: %s", self.picking_type_id.code)
+ _logger.info("Group ID: %s", self.group_id)
+ _logger.info("Group ID ID: %s", self.group_id.id if self.group_id else None)
+ _logger.info("Is Internal Use: %s", self.is_internal_use)
threshold_datetime = waktu(2025, 4, 11, 6, 26)
group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id
users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])])
@@ -1269,6 +1318,19 @@ class StockPicking(models.Model):
current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self.date_reserved = current_time
+ # Validate Qty Demand Can't higher than Qty Product
+ for move_line in self.move_line_ids_without_package:
+ purchase_line = move_line.move_id.purchase_line_id
+ if purchase_line:
+ if purchase_line.product_uom_qty < move_line.product_uom_qty:
+ raise UserError(
+ _("Quantity demand (%s) tidak bisa lebih besar dari qty product (%s) untuk produk %s") % (
+ move_line.product_uom_qty,
+ purchase_line.product_uom_qty,
+ move_line.product_id.display_name
+ )
+ )
+
self.validation_minus_onhand_quantity()
self.responsible = self.env.user.id
# self.send_koli_to_so()
@@ -1280,7 +1342,6 @@ class StockPicking(models.Model):
self.final_seq = 0
self.set_picking_code_out()
self.send_koli_to_so()
-
if (self.state_reserve == 'done' and self.picking_type_code == 'internal' and 'BU/PICK/' in self.name
and self.linked_manual_bu_out):
if not self.linked_manual_bu_out.date_reserved:
@@ -1317,26 +1378,40 @@ class StockPicking(models.Model):
'target': 'new',
}
self.send_mail_bills()
+ if 'BU/PUT' in self.name:
+ self.automatic_reserve_product()
return res
+ def automatic_reserve_product(self):
+ if self.state == 'done':
+ po = self.env['purchase.order'].search([
+ ('name', '=', self.group_id.name)
+ ])
+
+ for line in po.order_sales_match_line:
+ if not line.bu_pick:
+ continue
+ line.bu_pick.action_assign()
+
def check_invoice_date(self):
for picking in self:
if picking.picking_type_code != 'outgoing' or 'BU/OUT/' not in picking.name or picking.partner_id.id == 96868:
continue
invoice = self.env['account.move'].search(
- [('sale_id', '=', picking.sale_id.id), ('state', 'not in', ['draft', 'cancel']), ('move_type', '=', 'out_invoice')], limit=1)
+ [('sale_id', '=', picking.sale_id.id), ('state', 'not in', ['draft', 'cancel']),
+ ('move_type', '=', 'out_invoice')], limit=1)
if not invoice:
continue
-
+
if not picking.so_lama and invoice and (not picking.date_doc_kirim or not invoice.invoice_date):
raise UserError("Tanggal Kirim atau Tanggal Invoice belum diisi!")
picking_date = fields.Date.to_date(picking.date_doc_kirim)
invoice_date = fields.Date.to_date(invoice.invoice_date)
- if picking_date != invoice_date and picking.update_date_doc_kirim_add:
+ if picking_date != invoice_date and picking.update_date_doc_kirim_add and not picking.so_lama:
raise UserError("Tanggal Kirim (%s) tidak sesuai dengan Tanggal Invoice (%s)!" % (
picking_date.strftime('%d-%m-%Y'),
invoice_date.strftime('%d-%m-%Y')
@@ -1515,25 +1590,25 @@ class StockPicking(models.Model):
new_picking.state_packing = 'packing_done'
self._use_faktur(vals)
self.sync_sale_line(vals)
- for picking in self:
- # Periksa apakah kondisi terpenuhi saat data diubah
- if (vals.get('picking_type_code', picking.picking_type_code) == 'incoming' and
- vals.get('location_dest_id', picking.location_dest_id.id) == 58):
- if 'name' in vals or picking.name.startswith('BU/IN/'):
- name_to_modify = vals.get('name', picking.name)
- if name_to_modify.startswith('BU/IN/'):
- vals['name'] = name_to_modify.replace('BU/IN/', 'BU/INPUT/', 1)
-
- if (vals.get('picking_type_code', picking.picking_type_code) == 'internal' and
- vals.get('location_id', picking.location_id.id) == 58):
- name_to_modify = vals.get('name', picking.name)
- if name_to_modify.startswith('BU/INT'):
- new_name = name_to_modify.replace('BU/INT', 'BU/IN', 1)
- # Periksa apakah nama sudah ada
- if self.env['stock.picking'].search_count(
- [('name', '=', new_name), ('company_id', '=', picking.company_id.id)]) > 0:
- new_name = f"{new_name}-DUP"
- vals['name'] = new_name
+ # for picking in self:
+ # # Periksa apakah kondisi terpenuhi saat data diubah
+ # if (vals.get('picking_type_code', picking.picking_type_code) == 'incoming' and
+ # vals.get('location_dest_id', picking.location_dest_id.id) == 58):
+ # if 'name' in vals or picking.name.startswith('BU/IN/'):
+ # name_to_modify = vals.get('name', picking.name)
+ # if name_to_modify.startswith('BU/IN/'):
+ # vals['name'] = name_to_modify.replace('BU/IN/', 'BU/INPUT/', 1)
+
+ # if (vals.get('picking_type_code', picking.picking_type_code) == 'internal' and
+ # vals.get('location_id', picking.location_id.id) == 58):
+ # name_to_modify = vals.get('name', picking.name)
+ # if name_to_modify.startswith('BU/INT'):
+ # new_name = name_to_modify.replace('BU/INT', 'BU/IN', 1)
+ # # Periksa apakah nama sudah ada
+ # if self.env['stock.picking'].search_count(
+ # [('name', '=', new_name), ('company_id', '=', picking.company_id.id)]) > 0:
+ # new_name = f"{new_name}-DUP"
+ # vals['name'] = new_name
return super(StockPicking, self).write(vals)
def _use_faktur(self, vals):
@@ -1614,27 +1689,51 @@ class StockPicking(models.Model):
order = self.env['sale.order'].search([('name', '=', self.sale_id.name)], limit=1)
+ sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', order.name)], limit=1)
+
+ product_shipped = []
+ for move_line in self.move_line_ids_without_package:
+ if move_line.qty_done > 0:
+ product_shipped.append({
+ 'name': move_line.product_id.name,
+ 'qty': move_line.qty_done
+ })
+
response = {
'delivery_order': {
'name': self.name,
- 'carrier': self.carrier_id.name or '',
- 'service': order.delivery_service_type or '',
+ 'carrier': self.carrier_id.name or '-',
+ 'service': order.delivery_service_type or '-',
'receiver_name': '',
'receiver_city': ''
},
+ 'delivered_date': self.driver_departure_date.strftime(
+ '%d %b %Y') if self.driver_departure_date != False else '-',
'delivered': False,
'status': self.shipping_status,
- 'waybill_number': self.delivery_tracking_no or '',
+ 'waybill_number': self.delivery_tracking_no or '-',
'delivery_status': None,
'eta': self.generate_eta_delivery(),
'is_biteship': True if self.biteship_id else False,
- 'manifests': self.get_manifests()
+ 'manifests': self.get_manifests(),
+ 'is_delay': True if sale_order_delay and sale_order_delay.status == 'delayed' else False,
+ 'products': product_shipped
}
if self.biteship_id:
histori = self.get_manifest_biteship()
- eta_start = order.date_order + timedelta(days=order.estimated_arrival_days_start)
- eta_end = order.date_order + timedelta(days=order.estimated_arrival_days)
+ day_start = order.estimated_arrival_days_start
+ day_end = order.estimated_arrival_days
+ if sale_order_delay:
+ if sale_order_delay.status == 'delayed':
+ day_start = day_start + sale_order_delay.days_delayed
+ day_end = day_end + sale_order_delay.days_delayed
+ elif sale_order_delay.status == 'early':
+ day_start = day_start - sale_order_delay.days_delayed
+ day_end = day_end - sale_order_delay.days_delayed
+
+ eta_start = order.date_order + timedelta(days=day_start)
+ eta_end = order.date_order + timedelta(days=day_end)
formatted_eta = f"{eta_start.strftime('%d %b')} - {eta_end.strftime('%d %b %Y')}"
response['eta'] = formatted_eta
response['manifests'] = histori.get("manifests", [])
@@ -1656,7 +1755,7 @@ class StockPicking(models.Model):
return response
def get_manifest_biteship(self):
- api_key = _biteship_api_key
+ api_key = biteship_api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
@@ -1669,16 +1768,15 @@ class StockPicking(models.Model):
response = requests.get(_biteship_url + '/trackings/' + self.biteship_tracking_id, headers=headers,
json=manifests)
result = response.json()
- description = {
- 'confirmed': 'Indoteknik telah melakukan permintaan pick-up',
- 'allocated': 'Kurir akan melakukan pick-up pesanan',
- 'picking_up': 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
- 'picked': 'Pesanan sudah di pick-up kurir ' + result.get("courier", {}).get("name", ""),
- 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman',
- 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
- 'delivered': 'Pesanan telah sampai dan diterima oleh ' + result.get("destination", {}).get(
- "contact_name", "")
- }
+ # description = {
+ # 'confirmed' : 'Indoteknik telah melakukan permintaan pick-up',
+ # 'allocated' : 'Kurir akan melakukan pick-up pesanan',
+ # 'picking_up' : 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
+ # 'picked' : 'Pesanan sudah di pick-up kurir '+result.get("courier", {}).get("company", ""),
+ # 'on_hold' : 'Pesanan ditahan sementara karena masalah pengiriman',
+ # 'dropping_off' : 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
+ # 'delivered' : f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{result.get("destination", {}).get("contact_name", "")}</span>'
+ # }
if (result.get('success') == True):
history = result.get("history", [])
status = result.get("status", "")
@@ -1687,7 +1785,7 @@ class StockPicking(models.Model):
manifests.append({
"status": re.sub(r'[^a-zA-Z0-9\s]', ' ', entry["status"]).lower().capitalize(),
"datetime": self._convert_to_local_time(entry["updated_at"]),
- "description": description[entry["status"]],
+ "description": self._get_biteship_status_description(entry["status"], result),
})
return {
@@ -1695,19 +1793,205 @@ class StockPicking(models.Model):
"delivered": status
}
- return manifests
+ return {
+ "manifests": [],
+ "delivered": False
+ }
except Exception as e:
_logger.error(f"Error fetching Biteship order for picking {self.id}: {str(e)}")
return {'error': str(e)}
- def _convert_to_local_time(self, iso_date):
+ # ACTION GET TRACKING MANUAL BITESHIP
+ # def action_sync_biteship_tracking(self):
+ # for picking in self:
+ # if not picking.biteship_id:
+ # raise UserError("Tracking Biteship tidak tersedia.")
+
+ # histori = picking.get_manifest_biteship()
+ # updated_fields = {}
+ # seen_logs = set()
+
+ # manifests = sorted(histori.get("manifests", []), key=lambda m: m.get("datetime") or "")
+
+ # for manifest in manifests:
+ # status = manifest.get("status", "").lower()
+ # dt_str = manifest.get("datetime")
+ # desc = manifest.get("description")
+ # dt = False
+
+ # try:
+ # dt = picking._convert_to_utc_datetime(dt_str)
+ # _logger.info(f"[Biteship Sync] Berhasil parse datetime: {dt_str} -> {dt}")
+ # except Exception as e:
+ # _logger.warning(f"[Biteship Sync] Gagal parse datetime: {e}")
+ # continue
+
+ # # Update tanggal ke field (pastikan naive datetime UTC)
+ # if status == "picked" and dt and not picking.driver_departure_date:
+ # updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+
+ # if status == "delivered" and dt and not picking.driver_arrival_date:
+ # updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+
+ # # Buat log unik dengan waktu lokal Asia/Jakarta
+ # if dt and desc:
+ # try:
+ # dt_local = parser.parse(dt_str).replace(tzinfo=None)
+ # except Exception as e:
+ # _logger.warning(f"[Biteship Sync] Gagal parse dt_str untuk log: {e}")
+ # dt_local = dt # fallback
+
+ # desc_clean = ' '.join(desc.strip().split())
+ # log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}: {desc_clean}"
+ # if not picking._has_existing_log(log_line):
+ # picking.message_post(body=log_line)
+ # seen_logs.add(log_line)
+
+ # if updated_fields:
+ # picking.write(updated_fields)
+
+ def action_open_biteship_tracking(self):
+ self.ensure_one()
+ if not self.biteship_courier_link:
+ raise UserError("Biteship tracking link tidak tersedia.")
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': self.biteship_courier_link,
+ 'target': 'new',
+ }
+
+ def _get_biteship_status_description(self, status, data=None):
+ data = data or {}
+ courier = data.get("courier", {}).get("company", "")
+ contact_name = data.get("destination", {}).get("contact_name", "")
+
+ description_map = {
+ 'confirmed': 'Indoteknik telah melakukan permintaan pick-up',
+ 'allocated': 'Kurir akan melakukan pick-up pesanan',
+ 'picking_up': 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
+ 'picked': f'Pesanan sudah di pick-up kurir {courier}',
+ 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
+ 'delivered': f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{contact_name}</span>',
+ 'return_in_transit': 'Pesanan dalam perjalanan kembali ke pengirim',
+ 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman',
+ 'rejected': 'Pesanan ditolak, silakan hubungi Biteship',
+ 'courier_not_found': 'Pesanan dibatalkan karena tidak ada kurir tersedia',
+ 'returned': 'Pesanan berhasil dikembalikan',
+ 'disposed': 'Pesanan sudah dimusnahkan',
+ 'cancelled': 'Pesanan dibatalkan oleh sistem atau pengguna',
+ }
+
+ return description_map.get(status, f"Status '{status}' diterima dari Biteship")
+
+ def log_biteship_event_from_webhook(self, status, timestamp, description, extra_data=None):
+ self.ensure_one()
+ updated_fields = {}
+
try:
- dt_with_tz = waktu.fromisoformat(iso_date)
- utc_dt = dt_with_tz.astimezone(pytz.utc)
+ dt = self._convert_to_utc_datetime(timestamp)
+ except Exception as e:
+ _logger.warning(f"[Webhook] Gagal konversi waktu: {e}")
+ dt = datetime.utcnow()
+
+ # Penanganan status pengiriman
+ if status == "picked" and not self.driver_departure_date:
+ updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+ if status == "delivered" and not self.driver_arrival_date:
+ updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+
+ shipping_status = self._map_status_biteship(status)
+ if shipping_status and self.shipping_status != shipping_status:
+ updated_fields["shipping_status"] = shipping_status
+
+ # Penanganan extra data dari webhook
+ if extra_data:
+ # Informasi kurir
+ if extra_data.get("courier_driver_name"):
+ updated_fields["biteship_driver_name"] = extra_data["courier_driver_name"]
+ if extra_data.get("courier_driver_phone"):
+ updated_fields["biteship_driver_phone"] = extra_data["courier_driver_phone"]
+ if extra_data.get("courier_driver_plate_number"):
+ updated_fields["biteship_driver_plate_number"] = extra_data["courier_driver_plate_number"]
+ if extra_data.get("courier_link"):
+ updated_fields["biteship_courier_link"] = extra_data["courier_link"]
+ # Informasi harga
+ if extra_data.get("order_price"):
+ updated_fields["biteship_shipping_price"] = extra_data["order_price"]
+ # Status mentah dari Biteship
+ if extra_data.get("status"):
+ updated_fields["biteship_shipping_status"] = extra_data["status"]
+
+ # Tambahan untuk handle order.waybill_id
+ if extra_data.get("tracking_id"):
+ updated_fields["biteship_tracking_id"] = extra_data["tracking_id"]
+ updated_fields["delivery_tracking_no"] = extra_data["tracking_id"]
+ if extra_data.get("waybill_id"):
+ updated_fields["biteship_waybill_id"] = extra_data["waybill_id"]
+
+ # Konversi waktu lokal untuk log
+ try:
+ dt_parsed = parser.parse(timestamp)
+ if dt_parsed.tzinfo is None:
+ dt_parsed = dt_parsed.replace(tzinfo=pytz.utc)
+ dt_local = dt_parsed.astimezone(pytz.timezone("Asia/Jakarta"))
+ except Exception:
+ dt_local = dt.astimezone(pytz.timezone("Asia/Jakarta"))
+
+ # Format pesan log
+ desc_clean = ' '.join(description.strip().split())
+ log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}:<br/>{desc_clean}"
+
+ # Hindari log duplikat
+ if not self._has_existing_log(log_line):
+ biteship_user = self.env['res.users'].sudo().browse(15710) # ID live
+ # biteship_user = self.env['res.users'].sudo().browse(15710) # ID user (cek di db)
+ self.sudo().message_post(
+ body=log_line,
+ author_id=biteship_user.partner_id.id
+ )
- local_tz = pytz.timezone("Asia/Jakarta")
- local_dt = utc_dt.astimezone(local_tz)
+ # Update field-field terkait
+ if updated_fields:
+ self.write(updated_fields)
+ _logger.info(f"[Webhook] Updated fields on picking {self.name}: {updated_fields}")
+ def _has_existing_log(self, log_line):
+ self.ensure_one()
+ self.env.cr.execute("""
+ SELECT 1 FROM mail_message
+ WHERE model = %s AND res_id = %s
+ AND subtype_id IS NOT NULL
+ AND body ILIKE %s
+ LIMIT 1
+ """, (self._name, self.id, f"%{log_line}%"))
+ return self.env.cr.fetchone() is not None
+
+ # Untuk internal Odoo (mengembalikan naive UTC datetime untuk disimpan ke DB)
+ def _convert_to_utc_datetime(self, iso_date):
+ try:
+ if isinstance(iso_date, str):
+ waktu = parser.parse(iso_date)
+ else:
+ waktu = iso_date
+ if waktu.tzinfo is None:
+ waktu = waktu.replace(tzinfo=pytz.utc)
+ utc_dt = waktu.astimezone(pytz.utc).replace(tzinfo=None)
+ return utc_dt
+ except Exception as e:
+ _logger.warning(f"[Biteship] Gagal konversi waktu UTC: {e}")
+ return False
+
+ # Untuk tampilan di API atau kebutuhan web (mengembalikan string waktu lokal)
+ def _convert_to_local_time(self, iso_date):
+ try:
+ if isinstance(iso_date, str):
+ waktu = parser.parse(iso_date)
+ else:
+ waktu = iso_date
+ if waktu.tzinfo is None:
+ waktu = waktu.replace(tzinfo=pytz.utc)
+ local_tz = pytz.timezone("Asia/Jakarta")
+ local_dt = waktu.astimezone(local_tz)
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
return str(e)
@@ -1719,29 +2003,39 @@ class StockPicking(models.Model):
"allocated": "pending",
"picking_up": "pending",
"picked": "shipment",
- "cancelled": "cancelled",
- "on_hold": "on_hold",
"dropping_off": "shipment",
- "delivered": "completed"
+ "delivered": "completed",
+ "return_in_transit": "returning",
+ "on_hold": "on_hold",
+ "rejected": "cancelled",
+ "courier_not_found": "cancelled",
+ "returned": "returned",
+ "disposed": "disposed",
+ "cancelled": "cancelled"
}
return status_mapping.get(status, "Hubungi Admin")
def generate_eta_delivery(self):
current_date = datetime.datetime.now()
- prepare_days = 3
- start_date = self.driver_departure_date or self.create_date
-
- ead = self.sale_id.estimated_arrival_days or 0
- if not self.driver_departure_date:
- ead += prepare_days
-
- ead_datetime = datetime.timedelta(days=ead)
- fastest_eta = start_date + ead_datetime
- if not self.driver_departure_date and fastest_eta < current_date:
- fastest_eta = current_date + ead_datetime
-
- longest_days = 3
- longest_eta = fastest_eta + datetime.timedelta(days=longest_days)
+ days_start = self.sale_id.estimated_arrival_days_start or self.sale_id.estimated_arrival_days
+ days_end = self.sale_id.estimated_arrival_days or (self.sale_id.estimated_arrival_days + 3)
+ start_date = self.sale_id.create_date + datetime.timedelta(days=days_start)
+ end_date = self.sale_id.create_date + datetime.timedelta(days=days_end)
+
+ add_day_start = 0
+ add_day_end = 0
+ sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', self.sale_id.name)], limit=1)
+ if sale_order_delay:
+ if sale_order_delay.status == 'delayed':
+ add_day_start = sale_order_delay.days_delayed
+ add_day_end = sale_order_delay.days_delayed
+ elif sale_order_delay.status == 'early':
+ add_day_start = -abs(sale_order_delay.days_delayed)
+ add_day_end = -abs(sale_order_delay.days_delayed)
+
+ fastest_eta = start_date + datetime.timedelta(days=add_day_start + add_day_start)
+
+ longest_eta = end_date + datetime.timedelta(days=add_day_end)
format_time = '%d %b %Y'
format_time_fastest = '%d %b' if fastest_eta.year == longest_eta.year else format_time
@@ -2229,6 +2523,8 @@ class KonfirmKoli(models.Model):
copy=False,
)
pick_id = fields.Many2one('stock.picking', string='Pick')
+ product_id = fields.Many2one('product.product', string='Product')
+ qty_done = fields.Float(string='Qty Done')
@api.constrains('pick_id')
def _check_duplicate_pick_id(self):
@@ -2254,4 +2550,4 @@ class WarningModalWizard(models.TransientModel):
def action_continue(self):
if self.picking_id:
return self.picking_id.with_context(skip_koli_check=True).button_validate()
- return {'type': 'ir.actions.act_window_close'}
+ return {'type': 'ir.actions.act_window_close'} \ No newline at end of file
diff --git a/indoteknik_custom/models/stock_picking_return.py b/indoteknik_custom/models/stock_picking_return.py
index a683d80e..1fc8d088 100644
--- a/indoteknik_custom/models/stock_picking_return.py
+++ b/indoteknik_custom/models/stock_picking_return.py
@@ -1,38 +1,154 @@
-from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_round
+from odoo import models, fields, api, _
+import logging
+_logger = logging.getLogger(__name__)
class ReturnPicking(models.TransientModel):
_inherit = 'stock.return.picking'
- @api.model
- def default_get(self, fields):
- res = super(ReturnPicking, self).default_get(fields)
-
- stock_picking = self.env['stock.picking'].search([
- ('id', '=', res['picking_id']),
- ])
-
- # sale_id = stock_picking.group_id.sale_id
- if not stock_picking.approval_return_status == 'approved':
- raise UserError('Harus Approval Accounting AR untuk melakukan Retur')
-
- # purchase = self.env['purchase.order'].search([
- # ('name', '=', stock_picking.group_id.name),
- # ])
- # if not stock_picking.approval_return_status == 'approved' and purchase.invoice_ids:
- # raise UserError('Harus Approval Accounting AP untuk melakukan Retur')
-
- return res
-
+ # return_type = fields.Selection([
+ # ('revisi_so', 'Revisi SO'),
+ # ('tukar_guling', 'Tukar Guling')
+ # ], string='Jenis Retur', default='revisi_so')
+
+
+ def create_returns(self):
+ picking = self.picking_id
+ # guling = self.env['tukar.guling']
+ # if guling._is_already_returned(picking):
+ # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.")
+ # if self._is_already_returned(picking):
+ # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.")
+ # if picking.picking_type_id.id == 30 and picking.linked_manual_bu_out.state == 'done':
+ # raise UserError("❌ BU/PICK tidak dapat di retur karena BU/OUT Sudah Done")
+
+ if self._context.get('from_ui', True):
+ return self._redirect_to_tukar_guling()
+ return super(ReturnPicking, self).create_returns()
+
+ def _redirect_to_tukar_guling(self):
+ """Redirect ke Tukar Guling SO atau PO form dengan pre-filled data"""
+ self.ensure_one()
+ picking = self.picking_id
+
+ # Ambil lines valid
+ valid_lines = []
+ self.env.cr.execute("SELECT id FROM stock_return_picking_line WHERE wizard_id = %s", (self.id,))
+ line_ids = [row[0] for row in self.env.cr.fetchall()]
+ if line_ids:
+ existing_lines = self.env['stock.return.picking.line'].sudo().browse(line_ids)
+ for line in existing_lines:
+ if line.exists() and line.quantity > 0:
+ valid_lines.append(line)
+
+ if not valid_lines:
+ for line in self.product_return_moves:
+ if hasattr(line, 'quantity') and line.quantity > 0:
+ valid_lines.append(line)
+
+ if not valid_lines:
+ raise UserError(_("Tidak ada produk yang bisa diretur. Pastikan ada produk dengan quantity > 0."))
+
+ # Siapkan context
+ context = {
+ 'default_operations': picking.id,
+ 'default_date': fields.Datetime.now(),
+ 'default_state': 'draft',
+ 'default_notes': _('Retur dari %s') % picking.name,
+ 'from_return_picking': True,
+ }
+ if picking.origin:
+ context['default_origin'] = picking.origin
+ if picking.partner_id:
+ context['default_partner_id'] = picking.partner_id.id
+ if hasattr(picking, 'real_shipping_id') and picking.real_shipping_id:
+ context['default_real_shipping_id'] = picking.real_shipping_id.id
+ elif picking.partner_id:
+ context['default_real_shipping_id'] = picking.partner_id.id
+
+ # Siapkan product lines
+ line_vals = []
+ sequence = 10
+ for line in valid_lines:
+ quantity = getattr(line, 'quantity', 0)
+ if quantity <= 0:
+ continue
+ product = getattr(line, 'product_id', None)
+ if not product:
+ continue
+ line_vals.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': product.id,
+ 'product_uom_qty': quantity,
+ 'product_uom': product.uom_id.id,
+ 'name': product.display_name,
+ }))
+ sequence += 10
+ if line_vals:
+ context['default_line_ids'] = line_vals
+
+ if picking.picking_type_id.id == 29:
+ mapping_koli_vals = []
+ sequence = 10
+ returned_product_ids = set()
+
+ # Ambil move lines dari BU/PICK
+ for move_line in picking.move_line_ids_without_package:
+ # Cek apakah produk ini ada di daftar retur dan qty_done > 0
+ if move_line.product_id.id in returned_product_ids and move_line.qty_done > 0:
+ mapping_koli_vals.append((0, 0, {
+ 'sequence': sequence,
+ 'pick_id': picking.id, # ID BU/PICK itu sendiri
+ 'product_id': move_line.product_id.id,
+ 'qty_done': move_line.qty_done,
+ 'qty_return': move_line.qty_done,
+ }))
+ sequence += 10
+
+ if mapping_koli_vals:
+ context['default_mapping_koli_ids'] = mapping_koli_vals
+
+ if picking.purchase_id or 'PO' in picking.origin:
+ _logger.info("Redirect ke Tukar Guling PO via purchase_id / origin")
+ return {
+ 'name': _('Tukar Guling PO'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'tukar.guling.po',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': context,
+ }
+ else:
+ _logger.info("This picking is NOT from a PO, fallback to SO.")
+ return {
+ 'name': _('Tukar Guling SO'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'tukar.guling',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': context,
+ }
+
+
class ReturnPickingLine(models.TransientModel):
_inherit = 'stock.return.picking.line'
@api.onchange('quantity')
def _onchange_quantity(self):
+ """Validate quantity against done quantity"""
for rec in self:
- qty_done = rec.move_id.quantity_done
+ if rec.move_id and rec.quantity > 0:
+ # Get quantity done from the move
+ qty_done = rec.move_id.quantity_done
+
+ # If quantity_done is 0, use product_uom_qty as fallback
+ if qty_done == 0:
+ qty_done = rec.move_id.product_uom_qty
- if rec.quantity > qty_done:
- raise UserError(f"Quantity yang Anda masukkan tidak boleh melebihi quantity done yaitu: {qty_done}") \ No newline at end of file
+ if rec.quantity > qty_done:
+ raise UserError(
+ _("Quantity yang Anda masukkan (%.2f) tidak boleh melebihi quantity done yaitu: %.2f untuk produk %s")
+ % (rec.quantity, qty_done, rec.product_id.name)
+ )
diff --git a/indoteknik_custom/models/tukar_guling.py b/indoteknik_custom/models/tukar_guling.py
new file mode 100644
index 00000000..7253afb7
--- /dev/null
+++ b/indoteknik_custom/models/tukar_guling.py
@@ -0,0 +1,843 @@
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError, ValidationError
+import logging
+from datetime import datetime
+
+_logger = logging.getLogger(__name__)
+
+#TODO
+# 1. tracking status dokumen BU [X]
+# 2. ganti nama dokumen
+# 3. Tracking ketika create dokumen [X]
+# 4. Tracking ketika ganti field operations, date approval (sales, finance, logistic) [X]
+# 5. Ganti proses approval ke Sales, Finance, Logistic [X]
+# 6. Make sure bu pick dan out tidak bisa diedit ketika ort dan srt blm done
+# 7. change approval
+
+class TukarGuling(models.Model):
+ _name = 'tukar.guling'
+ _description = 'Tukar Guling'
+ _order = 'date desc, id desc'
+ _rec_name = 'name'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)
+ origin = fields.Char(string='Origin SO')
+ if_so = fields.Boolean('Is SO', default=True)
+ if_po = fields.Boolean('Is PO', default=False)
+ real_shipping_id = fields.Many2one('res.partner', string='Shipping Address')
+ picking_ids = fields.One2many(
+ 'stock.picking',
+ 'tukar_guling_id',
+ string='Transfers'
+ )
+ # origin_so = fields.Many2one('sale.order', string='Origin SO')
+ name = fields.Char('Number', required=True, copy=False, readonly=True, default='New')
+ date = fields.Datetime('Date', default=fields.Datetime.now, required=True)
+ operations = fields.Many2one(
+ 'stock.picking',
+ string='Operations',
+ domain=[
+ '|',
+ # BU/OUT
+ '&',
+ ('picking_type_id.id', '=', 29),
+ ('state', '=', 'done'),
+ '&',
+ '&',
+ ('picking_type_id.id', '=', 30),
+ ('state', '=', 'done'),
+ ('linked_manual_bu_out', '!=', 'done'),
+ ],
+ help='Nomor BU/OUT atau BU/PICK', tracking=3,
+ required=True
+ )
+ ba_num = fields.Text('Nomor BA')
+ notes = fields.Text('Notes')
+ return_type = fields.Selection(String='Return Type', selection=[
+ ('tukar_guling', 'Tukar Guling'), # -> barang yang sama
+ ('revisi_so', 'Revisi SO')], required=True, tracking=3)
+ state = fields.Selection(string='Status', selection=[
+ ('draft', 'Draft'),
+ ('approval_sales', ' Approval Sales'),
+ ('approval_finance', 'Approval Finance'),
+ ('approval_logistic', 'Approval Logistic'),
+ ('done', 'Done'),
+ ('cancel', 'Canceled')
+ ], default='draft', tracking=True, required=True)
+
+ line_ids = fields.One2many('tukar.guling.line', 'tukar_guling_id', string='Product Lines')
+ mapping_koli_ids = fields.One2many('tukar.guling.mapping.koli', 'tukar_guling_id', string='Mapping Koli')
+ date_finance = fields.Datetime('Approved Date Finance', tracking=3, readonly=True)
+ date_sales = fields.Datetime('Approved Date Sales', tracking=3, readonly=True)
+ date_logistic = fields.Datetime('Approved Date Logistic', tracking=3, readonly=True)
+
+ # @api.onchange('operations')
+ # def get_partner_id(self):
+ # if self.operations and self.operations.partner_id and self.operations.partner_id.name:
+ # self.partner_id == self.operations.partner_id.name
+
+ def _check_mapping_koli(self):
+ for record in self:
+ if record.operations.picking_type_id.id == 29: # Only for BU/OUT
+ if not record.mapping_koli_ids:
+ raise UserError("❌ Mapping Koli belum diisi")
+
+ # Calculate totals
+ total_mapping_qty = sum(int(mapping.qty_return) for mapping in record.mapping_koli_ids)
+ total_line_qty = sum(int(line.product_uom_qty) for line in record.line_ids)
+
+ if total_mapping_qty != total_line_qty:
+ raise UserError(
+ "❌ Total quantity return di mapping koli (%d) tidak sama dengan quantity retur product lines (%d)" %
+ (total_mapping_qty, total_line_qty)
+ )
+ else:
+ _logger.info("✅ Qty mapping koli sesuai dengan product lines")
+
+ @api.onchange('operations')
+ def _onchange_operations(self):
+ """Auto-populate lines ketika operations dipilih"""
+ if self.operations.picking_type_id.id not in [29,30]:
+ raise UserError("❌ Picking type harus BU/OUT atau BU/PICK")
+ for rec in self:
+ if rec.operations and rec.operations.picking_type_id.id == 30:
+ rec.return_type = 'revisi_so'
+
+ if self.operations:
+ from_return_picking = self.env.context.get('from_return_picking', False) or \
+ self.env.context.get('default_line_ids', False)
+
+ if self.line_ids and from_return_picking:
+ # Hanya update origin, jangan ubah lines
+ if self.operations.origin:
+ self.origin = self.operations.origin
+ _logger.info("📌 Menggunakan product lines dari return wizard, tidak populate ulang.")
+
+ # 🚀 Tapi tetap populate mapping koli jika BU/OUT
+ if self.operations.picking_type_id.id == 29:
+ mapping_koli_data = []
+ sequence = 10
+ tg_product_ids = self.line_ids.mapped('product_id.id')
+
+ for koli_line in self.operations.konfirm_koli_lines:
+ for move in koli_line.pick_id.move_line_ids_without_package:
+ if move.product_id.id in tg_product_ids:
+ mapping_koli_data.append((0, 0, {
+ 'sequence': sequence,
+ 'pick_id': koli_line.pick_id.id,
+ 'product_id': move.product_id.id,
+ 'qty_done': move.qty_done,
+ 'qty_return': 0
+ }))
+ sequence += 10
+
+ self.mapping_koli_ids = mapping_koli_data
+ _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines (from return wizard)")
+ return # keluar supaya tidak populate ulang lines
+
+ # Clear existing lines hanya jika tidak dari return picking
+ self.line_ids = [(5, 0, 0)]
+ self.mapping_koli_ids = [(5, 0, 0)] # Clear existing mapping koli juga
+
+ # Set origin dari operations
+ if self.operations.origin:
+ self.origin = self.operations.origin
+
+ # Auto-populate lines dari move_ids operations
+ lines_data = []
+ sequence = 10
+
+ # Untuk Odoo 14, gunakan move_ids_without_package atau move_lines
+ moves_to_check = []
+ if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package:
+ moves_to_check = self.operations.move_ids_without_package
+ elif hasattr(self.operations, 'move_lines') and self.operations.move_lines:
+ moves_to_check = self.operations.move_lines
+
+ # Collect product data
+ product_data = {}
+ for move in moves_to_check:
+ if move.product_id and move.product_uom_qty > 0:
+ product_id = move.product_id.id
+ if product_id not in product_data:
+ product_data[product_id] = {
+ 'product': move.product_id,
+ 'qty': move.product_uom_qty,
+ 'uom': move.product_uom.id,
+ 'name': move.name or move.product_id.display_name
+ }
+
+ # Buat lines_data
+ for product_id, data in product_data.items():
+ lines_data.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': product_id,
+ 'product_uom_qty': data['qty'],
+ 'product_uom': data['uom'],
+ 'name': data['name'],
+ }))
+ sequence += 10
+
+ if lines_data:
+ self.line_ids = lines_data
+ _logger.info(f"✅ Created {len(lines_data)} product lines")
+
+ # Prepare mapping koli jika BU/OUT
+ mapping_koli_data = []
+ sequence = 10
+
+ if self.operations.picking_type_id.id == 29:
+ tg_product_ids = [p for p in product_data]
+ for koli_line in self.operations.konfirm_koli_lines:
+ for move in koli_line.pick_id.move_line_ids_without_package:
+ if move.product_id.id in tg_product_ids:
+ mapping_koli_data.append((0, 0, {
+ 'sequence': sequence,
+ 'pick_id': koli_line.pick_id.id,
+ 'product_id': move.product_id.id,
+ 'qty_done': move.qty_done
+ }))
+ sequence += 10
+
+ if mapping_koli_data:
+ self.mapping_koli_ids = mapping_koli_data
+ _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines")
+ else:
+ _logger.info("⚠️ No mapping koli lines created")
+ else:
+ _logger.info("⚠️ No product lines created - no valid moves found")
+ else:
+ from_return_picking = self.env.context.get('from_return_picking', False) or \
+ self.env.context.get('default_line_ids', False)
+
+ if not from_return_picking:
+ self.line_ids = [(5, 0, 0)]
+ self.mapping_koli_ids = [(5, 0, 0)]
+
+ self.origin = False
+
+
+ def action_populate_lines(self):
+ """Manual button untuk populate lines - sebagai alternatif"""
+ self.ensure_one()
+ if not self.operations:
+ raise UserError("Pilih BU/OUT atau BU/PICK terlebih dahulu!")
+
+ # Clear existing lines
+ self.line_ids = [(5, 0, 0)]
+
+ lines_data = []
+ sequence = 10
+
+ # Ambil semua stock moves dari operations
+ for move in self.operations.move_ids:
+ if move.product_uom_qty > 0:
+ lines_data.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': move.product_id.id,
+ 'product_uom_qty': move.product_uom_qty,
+ 'product_uom': move.product_uom.id,
+ 'name': move.name or move.product_id.display_name,
+ }))
+ sequence += 10
+
+ if lines_data:
+ self.line_ids = lines_data
+ else:
+ raise UserError("Tidak ditemukan barang di BU/OUT yang dipilih!")
+
+ @api.constrains('return_type', 'operations')
+ def _check_required_bu_fields(self):
+ for record in self:
+ if record.return_type in ['revisi_so', 'tukar_guling'] and not record.operations:
+ raise ValidationError("Operations harus diisi")
+
+ @api.constrains('line_ids', 'state')
+ def _check_product_lines(self):
+ """Constraint: Product lines harus ada jika state bukan draft"""
+ for record in self:
+ if record.state in ('approval_sales', 'approval_logistic', 'approval_finance',
+ 'done') and not record.line_ids:
+ raise ValidationError("Product lines harus diisi sebelum submit atau approve!")
+
+ def _validate_product_lines(self):
+ """Helper method untuk validasi product lines"""
+ self.ensure_one()
+
+ # Check ada product lines
+ if not self.line_ids:
+ raise UserError("Belum ada product lines yang ditambahkan!")
+
+ # Check product sudah diisi
+ empty_lines = self.line_ids.filtered(lambda line: not line.product_id)
+ if empty_lines:
+ raise UserError("Ada product lines yang belum diisi productnya!")
+
+ # Check quantity > 0
+ zero_qty_lines = self.line_ids.filtered(lambda line: line.product_uom_qty <= 0)
+ if zero_qty_lines:
+ raise UserError("Quantity product tidak boleh kosong atau 0!")
+
+ return True
+
+ def _is_already_returned(self, picking):
+ return self.env['stock.picking'].search_count([
+ ('origin', '=', 'Return of %s' % picking.name),
+ ('state', '!=', 'cancel')
+ ]) > 0
+
+ @api.constrains('return_type', 'operations')
+ def _check_invoice_on_revisi_so(self):
+ for record in self:
+ if record.return_type == 'revisi_so' and record.origin:
+ invoices = self.env['account.move'].search([
+ ('invoice_origin', 'ilike', record.origin),
+ ('state', 'not in', ['draft', 'cancel'])
+ ])
+ if invoices:
+ raise ValidationError(
+ _("Tidak bisa memilih Return Type 'Revisi SO' karena dokumen %s sudah dibuat invoice.") % record.origin
+ )
+
+ @api.model
+ def create(self, vals):
+ # Generate sequence number
+ if not vals.get('name') or vals['name'] == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling')
+
+ # Auto-fill origin from operations
+ if not vals.get('origin') and vals.get('operations'):
+ picking = self.env['stock.picking'].browse(vals['operations'])
+ if picking.origin:
+ vals['origin'] = picking.origin
+ if picking.partner_id:
+ vals['partner_id'] = picking.partner_id.id
+
+ res = super(TukarGuling, self).create(vals)
+ res.message_post(body=_("CCM Created By %s") % self.env.user.name)
+ return res
+
+ def copy(self, default=None):
+ if default is None:
+ default = {}
+
+ # Generate new sequence untuk duplicate
+ sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling')], limit=1)
+ if sequence:
+ default['name'] = sequence.next_by_id()
+ else:
+ default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling') or 'copy'
+
+ default.update({
+ 'state': 'draft',
+ 'date': fields.Datetime.now(),
+ })
+
+ new_record = super(TukarGuling, self).copy(default)
+
+ # Re-sequence lines
+ if new_record.line_ids:
+ for i, line in enumerate(new_record.line_ids):
+ line.sequence = (i + 1) * 10
+
+ return new_record
+
+ def write(self, vals):
+ self.ensure_one()
+ if self.operations.picking_type_id.id not in [29,30]:
+ raise UserError("❌ Picking type harus BU/OUT atau BU/PICK")
+ self._check_invoice_on_revisi_so()
+ operasi = self.operations.picking_type_id.id
+ tipe = self.return_type
+ pp = vals.get('return_type', tipe)
+
+ if not self.operations:
+ raise UserError("Operations harus diisi!")
+
+ if not self.return_type:
+ raise UserError("Return Type harus diisi!")
+
+ if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done':
+ raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done")
+ if operasi == 30 and pp == 'tukar_guling':
+ raise UserError("❌ BU/PICK tidak boleh di retur tukar guling")
+ # else:
+ # _logger.info("hehhe")
+
+ if 'operations' in vals and not vals.get('origin'):
+ picking = self.env['stock.picking'].browse(vals['operations'])
+ if picking.origin:
+ vals['origin'] = picking.origin
+
+ return super(TukarGuling, self).write(vals)
+
+ def unlink(self):
+ # if self.state == 'done':
+ # raise UserError ("Tidak Boleh delete ketika sudahh done")
+ for record in self:
+ if record.state == 'done':
+ raise UserError(
+ "Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu jika ingin menghapus")
+ ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done')
+ for picking in ongoing_bu:
+ picking.action_cancel()
+ return super(TukarGuling, self).unlink()
+
+ def action_view_picking(self):
+ self.ensure_one()
+ action = self.env.ref('stock.action_picking_tree_all').read()[0]
+ pickings = self.picking_ids
+ if len(pickings) > 1:
+ action['domain'] = [('id', 'in', pickings.ids)]
+ elif pickings:
+ action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')]
+ action['res_id'] = pickings.id
+ return action
+
+ def action_draft(self):
+ """Reset to draft state"""
+ for record in self:
+ if record.state == 'cancel':
+ record.write({'state': 'draft'})
+ else:
+ raise UserError("Hanya record yang di-cancel yang bisa dikembalikan ke draft")
+
+ def _check_not_allow_tukar_guling_on_bu_pick(self, return_type=None):
+ operasi = self.operations.picking_type_id.id
+ tipe = return_type or self.return_type
+
+ if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done':
+ raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done")
+ if operasi == 30 and tipe == 'tukar_guling':
+ raise UserError("❌ BU/PICK tidak boleh di retur tukar guling")
+
+ def action_submit(self):
+ self.ensure_one()
+ self._check_not_allow_tukar_guling_on_bu_pick()
+
+ existing_tukar_guling = self.env['tukar.guling'].search([
+ ('operations', '=', self.operations.id),
+ ('id', '!=', self.id),
+ ('state', '!=', 'cancel'),
+ ], limit=1)
+
+ if existing_tukar_guling:
+ raise UserError("BU ini sudah pernah diretur oleh dokumen %s." % existing_tukar_guling.name)
+ picking = self.operations
+ if picking.picking_type_id.id == 30 and self.return_type == 'tukar_guling':
+ raise UserError("❌ BU/PICK tidak boleh di retur tukar guling")
+ if picking.picking_type_id.id == 29:
+ if picking.state != 'done':
+ raise UserError("BU/OUT belum Done!")
+ elif picking.picking_type_id.id == 30:
+ linked_bu_out = picking.linked_manual_bu_out
+ if linked_bu_out and linked_bu_out.state == 'done':
+ raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT suda Done!")
+ if self._is_already_returned(self.operations):
+ raise UserError("BU ini sudah pernah diretur oleh dokumen lain.")
+
+ if self.operations.picking_type_id.id == 29:
+ for line in self.line_ids:
+ mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id)
+ total_qty = sum(l.qty_return for l in mapping_lines)
+ if total_qty != line.product_uom_qty:
+ raise UserError(
+ _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name)
+
+ self._check_invoice_on_revisi_so()
+ self._validate_product_lines()
+
+ if self.state != 'draft':
+ raise UserError("Submit hanya bisa dilakukan dari Draft.")
+ self.state = 'approval_sales'
+
+ def action_approve(self):
+ self.ensure_one()
+ self._validate_product_lines()
+ self._check_invoice_on_revisi_so()
+ self._check_not_allow_tukar_guling_on_bu_pick()
+
+ operasi = self.operations.picking_type_id.id
+ tipe = self.return_type
+
+ if self.operations.picking_type_id.id == 29:
+ for line in self.line_ids:
+ mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id)
+ total_qty = sum(l.qty_return for l in mapping_lines)
+ if total_qty != line.product_uom_qty:
+ raise UserError(
+ _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name)
+
+ if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done':
+ raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done")
+ if operasi == 30 and tipe == 'tukar_guling':
+ raise UserError("❌ BU/PICK tidak boleh di retur tukar guling")
+ # else:
+ # _logger.info("hehhe")
+
+ if not self.operations:
+ raise UserError("Operations harus diisi!")
+
+ if not self.return_type:
+ raise UserError("Return Type harus diisi!")
+
+ now = datetime.now()
+
+ # Cek hak akses berdasarkan state
+ for rec in self:
+ if rec.state == 'approval_sales':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_sales'):
+ raise UserError("Hanya Sales Manager yang boleh approve tahap ini.")
+ rec.state = 'approval_finance'
+ rec.date_sales = now
+
+ elif rec.state == 'approval_finance':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_fat'):
+ raise UserError("Hanya Finance Manager yang boleh approve tahap ini.")
+ rec.state = 'approval_logistic'
+ rec.date_finance = now
+
+ elif rec.state == 'approval_logistic':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_logistic'):
+ raise UserError("Hanya Logistic Manager yang boleh approve tahap ini.")
+ rec.state = 'done'
+ rec._create_pickings()
+ rec.date_logistic = now
+
+ else:
+ raise UserError("Status ini tidak bisa di-approve.")
+
+ def action_cancel(self):
+ self.ensure_one()
+ # picking = self.env['stock.picking']
+
+ user = self.env.user
+ if not (
+ user.has_group('indoteknik_custom.group_role_sales') or
+ user.has_group('indoteknik_custom.group_role_fat') or
+ user.has_group('indoteknik_custom.group_role_logistic')
+ ):
+ raise UserWarning('Anda tidak memiliki Permission untuk cancel document')
+
+ bu_done = self.picking_ids.filtered(lambda p: p.state == 'done')
+ if bu_done:
+ raise UserError("Dokuemen BU sudah Done, tidak bisa di cancel")
+ ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done')
+ for picking in ongoing_bu:
+ picking.action_cancel()
+
+ # if self.state == 'done':
+ # raise UserError("Tidak bisa cancel jika sudah done")
+ self.state = 'cancel'
+
+ def _create_pickings(self):
+ _logger.info("🛠 Starting _create_pickings()")
+ for record in self:
+ if not record.operations:
+ raise UserError("BU/OUT dari field operations tidak ditemukan.")
+
+ bu_out = record.operations
+ mapping_koli = record.mapping_koli_ids
+
+ # Constants
+ PARTNER_LOCATION_ID = 5
+ BU_OUTPUT_LOCATION_ID = 60
+ BU_STOCK_LOCATION_ID = 57
+
+ # Picking Types
+ srt_type = self.env['stock.picking.type'].browse(73)
+ ort_type = self.env['stock.picking.type'].browse(74)
+ bu_pick_type = self.env['stock.picking.type'].browse(30)
+ bu_out_type = self.env['stock.picking.type'].browse(29)
+
+ created_returns = []
+
+ ### ======== SRT dari BU/OUT =========
+ srt_return_lines = []
+ for prod in mapping_koli.mapped('product_id'):
+ qty_total = sum(mk.qty_return for mk in mapping_koli.filtered(lambda m: m.product_id == prod))
+ move = bu_out.move_lines.filtered(lambda m: m.product_id == prod)
+ if not move:
+ raise UserError(f"Move BU/OUT tidak ditemukan untuk produk {prod.display_name}")
+ srt_return_lines.append((0, 0, {
+ 'product_id': prod.id,
+ 'quantity': qty_total,
+ 'move_id': move.id,
+ }))
+ _logger.info(f"📟 SRT line: {prod.display_name} | qty={qty_total}")
+
+ srt_picking = None
+ if srt_return_lines:
+ srt_wizard = self.env['stock.return.picking'].with_context({
+ 'active_id': bu_out.id,
+ 'default_location_id': PARTNER_LOCATION_ID,
+ 'default_location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'from_ui': False,
+ }).create({
+ 'picking_id': bu_out.id,
+ 'location_id': PARTNER_LOCATION_ID,
+ 'original_location_id': BU_OUTPUT_LOCATION_ID,
+ 'product_return_moves': srt_return_lines
+ })
+ srt_vals = srt_wizard.create_returns()
+ srt_picking = self.env['stock.picking'].browse(srt_vals['res_id'])
+ srt_picking.write({
+ 'location_id': PARTNER_LOCATION_ID,
+ 'location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'group_id': bu_out.group_id.id,
+ 'tukar_guling_id': record.id,
+ 'sale_order': record.origin
+ })
+ created_returns.append(srt_picking)
+ _logger.info(f"✅ SRT created: {srt_picking.name}")
+ record.message_post(
+ body=f"📦 <b>{srt_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{srt_picking.state}</b>)")
+
+ ### ======== ORT dari BU/PICK =========
+ ort_pickings = []
+ is_retur_from_bu_pick = record.operations.picking_type_id.id == 30
+ picks_to_return = [record.operations] if is_retur_from_bu_pick else mapping_koli.mapped('pick_id') or line.product_uom_qty
+
+ for pick in picks_to_return:
+ ort_return_lines = []
+ if is_retur_from_bu_pick:
+ # Ambil dari tukar.guling.line
+ for line in record.line_ids:
+ move = pick.move_lines.filtered(lambda m: m.product_id == line.product_id)
+ if not move:
+ raise UserError(
+ f"Move tidak ditemukan di BU/PICK {pick.name} untuk {line.product_id.display_name}")
+ ort_return_lines.append((0, 0, {
+ 'product_id': line.product_id.id,
+ 'quantity': line.product_uom_qty,
+ 'move_id': move.id,
+ }))
+ _logger.info(f"📟 ORT (BU/PICK langsung) | {pick.name} | {line.product_id.display_name} | qty={line.product_uom_qty}")
+ else:
+ # Ambil dari mapping koli
+ for mk in mapping_koli.filtered(lambda m: m.pick_id == pick):
+ move = pick.move_lines.filtered(lambda m: m.product_id == mk.product_id)
+ if not move:
+ raise UserError(
+ f"Move tidak ditemukan di BU/PICK {pick.name} untuk {mk.product_id.display_name}")
+ ort_return_lines.append((0, 0, {
+ 'product_id': mk.product_id.id,
+ 'quantity': mk.qty_return,
+ 'move_id': move.id,
+ }))
+ _logger.info(f"📟 ORT (mapping koli) | {pick.name} | {mk.product_id.display_name} | qty={mk.qty_return}")
+
+ if ort_return_lines:
+ ort_wizard = self.env['stock.return.picking'].with_context({
+ 'active_id': pick.id,
+ 'default_location_id': BU_OUTPUT_LOCATION_ID,
+ 'default_location_dest_id': BU_STOCK_LOCATION_ID,
+ 'from_ui': False,
+ }).create({
+ 'picking_id': pick.id,
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'original_location_id': BU_STOCK_LOCATION_ID,
+ 'product_return_moves': ort_return_lines
+ })
+ ort_vals = ort_wizard.create_returns()
+ ort_picking = self.env['stock.picking'].browse(ort_vals['res_id'])
+ ort_picking.write({
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'location_dest_id': BU_STOCK_LOCATION_ID,
+ 'group_id': bu_out.group_id.id,
+ 'tukar_guling_id': record.id,
+ 'sale_order': record.origin
+ })
+ created_returns.append(ort_picking)
+ ort_pickings.append(ort_picking)
+ _logger.info(f"✅ ORT created: {ort_picking.name}")
+ record.message_post(
+ body=f"📦 <b>{ort_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{ort_picking.state}</b>)")
+
+ ### ======== Tukar Guling: BU/OUT dan BU/PICK baru ========
+ if record.return_type == 'tukar_guling':
+
+ # BU/PICK Baru dari ORT
+ for ort_p in ort_pickings:
+ return_lines = []
+ for move in ort_p.move_lines:
+ if move.product_uom_qty > 0:
+ return_lines.append((0, 0, {
+ 'product_id': move.product_id.id,
+ 'quantity': move.product_uom_qty,
+ 'move_id': move.id
+ }))
+ _logger.info(
+ f"🔁 BU/PICK baru dari ORT {ort_p.name} | {move.product_id.display_name} | qty={move.product_uom_qty}")
+
+ if not return_lines:
+ _logger.warning(f"❌ Tidak ada qty > 0 di ORT {ort_p.name}, dilewati.")
+ continue
+
+ bu_pick_wizard = self.env['stock.return.picking'].with_context({
+ 'active_id': ort_p.id,
+ 'default_location_id': BU_STOCK_LOCATION_ID,
+ 'default_location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'from_ui': False,
+ }).create({
+ 'picking_id': ort_p.id,
+ 'location_id': BU_STOCK_LOCATION_ID,
+ 'original_location_id': BU_OUTPUT_LOCATION_ID,
+ 'product_return_moves': return_lines
+ })
+ bu_pick_vals = bu_pick_wizard.create_returns()
+ new_pick = self.env['stock.picking'].browse(bu_pick_vals['res_id'])
+ new_pick.write({
+ 'location_id': BU_STOCK_LOCATION_ID,
+ 'location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'group_id': bu_out.group_id.id,
+ 'tukar_guling_id': record.id,
+ 'sale_order': record.origin
+ })
+ new_pick.action_assign() # Penting agar bisa trigger check koli
+ new_pick.action_confirm()
+ created_returns.append(new_pick)
+ _logger.info(f"✅ BU/PICK Baru dari ORT created: {new_pick.name}")
+ record.message_post(
+ body=f"📦 <b>{new_pick.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_pick.state}</b>)")
+
+ # BU/OUT Baru dari SRT
+ if srt_picking:
+ return_lines = []
+ for move in srt_picking.move_lines:
+ if move.product_uom_qty > 0:
+ return_lines.append((0, 0, {
+ 'product_id': move.product_id.id,
+ 'quantity': move.product_uom_qty,
+ 'move_id': move.id,
+ }))
+ _logger.info(
+ f"🔁 BU/OUT baru dari SRT | {move.product_id.display_name} | qty={move.product_uom_qty}")
+
+ bu_out_wizard = self.env['stock.return.picking'].with_context({
+ 'active_id': srt_picking.id,
+ 'default_location_id': BU_OUTPUT_LOCATION_ID,
+ 'default_location_dest_id': PARTNER_LOCATION_ID,
+ 'from_ui': False,
+ }).create({
+ 'picking_id': srt_picking.id,
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'original_location_id': PARTNER_LOCATION_ID,
+ 'product_return_moves': return_lines
+ })
+ bu_out_vals = bu_out_wizard.create_returns()
+ new_out = self.env['stock.picking'].browse(bu_out_vals['res_id'])
+ new_out.write({
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'location_dest_id': PARTNER_LOCATION_ID,
+ 'group_id': bu_out.group_id.id,
+ 'tukar_guling_id': record.id,
+ 'sale_order': record.origin
+ })
+ created_returns.append(new_out)
+ _logger.info(f"✅ BU/OUT Baru dari SRT created: {new_out.name}")
+ record.message_post(
+ body=f"📦 <b>{new_out.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_out.state}</b>)")
+
+ if not created_returns:
+ raise UserError("Tidak ada dokumen retur berhasil dibuat.")
+
+ _logger.info("✅ Finished _create_pickings(). Created %s returns: %s",
+ len(created_returns),
+ ", ".join([p.name for p in created_returns]))
+
+
+class TukarGulingLine(models.Model):
+ _name = 'tukar.guling.line'
+ _description = 'Tukar Guling Line'
+ _order = 'sequence, id'
+
+ sequence = fields.Integer('Sequence', default=10, copy=False)
+ tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling', required=True, ondelete='cascade')
+ product_id = fields.Many2one('product.product', string='Product', required=True)
+ product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0)
+ product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
+ name = fields.Text('Description')
+
+ @api.constrains('product_uom_qty')
+ def _check_qty_change_allowed(self):
+ for rec in self:
+ if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']:
+ raise ValidationError("Tidak bisa mengubah Quantity karena status dokumen bukan Draft atau Cancel.")
+
+ def unlink(self):
+ for rec in self:
+ if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']:
+ raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.")
+ return super(TukarGulingLine, self).unlink()
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Override create to auto-assign sequence"""
+ for vals in vals_list:
+ if 'sequence' not in vals or vals.get('sequence', 0) <= 0:
+ # Get max sequence untuk tukar_guling yang sama
+ tukar_guling_id = vals.get('tukar_guling_id')
+ if tukar_guling_id:
+ max_seq = self.search([
+ ('tukar_guling_id', '=', tukar_guling_id)
+ ], order='sequence desc', limit=1)
+ vals['sequence'] = (max_seq.sequence or 0) + 10
+ else:
+ vals['sequence'] = 10
+ return super(TukarGulingLine, self).create(vals_list)
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ self.name = self.product_id.display_name
+ self.product_uom = self.product_id.uom_id
+
+
+class StockPicking(models.Model):
+ _inherit = 'stock.picking'
+
+ tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling Ref')
+
+ def button_validate(self):
+ res = super(StockPicking, self).button_validate()
+
+ for picking in self:
+ if picking.tukar_guling_id:
+ message = _(
+ "📦 <b>%s</b> Validated by <b>%s</b> Status Changed <b>%s</b> at <b>%s</b>."
+ ) % (
+ picking.name,
+ # picking.picking_type_id.name,
+ picking.env.user.name,
+ picking.state,
+ fields.Datetime.now().strftime("%d/%m/%Y %H:%M")
+ )
+ picking.tukar_guling_id.message_post(body=message)
+
+ return res
+
+
+
+class TukarGulingMappingKoli(models.Model):
+ _name = 'tukar.guling.mapping.koli'
+ _description = 'Mapping Koli di Tukar Guling'
+
+ tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling')
+ pick_id = fields.Many2one('stock.picking', string='BU PICK')
+ product_id = fields.Many2one('product.product', string='Product')
+ qty_done = fields.Float(string='Qty Done BU PICK')
+ qty_return = fields.Float(string='Qty diretur')
+ sequence = fields.Integer(string='Sequence', default=10)
+ @api.constrains('qty_return')
+ def _check_qty_return_editable(self):
+ for rec in self:
+ if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']:
+ raise ValidationError("Tidak Bisa ubah qty retur jika status sudah approval atau done.")
+
+ def unlink(self):
+ for rec in self:
+ if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']:
+ raise UserError("Tidak bisa menghapus Mapping Koli karena status Tukar Guling bukan Draft atau Cancel.")
+ return super(TukarGulingMappingKoli, self).unlink() \ No newline at end of file
diff --git a/indoteknik_custom/models/tukar_guling_po.py b/indoteknik_custom/models/tukar_guling_po.py
new file mode 100644
index 00000000..7c9680f8
--- /dev/null
+++ b/indoteknik_custom/models/tukar_guling_po.py
@@ -0,0 +1,662 @@
+from email.policy import default
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError, ValidationError
+import logging
+from datetime import datetime
+
+_logger = logging.getLogger(__name__)
+
+
+class TukarGulingPO(models.Model):
+ _name = 'tukar.guling.po'
+ _description = 'Tukar Guling PO'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ vendor_id = fields.Many2one('res.partner', string='Vendor Name', readonly=True)
+ origin = fields.Char(string='Origin PO')
+ is_po = fields.Boolean('Is PO', default=True)
+ is_so = fields.Boolean('Is SO', default=False)
+ name = fields.Char(string='Name', required=True)
+ po_picking_ids = fields.One2many(
+ 'stock.picking',
+ 'tukar_guling_po_id',
+ string='Picking Reference',
+ )
+ name = fields.Char('Number', required=True, copy=False, readonly=True, default='New')
+ date = fields.Datetime('Date', default=fields.Datetime.now, required=True)
+ date_purchase = fields.Datetime('Date Approve Purchase', readonly=True)
+ date_finance = fields.Datetime('Date Approve Finance', readonly=True)
+ date_logistic = fields.Datetime('Date Approve Logistic', readonly=True)
+ operations = fields.Many2one(
+ 'stock.picking',
+ string='Operations',
+ domain=[
+ ('picking_type_id.id', 'in', [75, 28]),
+ ('state', '=', 'done')
+ ], help='Nomor BU INPUT atau BU PUT', tracking=3
+ )
+ ba_num = fields.Char('Nomor BA', tracking=3)
+ return_type = fields.Selection([
+ ('revisi_po', 'Revisi PO'),
+ ('tukar_guling', 'Tukar Guling'),
+ ], string='Return Type', required=True, tracking=3)
+ notes = fields.Text('Notes', tracking=3)
+ tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade')
+ line_ids = fields.One2many('tukar.guling.line.po', 'tukar_guling_po_id', string='Product Lines', tracking=3)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('approval_purchase', 'Approval Purchasing'),
+ ('approval_finance', 'Approval Finance'),
+ ('approval_logistic', 'Approval Logistic'),
+ ('done', 'Done'),
+ ('cancel', 'Cancel'),
+ ], string='Status', default='draft', tracking=3)
+
+ @api.model
+ def create(self, vals):
+ # Generate sequence number
+ # ven_name = self.origin.search([('name', 'ilike', vals['origin'])])
+ if not vals.get('name') or vals['name'] == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po')
+
+ # Auto-fill origin from operations
+ if not vals.get('origin') and vals.get('operations'):
+ picking = self.env['stock.picking'].browse(vals['operations'])
+ if picking.origin:
+ vals['origin'] = picking.origin
+ if picking.group_id.id:
+ vals['vendor_id'] = picking.group_id.partner_id.id
+
+ res = super(TukarGulingPO, self).create(vals)
+ res.message_post(body=_("VCM Created By %s") % self.env.user.name)
+
+ return res
+
+ @api.constrains('return_type', 'operations')
+ def _check_bill_on_revisi_po(self):
+ for record in self:
+ if record.return_type == 'revisi_po' and record.origin:
+ bills = self.env['account.move'].search([
+ ('invoice_origin', 'ilike', record.origin),
+ ('move_type', '=', 'in_invoice'), # hanya vendor bill
+ ('state', 'not in', ['draft', 'cancel'])
+ ])
+ if bills:
+ raise ValidationError(
+ _("Tidak bisa memilih Return Type 'Revisi PO' karena PO %s sudah dibuat vendor bill.") % record.origin
+ )
+
+ @api.onchange('operations')
+ def _onchange_operations(self):
+ """Auto-populate lines ketika operations dipilih"""
+ if self.operations.picking_type_id.id not in [75, 28]:
+ raise UserError("❌ Picking type harus BU/INPUT atau BU/PUT")
+
+ if self.operations:
+ from_return_picking = self.env.context.get('from_return_picking', False) or \
+ self.env.context.get('default_line_ids', False)
+
+ if self.line_ids and from_return_picking:
+ # Hanya update origin, jangan ubah lines
+ if self.operations.origin:
+ self.origin = self.operations.origin
+ return
+
+ if from_return_picking:
+ # Gunakan qty dari context (stock return wizard)
+ default_lines = self.env.context.get('default_line_ids', [])
+ parsed_lines = []
+ sequence = 10
+ for line_data in default_lines:
+ if isinstance(line_data, (list, tuple)) and len(line_data) == 3:
+ vals = line_data[2]
+ parsed_lines.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': vals.get('product_id'),
+ 'product_uom_qty': vals.get('quantity'),
+ 'product_uom': self.env['product.product'].browse(vals.get('product_id')).uom_id.id,
+ 'name': self.env['product.product'].browse(vals.get('product_id')).display_name,
+ }))
+ sequence += 10
+
+ self.line_ids = parsed_lines
+ return
+ else:
+ self.line_ids = [(5, 0, 0)]
+
+ # Set origin dari operations
+ if self.operations.origin:
+ self.origin = self.operations.origin
+
+ # Auto-populate lines dari move_ids operations
+ lines_data = []
+ sequence = 10
+
+ # Untuk Odoo 14, gunakan move_ids_without_package atau move_lines
+ moves_to_check = []
+
+ # 1. move_ids_without_package (standard di Odoo 14)
+ if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package:
+ moves_to_check = self.operations.move_ids_without_package
+ # 2. move_lines (backup untuk versi lama)
+ elif hasattr(self.operations, 'move_lines') and self.operations.move_lines:
+ moves_to_check = self.operations.move_lines
+
+ for move in moves_to_check:
+ _logger.info(
+ f"Move: {move.name}, Product: {move.product_id.name if move.product_id else 'No Product'}, Qty: {move.product_uom_qty}, State: {move.state}")
+
+ # Ambil semua move yang ada quantity
+ if move.product_id and move.product_uom_qty > 0:
+ lines_data.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': move.product_id.id,
+ 'product_uom_qty': move.product_uom_qty,
+ 'product_uom': move.product_uom.id,
+ 'name': move.name or move.product_id.display_name,
+ }))
+ sequence += 10
+
+ if lines_data:
+ self.line_ids = lines_data
+ _logger.info(f"Created {len(lines_data)} lines")
+ else:
+ _logger.info("No lines created - no valid moves found")
+ else:
+ # Clear lines jika operations dikosongkan, kecuali dari return picking
+ from_return_picking = self.env.context.get('from_return_picking', False) or \
+ self.env.context.get('default_line_ids', False)
+
+ if not from_return_picking:
+ self.line_ids = [(5, 0, 0)]
+
+ self.origin = False
+
+ def _check_not_allow_tukar_guling_on_bu_input(self, return_type=None):
+ operasi = self.operations.picking_type_id.id
+ tipe = return_type or self.return_type
+
+ if operasi == 28 and self.operations.linked_manual_bu_out.state == 'done':
+ raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah done")
+ if operasi == 28 and tipe == 'tukar_guling':
+ raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling")
+
+ def action_populate_lines(self):
+ """Manual button untuk populate lines - sebagai alternatif"""
+ self.ensure_one()
+ if not self.operations:
+ raise UserError("Pilih BU/OUT atau BU/PICK terlebih dahulu!")
+
+ # Clear existing lines
+ self.line_ids = [(5, 0, 0)]
+
+ lines_data = []
+ sequence = 10
+
+ # Ambil semua stock moves dari operations
+ for move in self.operations.move_ids:
+ if move.product_uom_qty > 0:
+ lines_data.append((0, 0, {
+ 'sequence': sequence,
+ 'product_id': move.product_id.id,
+ 'product_uom_qty': move.product_uom_qty,
+ 'product_uom': move.product_uom.id,
+ 'name': move.name or move.product_id.display_name,
+ }))
+ sequence += 10
+
+ if lines_data:
+ self.line_ids = lines_data
+ else:
+ raise UserError("Tidak ditemukan barang di BU/OUT yang dipilih!")
+
+ @api.constrains('return_type', 'operations')
+ def _check_required_bu_fields(self):
+ for record in self:
+ if record.return_type in ['revisi_po', 'tukar_guling'] and not record.operations:
+ raise ValidationError("Operations harus diisi")
+
+ @api.constrains('line_ids', 'state')
+ def _check_product_lines(self):
+ """Constraint: Product lines harus ada jika state bukan draft"""
+ for record in self:
+ if record.state in ('approval_purchase', 'approval_finance', 'approval_logistic',
+ 'done') and not record.line_ids:
+ raise ValidationError("Product lines harus diisi sebelum submit atau approve!")
+
+ def _validate_product_lines(self):
+ """Helper method untuk validasi product lines"""
+ self.ensure_one()
+
+ # Check ada product lines
+ if not self.line_ids:
+ raise UserError("Belum ada product lines yang ditambahkan!")
+
+ # Check product sudah diisi
+ empty_lines = self.line_ids.filtered(lambda line: not line.product_id)
+ if empty_lines:
+ raise UserError("Ada product lines yang belum diisi productnya!")
+
+ # Check quantity > 0
+ zero_qty_lines = self.line_ids.filtered(lambda line: line.product_uom_qty <= 0)
+ if zero_qty_lines:
+ raise UserError("Quantity product tidak boleh kosong atau 0!")
+
+ return True
+
+ def _is_already_returned(self, picking):
+ return self.env['stock.picking'].search_count([
+ ('origin', '=', 'Return of %s' % picking.name),
+ # ('returned_from_id', '=', picking.id),
+ ('state', 'not in', ['cancel', 'draft']),
+ ]) > 0
+
+ def copy(self, default=None):
+ if default is None:
+ default = {}
+
+ # Generate new sequence untuk duplicate
+ sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling.po')], limit=1)
+ if sequence:
+ default['name'] = sequence.next_by_id()
+ else:
+ default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po') or 'copy'
+
+ default.update({
+ 'state': 'draft',
+ 'date': fields.Datetime.now(),
+ })
+
+ new_record = super(TukarGulingPO, self).copy(default)
+
+ # Re-sequence lines
+ if new_record.line_ids:
+ for i, line in enumerate(new_record.line_ids):
+ line.sequence = (i + 1) * 10
+
+ return new_record
+
+ def write(self, vals):
+ if self.operations.picking_type_id.id not in [75, 28]:
+ raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!")
+ self._check_bill_on_revisi_po()
+ tipe = vals.get('return_type', self.return_type)
+
+ if self.operations and self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling':
+ group = self.operations.group_id
+ if group:
+ # Cari BU/PUT dalam group yang sama
+ bu_put = self.env['stock.picking'].search([
+ ('group_id', '=', group.id),
+ ('picking_type_id.id', '=', 75), # 75 = ID BU/PUT
+ ('state', '=', 'done')
+ ], limit=1)
+
+ if bu_put:
+ raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!")
+
+ if self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling':
+ raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling")
+
+ # if self.operations.picking_type_id.id != 28:
+ # if self._is_already_returned(self.operations):
+ # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.")
+ if 'operations' in vals and not vals.get('origin'):
+ picking = self.env['stock.picking'].browse(vals['operations'])
+ if picking.origin:
+ vals['origin'] = picking.origin
+
+ return super(TukarGulingPO, self).write(vals)
+
+ def unlink(self):
+ for record in self:
+ if record.state == 'done':
+ raise UserError("Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu")
+ ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done')
+ for picking in ongoing_bu:
+ picking.action_cancel()
+ return super(TukarGulingPO, self).unlink()
+
+ def action_view_picking(self):
+ self.ensure_one()
+ action = self.env.ref('stock.action_picking_tree_all').read()[0]
+ pickings = self.po_picking_ids
+ if len(pickings) > 1:
+ action['domain'] = [('id', 'in', pickings.ids)]
+ elif pickings:
+ action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')]
+ action['res_id'] = pickings.id
+ return action
+
+ def action_draft(self):
+ """Reset to draft state"""
+ for record in self:
+ if record.state == 'cancel':
+ record.write({'state': 'draft'})
+ else:
+ raise UserError("Hanya record yang di-cancel yang bisa dikembalikan ke draft")
+
+ def action_submit(self):
+ self.ensure_one()
+ self._check_bill_on_revisi_po()
+ self._validate_product_lines()
+ self._check_not_allow_tukar_guling_on_bu_input()
+
+ if self.operations.picking_type_id.id == 28:
+ group = self.operations.group_id
+ if group:
+ # Cari BU/PUT dalam group yang sama
+ bu_put = self.env['stock.picking'].search([
+ ('group_id', '=', group.id),
+ ('picking_type_id.id', '=', 75),
+ ('state', '=', 'done')
+ ], limit=1)
+
+ if bu_put:
+ raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!")
+
+ picking = self.operations
+ pick_id = self.operations.picking_type_id.id
+ if pick_id == 75:
+ if picking.state != 'done':
+ raise UserError("BU/PUT belum Done!")
+
+ if pick_id not in [75, 28]:
+ raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!")
+
+ if self._is_already_returned(self.operations):
+ raise UserError("BU ini sudah pernah diretur oleh dokumen lain.")
+
+ if self.state != 'draft':
+ raise UserError("Submit hanya bisa dilakukan dari Draft.")
+ self.state = 'approval_purchase'
+
+ def action_approve(self):
+ self.ensure_one()
+ self._validate_product_lines()
+ self._check_bill_on_revisi_po()
+ self._check_not_allow_tukar_guling_on_bu_input()
+
+ if not self.operations:
+ raise UserError("Operations harus diisi!")
+
+ if not self.return_type:
+ raise UserError("Return Type harus diisi!")
+
+ now = datetime.now()
+
+ # Cek hak akses berdasarkan state
+ for rec in self:
+ if rec.state == 'approval_purchase':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_sales'):
+ raise UserError("Hanya Sales Manager yang boleh approve tahap ini.")
+ rec.state = 'approval_finance'
+ rec.date_purchase = now
+
+ elif rec.state == 'approval_finance':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_fat'):
+ raise UserError("Hanya Finance Manager yang boleh approve tahap ini.")
+ rec.state = 'approval_logistic'
+ rec.date_finance = now
+
+ elif rec.state == 'approval_logistic':
+ if not rec.env.user.has_group('indoteknik_custom.group_role_logistic'):
+ raise UserError("Hanya Logistic Manager yang boleh approve tahap ini.")
+ rec.state = 'done'
+ rec._create_pickings()
+ rec.date_logistic = now
+ else:
+ raise UserError("Status ini tidak bisa di-approve.")
+
+ def action_cancel(self):
+ self.ensure_one()
+ # if self.state == 'done':
+ # raise UserError("Tidak bisa cancel jika sudah done")
+
+ user = self.env.user
+ if not (
+ user.has_group('indoteknik_custom.group_role_sales') or
+ user.has_group('indoteknik_custom.group_role_fat') or
+ user.has_group('indoteknik_custom.group_role_logistic')
+ ):
+ raise UserWarning('Anda tidak memiliki Permission untuk cancel document')
+
+
+ bu_done = self.po_picking_ids.filtered(lambda p: p.state == 'done')
+ if bu_done:
+ raise UserError("Dokuemn BU sudah Done, tidak bisa di cancel")
+ ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done')
+ for picking in ongoing_bu:
+ picking.action_cancel()
+ self.state = 'cancel'
+
+ def _create_pickings(self):
+ for record in self:
+ if not record.operations:
+ raise UserError("BU Operations belum dipilih.")
+
+ created_returns = self.env['stock.picking']
+
+ group = record.operations.group_id
+ bu_inputs = bu_puts = self.env['stock.picking']
+
+ # Buat qty map awal dari line_ids
+ bu_input_qty_map = {
+ line.product_id.id: line.product_uom_qty
+ for line in record.line_ids
+ if line.product_id and line.product_uom_qty > 0
+ }
+ bu_put_qty_map = bu_input_qty_map.copy()
+
+ if group:
+ po_pickings = self.env['stock.picking'].search([
+ ('group_id', '=', group.id),
+ ('state', '=', 'done')
+ ])
+ bu_inputs = po_pickings.filtered(lambda p: p.picking_type_id.id == 28)
+ bu_puts = po_pickings.filtered(lambda p: p.picking_type_id.id == 75)
+ else:
+ raise UserError("Group ID tidak ditemukan pada BU Operations.")
+
+ def _create_return_from_picking(picking, qty_map):
+ if not picking:
+ return self.env['stock.picking']
+
+ grup = record.operations.group_id
+
+ # Tentukan lokasi
+ PARTNER_LOCATION_ID = 4
+ BU_INPUT_LOCATION_ID = 58
+ BU_STOCK_LOCATION_ID = 57
+
+ picking_type = picking.picking_type_id.id
+ if picking_type == 28:
+ default_location_id = BU_INPUT_LOCATION_ID
+ default_location_dest_id = PARTNER_LOCATION_ID
+ elif picking_type == 75:
+ default_location_id = BU_STOCK_LOCATION_ID
+ default_location_dest_id = BU_INPUT_LOCATION_ID
+ elif picking_type == 77:
+ default_location_id = BU_INPUT_LOCATION_ID
+ default_location_dest_id = BU_STOCK_LOCATION_ID
+ elif picking_type == 76:
+ default_location_id = PARTNER_LOCATION_ID
+ default_location_dest_id = BU_INPUT_LOCATION_ID
+ else:
+ return self.env['stock.picking']
+
+ return_context = dict(self.env.context)
+ return_context.update({
+ 'active_id': picking.id,
+ 'default_location_id': default_location_id,
+ 'default_location_dest_id': default_location_dest_id,
+ 'from_ui': False,
+ })
+
+ return_wizard = self.env['stock.return.picking'].with_context(return_context).create({
+ 'picking_id': picking.id,
+ 'location_id': default_location_dest_id,
+ 'original_location_id': default_location_id
+ })
+
+ return_lines = []
+ moves = getattr(picking, 'move_ids_without_package', False) or picking.move_lines
+
+ for move in moves:
+ product = move.product_id
+ if not product:
+ continue
+
+ pid = product.id
+ available_qty = qty_map.get(pid, 0.0)
+ move_qty = move.product_uom_qty
+ allocate_qty = min(available_qty, move_qty)
+
+ if allocate_qty <= 0:
+ continue
+
+ return_lines.append((0, 0, {
+ 'product_id': pid,
+ 'quantity': allocate_qty,
+ 'move_id': move.id,
+ }))
+ qty_map[pid] -= allocate_qty
+
+ _logger.info(f"📦 Alokasi {allocate_qty} untuk {product.display_name} | Sisa: {qty_map[pid]}")
+
+ if not return_lines:
+ # Tukar Guling lanjut dari PRT/VRT
+ if picking.picking_type_id.id in [76, 77]:
+ for move in moves:
+ if move.product_uom_qty > 0:
+ return_lines.append((0, 0, {
+ 'product_id': move.product_id.id,
+ 'quantity': move.product_uom_qty,
+ 'move_id': move.id,
+ }))
+ _logger.info(
+ f"🔁 TG lanjutan: Alokasi {move.product_uom_qty} untuk {move.product_id.display_name}")
+ else:
+ _logger.warning(
+ f"⏭️ Skipped return picking {picking.name}, tidak ada qty yang bisa dialokasikan.")
+ return self.env['stock.picking']
+
+ return_wizard.product_return_moves = return_lines
+ return_vals = return_wizard.create_returns()
+ return_picking = self.env['stock.picking'].browse(return_vals.get('res_id'))
+
+ return_picking.write({
+ 'location_id': default_location_id,
+ 'location_dest_id': default_location_dest_id,
+ 'group_id': grup.id,
+ 'tukar_guling_po_id': record.id,
+ })
+ record.message_post(
+ body=f"📦 <b>{return_picking.name}</b> "
+ f"<b>{return_picking.picking_type_id.display_name}</b> "
+ f"Created by <b>{self.env.user.name}</b> "
+ f"status <b>{return_picking.state}</b> "
+ f"at <b>{fields.Datetime.now().strftime('%d/%m/%Y %H:%M')}</b>",
+ message_type="comment",
+ subtype_id=self.env.ref("mail.mt_note").id,
+ )
+
+ return return_picking
+
+ # ============================
+ # Eksekusi utama return logic
+ # ============================
+
+ if record.operations.picking_type_id.id == 28:
+ # Dari BU INPUT langsung buat PRT
+ prt = _create_return_from_picking(record.operations, bu_input_qty_map)
+ if prt:
+ created_returns |= prt
+ else:
+ # ✅ Pairing BU PUT ↔ BU INPUT
+ # Temukan index dari BU PUT yang dipilih user
+ try:
+ bu_put_index = sorted(bu_puts, key=lambda p: p.name).index(record.operations)
+ except ValueError:
+ raise UserError("Dokumen BU PUT yang dipilih tidak ditemukan dalam daftar BU PUT.")
+
+ # Ambil pasangannya di BU INPUT (asumsi urutan sejajar)
+ sorted_bu_puts = sorted(bu_puts, key=lambda p: p.name)
+ sorted_bu_inputs = sorted(bu_inputs, key=lambda p: p.name)
+
+ if bu_put_index >= len(sorted_bu_inputs):
+ raise UserError("Tidak ditemukan pasangan BU INPUT untuk BU PUT yang dipilih.")
+
+ paired = [(sorted_bu_puts[bu_put_index], sorted_bu_inputs[bu_put_index])]
+
+ for bu_put, bu_input in paired:
+ vrt = _create_return_from_picking(bu_put, bu_put_qty_map)
+ if vrt:
+ created_returns |= vrt
+
+ prt = _create_return_from_picking(bu_input, bu_input_qty_map)
+ if prt:
+ created_returns |= prt
+
+ # 🌀 Tukar Guling: buat dokumen baru dari PRT & VRT
+ if record.return_type == 'tukar_guling':
+ for prt in created_returns.filtered(lambda p: p.picking_type_id.id == 76):
+ bu_input = _create_return_from_picking(prt, bu_input_qty_map)
+ if bu_input:
+ created_returns |= bu_input
+
+ for vrt in created_returns.filtered(lambda p: p.picking_type_id.id == 77):
+ bu_put = _create_return_from_picking(vrt, bu_put_qty_map)
+ if bu_put:
+ created_returns |= bu_put
+
+ if not created_returns:
+ raise UserError("Tidak ada dokumen retur yang berhasil dibuat.")
+
+
+class TukarGulingLinePO(models.Model):
+ _name = 'tukar.guling.line.po'
+ _description = 'Tukar Guling PO Line'
+
+ sequence = fields.Integer('Sequence', default=10, copy=False)
+ product_id = fields.Many2one('product.product', string='Product', required=True)
+ tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade')
+ product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0)
+ product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
+ name = fields.Text('Description')
+
+ @api.constrains('product_uom_qty')
+ def _check_qty_change_allowed(self):
+ for rec in self:
+ if rec.tukar_guling_id and rec.tukar_guling_po_id.state not in ['draft', 'cancel']:
+ raise ValidationError("Tidak bisa mengubah Quantity karena status dokumen bukan Draft atau Cancel.")
+
+ def unlink(self):
+ for rec in self:
+ if rec.tukar_guling_po_id and rec.tukar_guling_po_id.state not in ['draft', 'cancel']:
+ raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.")
+ return super(TukarGulingLinePO, self).unlink()
+
+
+class StockPicking(models.Model):
+ _inherit = 'stock.picking'
+ tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO Ref')
+
+
+ def button_validate(self):
+ res = super(StockPicking, self).button_validate()
+ for picking in self:
+ if picking.tukar_guling_po_id:
+ message = _(
+ "📦 <b>%s</b> Validated by <b>%s</b> Status Changed <b>%s</b> at <b>%s</b>."
+ ) % (
+ picking.name,
+ # picking.picking_type_id.name,
+ picking.env.user.name,
+ picking.state,
+ fields.Datetime.now().strftime("%d/%m/%Y %H:%M")
+ )
+ picking.tukar_guling_po_id.message_post(body=message)
+
+ return res \ No newline at end of file
diff --git a/indoteknik_custom/models/uangmuka_pembelian.py b/indoteknik_custom/models/uangmuka_pembelian.py
index ba41f814..13d51dcf 100644
--- a/indoteknik_custom/models/uangmuka_pembelian.py
+++ b/indoteknik_custom/models/uangmuka_pembelian.py
@@ -57,6 +57,8 @@ class UangmukaPembelian(models.TransientModel):
account_move = request.env['account.move'].create([param_header])
_logger.info('Success Create Uang Muka Pembelian %s' % account_move.name)
+ account_move.purchase_order_id = order.id # isi field purchase_order_id
+
if order.partner_id.parent_id:
partner_id = order.partner_id.parent_id.id
else:
diff --git a/indoteknik_custom/models/user_pengajuan_tempo_request.py b/indoteknik_custom/models/user_pengajuan_tempo_request.py
index 87227764..600381c0 100644
--- a/indoteknik_custom/models/user_pengajuan_tempo_request.py
+++ b/indoteknik_custom/models/user_pengajuan_tempo_request.py
@@ -371,7 +371,7 @@ class UserPengajuanTempoRequest(models.Model):
@api.onchange('tempo_limit')
def _onchange_tempo_limit(self):
for tempo in self:
- if tempo.env.user.id not in (7, 688, 28, 377, 12182, 375):
+ if tempo.env.user.id not in (7, 688, 28, 19, 375):
raise UserError("Limit tempo hanya bisa diubah oleh Sales Manager atau Direktur")
def button_approve(self):
for tempo in self:
@@ -381,7 +381,7 @@ class UserPengajuanTempoRequest(models.Model):
if tempo.env.user.id in (688, 28, 7):
raise UserError("Pengajuan tempo harus di approve oleh sales manager terlebih dahulu")
else:
- if tempo.env.user.id not in (377, 12182, 375):
+ if tempo.env.user.id not in (375, 19):
# if tempo.env.user.id != 12182:
raise UserError("Pengajuan tempo hanya bisa di approve oleh sales manager")
else:
@@ -400,7 +400,7 @@ class UserPengajuanTempoRequest(models.Model):
if tempo.env.user.id == 7:
raise UserError("Pengajuan tempo harus di approve oleh Finence terlebih dahulu")
else:
- if tempo.env.user.id not in (688, 28, 12182):
+ if tempo.env.user.id not in (688, 28):
# if tempo.env.user.id not in (288,28,12182):
raise UserError("Pengajuan tempo hanya bisa di approve oleh Finence")
else:
diff --git a/indoteknik_custom/patch.py b/indoteknik_custom/patch.py
new file mode 100644
index 00000000..704ab056
--- /dev/null
+++ b/indoteknik_custom/patch.py
@@ -0,0 +1,16 @@
+import json, logging
+from odoo.http import JsonRequest
+
+_logger = logging.getLogger(__name__)
+
+def _safe_jsonloads(self, raw):
+ """Kembalikan dict kosong bila body kosong / JSON rusak"""
+ try:
+ return json.loads(raw) if raw else {}
+ except Exception as e:
+ _logger.warning("Bypassed invalid JSON body: %s", e)
+ return {}
+
+# Odoo 14 memakai _jsonloads
+JsonRequest._jsonloads = _safe_jsonloads
+_logger.info("Patch OK → JsonRequest._jsonloads dilindungi (empty JSON diterima)")
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index 601f04c5..0ac3e86c 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -178,6 +178,17 @@ access_stock_inventory,access.stock.inventory,model_stock_inventory,,1,1,1,1
access_cancel_reason_order,cancel.reason.order,model_cancel_reason_order,,1,1,1,0
access_reject_reason_commision,reject.reason.commision,model_reject_reason_commision,,1,1,1,0
access_shipping_option,shipping.option,model_shipping_option,,1,1,1,1
+access_sale_order_delay,sale.order.delay,model_sale_order_delay,,1,1,1,1
access_production_purchase_match,access.production.purchase.match,model_production_purchase_match,,1,1,1,1
access_image_carousel,access.image.carousel,model_image_carousel,,1,1,1,1
access_v_sale_notin_matchpo,access.v.sale.notin.matchpo,model_v_sale_notin_matchpo,,1,1,1,1
+access_approval_payment_term,access.approval.payment.term,model_approval_payment_term,,1,1,1,1
+access_refund_sale_order,access.refund.sale.order,model_refund_sale_order,base.group_user,1,1,1,1
+access_refund_sale_order_line,access.refund.sale.order.line,model_refund_sale_order_line,base.group_user,1,1,1,1
+access_purchasing_job_seen,purchasing.job.seen,model_purchasing_job_seen,,1,1,1,1
+
+access_tukar_guling_all_users,tukar.guling.all.users,model_tukar_guling,base.group_user,1,1,1,1
+access_tukar_guling_line_all_users,tukar.guling.line.all.users,model_tukar_guling_line,base.group_user,1,1,1,1
+access_tukar_guling_po_all_users,tukar.guling.po.all.users,model_tukar_guling_po,base.group_user,1,1,1,1
+access_tukar_guling_line_po_all_users,tukar.guling.line.po.all.users,model_tukar_guling_line_po,base.group_user,1,1,1,1
+access_tukar_guling_mapping_koli_all_users,tukar.guling.mapping.koli.all.users,model_tukar_guling_mapping_koli,base.group_user,1,1,1,1 \ No newline at end of file
diff --git a/indoteknik_custom/views/account_move.xml b/indoteknik_custom/views/account_move.xml
index 46737a40..9b1c791b 100644
--- a/indoteknik_custom/views/account_move.xml
+++ b/indoteknik_custom/views/account_move.xml
@@ -6,6 +6,12 @@
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
+ <xpath expr="//div[@name='journal_div']" position="after">
+ <field name="reklas_misc_id"
+ string="Journal Entries Reklas"
+ attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"
+ readonly="1"/>
+ </xpath>
<button name="action_register_payment" position="after">
<button name="indoteknik_custom.action_view_invoice_reklas" string="Reklas"
type="action" class="btn-primary" attrs="{'invisible': [('state', '!=', 'posted')]}"/>
@@ -27,10 +33,16 @@
</field>
<field name="invoice_date" position="after">
<field name="sale_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'out_invoice')]}"/>
- <field name="purchase_order_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/>
+ <!-- <field name="purchase_order_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/> -->
</field>
<field name="ref" position="after">
<field name="sale_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'entry')]}"/>
+ <!-- <field name="refund_so_links" readonly="1" widget="html" attrs="{'invisible': ['|', ('move_type', '!=', 'entry'), ('has_refund_so', '=', False)]}"/>
+ <field name="has_refund_so" invisible="1"/> -->
+ </field>
+ <field name="reklas_misc_id" position="after">
+ <field name="purchase_order_id" context="{'form_view_ref': 'purchase.purchase_order_form'}" options="{'no_create': True}"/>
+ <field name="bill_id" readonly="1"/>
</field>
<field name="partner_shipping_id" position="before">
<field name="real_invoice_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'out_invoice')]}"/>
@@ -52,6 +64,7 @@
<field name="due_extension"/>
<field name="counter"/>
<field name="nomor_kwitansi"/>
+ <field name="down_payment"/>
</field>
<field name="to_check" position="after">
<field name="already_paid"/>
@@ -59,6 +72,7 @@
<field name="so_shipping_covered_by"/>
<field name="so_delivery_amt"/>
<field name="flag_delivery_amt"/>
+ <field name="length_of_payment"/>
</field>
<field name="amount_untaxed" position="after">
<field name="other_subtotal" invisible="1"/>
diff --git a/indoteknik_custom/views/approval_payment_term.xml b/indoteknik_custom/views/approval_payment_term.xml
new file mode 100644
index 00000000..cc9db914
--- /dev/null
+++ b/indoteknik_custom/views/approval_payment_term.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+ <record id="approval_payment_term_tree" model="ir.ui.view">
+ <field name="name">approval.payment.term.tree</field>
+ <field name="model">approval.payment.term</field>
+ <field name="arch" type="xml">
+ <tree default_order="create_date desc">
+ <field name="number"/>
+ <field name="partner_id"/>
+ <field name="parent_id"/>
+ <field name="property_payment_term_id"/>
+ <field name="create_date" optional="hide"/>
+ <field name="approve_date" optional="hide"/>
+ <field name="approve_sales_manager" optional="hide"/>
+ <field name="approve_finance" optional="hide"/>
+ <field name="approve_leader" optional="hide"/>
+ <field name="create_uid" optional="hide"/>
+ <field name="sale_order_ids" optional="hide" widget="many2many_tags"/>
+ <field name="total" optional="hide"/>
+ <field name="grand_total" optional="hide"/>
+ <field name="state" widget="badge" decoration-danger="state == 'rejected'"
+ decoration-success="state == 'approved'"
+ decoration-info="state in ['waiting_approval_sales_manager', 'waiting_approval_finance', 'waiting_approval_leader']"/>
+ <field name="change_log_688" optional="hide"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="approval_payment_term_form" model="ir.ui.view">
+ <field name="name">approval.payment.term.form</field>
+ <field name="model">approval.payment.term</field>
+ <field name="arch" type="xml">
+ <form>
+ <header>
+ <button name="button_approve"
+ string="Approve"
+ type="object"
+ attrs="{'invisible': [('approve_leader', '=', True)]}"
+ />
+ <button name="button_reject"
+ string="Reject"
+ type="object"
+ attrs="{'invisible': [('approve_leader', '=', True)]}"
+ />
+ <field name="state" widget="statusbar"
+ statusbar_visible="waiting_approval,approved,rejected"
+ statusbar_colors='{"rejected":"red"}'/>
+ </header>
+ <sheet string="Approval Payment Term">
+ <group>
+ <group>
+ <field name="number" readonly="1"/>
+ <field name="partner_id"/>
+ <field name="parent_id" readonly="1"/>
+ <field name="blocking_stage" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="warning_stage" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="property_payment_term_id" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="active_limit" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ </group>
+ <group>
+ <field name="reason"/>
+ <field name="reason_reject" attrs="{'invisible': [('state', '!=', 'rejected')]}"/>
+ <field name="approve_date" readonly="1"/>
+ <field name="approve_sales_manager" readonly="1"/>
+ <field name="approve_finance" readonly="1"/>
+ <field name="approve_leader" readonly="1"/>
+ <field name="sale_order_ids" widget="many2many_tags"/>
+ <field name="total"/>
+ <field name="grand_total"/>
+ </group>
+ </group>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+
+ <record id="approval_payment_term_action" model="ir.actions.act_window">
+ <field name="name">Approval Payment Term</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">approval.payment.term</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <menuitem id="menu_approval_payment_term" name="Approval Payment Term"
+ parent="account.menu_finance_receivables"
+ action="approval_payment_term_action"
+ sequence="100"
+ />
+
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/customer_commision.xml b/indoteknik_custom/views/customer_commision.xml
index 37df16ff..514e6284 100644
--- a/indoteknik_custom/views/customer_commision.xml
+++ b/indoteknik_custom/views/customer_commision.xml
@@ -70,7 +70,7 @@
statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,pengajuan4,approved"
statusbar_colors='{"reject":"red"}'/>
</header>
- <sheet string="Customer Commision">
+ <sheet string="Customer Benefits">
<div class="oe_button_box" name="button_box"/>
<group>
<group>
@@ -81,6 +81,9 @@
<field name="commision_percent"/>
<field name="commision_amt"/>
<field name="commision_amt_text"/>
+ <field name="cashback" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/>
+ <field name="total_commision" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/>
+ <field name="total_cashback_text" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/>
<field name="grouped_so_number" readonly="1"/>
<field name="grouped_invoice_number" readonly="1"/>
<field name="approved_by" readonly="1"/>
@@ -100,6 +103,7 @@
<field name="notification" readonly="1"/>
<!-- <field name="status" readonly="1"/>-->
<field name="payment_status" readonly="1"/>
+ <field name="biaya_lain_lain"/>
<field name="total_dpp"/>
</group>
</group>
@@ -171,7 +175,7 @@
</record>
<record id="customer_commision_action" model="ir.actions.act_window">
- <field name="name">Customer Commision</field>
+ <field name="name">Customer Benefits</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">customer.commision</field>
<field name="search_view_id" ref="view_customer_commision_filter"/>
@@ -179,14 +183,14 @@
</record>
<menuitem id="menu_customer_commision_acct"
- name="Customer Commision"
+ name="Customer Benefits"
action="customer_commision_action"
parent="account.menu_finance_entries"
sequence="113"
/>
<menuitem id="menu_customer_commision_sales"
- name="Customer Commision"
+ name="Customer Benefits"
action="customer_commision_action"
parent="sale.product_menu_catalog"
sequence="101"
diff --git a/indoteknik_custom/views/ir_sequence.xml b/indoteknik_custom/views/ir_sequence.xml
index 97bf40bb..4915e4c5 100644
--- a/indoteknik_custom/views/ir_sequence.xml
+++ b/indoteknik_custom/views/ir_sequence.xml
@@ -131,7 +131,7 @@
<field name="number_increment">1</field>
</record>
- <record id="sequence_commision_customer" model="ir.sequence">
+ <!-- <record id="sequence_commision_customer" model="ir.sequence">
<field name="name">Customer Commision</field>
<field name="code">customer.commision</field>
<field name="active">TRUE</field>
@@ -139,6 +139,46 @@
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
+ </record> -->
+
+ <record id="sequence_commision_cashback" model="ir.sequence">
+ <field name="name">Customer Commision Cashback</field>
+ <field name="code">customer.commision.cashback</field>
+ <field name="prefix">CB/%(year)s/</field>
+ <field name="padding">5</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
+ </record>
+
+ <record id="sequence_approval_payment_term" model="ir.sequence">
+ <field name="name">Approval Payment Term</field>
+ <field name="code">approval.payment.term</field>
+ <field name="prefix">APT/%(year)s/</field>
+ <field name="padding">5</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
+ </record>
+
+ <record id="sequence_commision_fee" model="ir.sequence">
+ <field name="name">Customer Commision Fee</field>
+ <field name="code">customer.commision.fee</field>
+ <field name="prefix">CC/%(year)s/</field>
+ <field name="padding">5</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
+ </record>
+
+ <record id="sequence_commision_rebate" model="ir.sequence">
+ <field name="name">Customer Commision Rebate</field>
+ <field name="code">customer.commision.rebate</field>
+ <field name="prefix">RB/%(year)s/</field>
+ <field name="padding">5</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
</record>
<record id="sequence_automatic_purchase" model="ir.sequence">
@@ -160,5 +200,33 @@
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
+ <record id="seq_tukar_guling" model="ir.sequence">
+ <field name="name">Pengajuan Return SO</field>
+ <field name="code">tukar.guling</field>
+ <field name="active">TRUE</field>
+ <field name="prefix">CCM/%(year)s/%(month)s/</field>
+ <field name="padding">4</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ </record>
+ <record id="seq_tukar_guling_po" model="ir.sequence">
+ <field name="name">Pengajuan Return PO</field>
+ <field name="code">tukar.guling.po</field>
+ <field name="active">TRUE</field>
+ <field name="prefix">VCM/%(year)s/%(month)s/</field>
+ <field name="padding">4</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ </record>
+
+ <record id="seq_refund_sale_order" model="ir.sequence">
+ <field name="name">Refund Sale Order</field>
+ <field name="code">refund.sale.order</field>
+ <field name="prefix">RC/%(year)s/%(month)s/</field>
+ <field name="padding">4</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
+ </record>
</data>
</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/mail_template_invoice_reminder.xml b/indoteknik_custom/views/mail_template_invoice_reminder.xml
new file mode 100644
index 00000000..21055eb0
--- /dev/null
+++ b/indoteknik_custom/views/mail_template_invoice_reminder.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="0">
+ <record id="mail_template_invoice_due_reminder" model="mail.template">
+ <field name="name">Invoice Reminder: Due Date Notification</field>
+ <field name="model_id" ref="account.model_account_move"/>
+ <field name="subject">Reminder Invoice Due - ${object.name}</field>
+ <field name="email_from">finance@indoteknik.co.id</field>
+ <field name="email_to">andrifebriyadiputra@gmail.com</field>
+ <field name="body_html" type="html">
+ <div>
+ <p><b>Dear ${object.name},</b></p>
+
+ <p>Berikut adalah daftar invoice Anda yang mendekati atau telah jatuh tempo:</p>
+
+ <table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%; font-size: 12px">
+ <thead>
+ <tr style="background-color: #f2f2f2;" align="left">
+ <th>Invoice Number</th>
+ <th>Tanggal Invoice</th>
+ <th>Jatuh Tempo</th>
+ <th>Sisa Hari</th>
+ <th>Total</th>
+ <th>Referensi</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+
+ <p>Mohon bantuan dan kerjasamanya agar tetap bisa bekerjasama dengan baik</p>
+ <p>Terima Kasih.</p>
+ <br/>
+ <br/>
+ <p><b>Best Regards,
+ <br/>
+ <br/>
+ Widya R.<br/>
+ Dept. Finance<br/>
+ PT. INDOTEKNIK DOTCOM GEMILANG<br/>
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2135765" alt="Indoteknik" style="max-width: 18%; height: auto;"></img><br/>
+ <a href="https://wa.me/6285716970374" target="_blank">+62-857-1697-0374</a> |
+ <a href="mailto:finance@indoteknik.co.id">finance@indoteknik.co.id</a>
+ </b></p>
+
+ </div>
+ </field>
+ <field name="auto_delete" eval="True"/>
+ </record>
+ </data>
+</odoo>
diff --git a/indoteknik_custom/views/mrp_production.xml b/indoteknik_custom/views/mrp_production.xml
index 3de52a08..5057415f 100644
--- a/indoteknik_custom/views/mrp_production.xml
+++ b/indoteknik_custom/views/mrp_production.xml
@@ -11,7 +11,7 @@
<field name="bom_id" position="after">
<field name="desc"/>
<field name="sale_order"/>
- <field name="is_po"/>
+ <field name="is_po" readonly="1"/>
</field>
<xpath expr="//form/sheet/notebook/page/field[@name='move_raw_ids']/tree/field[@name='product_uom_qty']" position="before">
<field name="vendor_id"/>
diff --git a/indoteknik_custom/views/purchase_order.xml b/indoteknik_custom/views/purchase_order.xml
index 0fbbb5e7..ff223125 100755
--- a/indoteknik_custom/views/purchase_order.xml
+++ b/indoteknik_custom/views/purchase_order.xml
@@ -14,6 +14,29 @@
attrs="{'invisible': ['|', ('sale_order_id', '=', False), ('state', 'not in', ['draft'])]}"
/>
</div>
+ <xpath expr="//button[@name='action_view_invoice']" position="before">
+ <field name="is_cab_visible" invisible="1"/>
+ <button type="object"
+ name="action_view_journal_uangmuka"
+ class="oe_stat_button"
+ icon="fa-book"
+ attrs="{'invisible': [('is_cab_visible', '=', False)]}"
+ style="width: 200px;">
+ <field name="move_id" widget="statinfo" string="Journal Uang Muka"/>
+ <span class="o_stat_text">
+ <t t-esc="record.move_id.name"/>
+ </span>
+ </button>
+ <button type="object"
+ name="action_view_related_bu"
+ class="oe_stat_button"
+ icon="fa-truck"
+ style="width: 200px;"
+ attrs="{'invisible': [('state', 'in', ['draft', 'sent'])]}">
+ <field name="bu_related_count" widget="statinfo" string="BU Related"/>
+ </button>
+ <field name="picking_count" invisible="1"/>
+ </xpath>
<button id="draft_confirm" position="after">
<button name="po_approve"
string="Ask Approval"
@@ -64,7 +87,9 @@
<field name="payment_term_id"/>
<field name="total_cost_service" attrs="{'required': [('partner_id', 'in', [9688, 29712])]}"/>
<field name="total_delivery_amt" attrs="{'required': [('partner_id', 'in', [9688, 29712])]}"/>
- <field name="product_bom_id"/>
+ <field name="product_bom_id" attrs="{'invisible': [('product_bom_id', '=', None)]}"/>
+ <field name="manufacturing_id" attrs="{'invisible': [('product_bom_id', '=', None)]}"/>
+ <!-- <field name="move_id" domain="[('move_type','=','entry')]" context="{'form_view_ref': 'account.view_move_form'}" options="{'no_create': True}"/> -->
</field>
<field name="amount_total" position="after">
<field name="total_margin"/>
@@ -164,6 +189,13 @@
<field name="order_sales_match_line"/>
</page>
</xpath>
+ <xpath expr="//form/sheet/notebook/page[@name='purchase_delivery_invoice']" position="after">
+ <page string="Other Info" name="purchase_order_sales_matches_lines">
+ <group string="Return Doc">
+ <field name="vcm_id"/>
+ </group>
+ </page>
+ </xpath>
</field>
</record>
</data>
@@ -308,6 +340,7 @@
<field name="delivery_amt" optional="hide"/>
<field name="margin_deduct" optional="hide"/>
<field name="hold_outgoing_so" optional="hide"/>
+ <field name="bu_pick" optional="hide"/>
<field name="margin_so"/>
</tree>
</field>
diff --git a/indoteknik_custom/views/purchasing_job.xml b/indoteknik_custom/views/purchasing_job.xml
index bb1c7643..e3866d84 100644
--- a/indoteknik_custom/views/purchasing_job.xml
+++ b/indoteknik_custom/views/purchasing_job.xml
@@ -4,7 +4,7 @@
<field name="name">v.purchasing.job.tree</field>
<field name="model">v.purchasing.job</field>
<field name="arch" type="xml">
- <tree create="false" multi_edit="1">
+ <tree decoration-info="(check_pj == False)" create="false" multi_edit="1">
<field name="product_id"/>
<field name="vendor_id"/>
<field name="purchase_representative_id"/>
@@ -18,6 +18,15 @@
<field name="action"/>
<field name="note"/>
<field name="date_po"/>
+ <field name="so_number"/>
+ <field name="check_pj" invisible="1"/>
+ <button name="action_open_job_detail"
+ string="📄"
+ type="object"
+ icon="fa-file"
+ attrs="{'invisible': [('check_pj','=',True)]}"
+ context="{}"/>
+
</tree>
</field>
</record>
@@ -41,6 +50,7 @@
<field name="item_code"/>
<field name="product"/>
<field name="action"/>
+ <field name="so_number"/>
</group>
<group>
<field name="onhand"/>
diff --git a/indoteknik_custom/views/refund_sale_order.xml b/indoteknik_custom/views/refund_sale_order.xml
new file mode 100644
index 00000000..4f791722
--- /dev/null
+++ b/indoteknik_custom/views/refund_sale_order.xml
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+ <!-- Tree View -->
+ <record id="view_refund_sale_order_tree" model="ir.ui.view">
+ <field name="name">refund.sale.order.tree</field>
+ <field name="model">refund.sale.order</field>
+ <field name="arch" type="xml">
+ <tree string="Refund Sales Orders">
+ <field name="name" readonly="1"/>
+ <field name="created_date" readonly="1"/>
+ <field name="partner_id" readonly="1"/>
+ <field name="sale_order_ids" widget="many2many_tags" readonly="1"/>
+ <field name="uang_masuk" readonly="1"/>
+ <field name="ongkir" readonly="1"/>
+ <field name="total_invoice" readonly="1"/>
+ <field name="amount_refund" readonly="1"/>
+ <field name="status"
+ decoration-info="status == 'draft'"
+ decoration-danger="status == 'reject'"
+ decoration-success="status == 'refund'"
+ decoration-warning="status == 'pengajuan1' or status == 'pengajuan2' or status == 'pengajuan3'"
+ widget="badge"
+ readonly="1"/>
+ <field name="status_payment"
+ decoration-info="status_payment == 'pending'"
+ decoration-danger="status_payment == 'reject'"
+ decoration-success="status_payment == 'done'"
+ widget="badge"
+ readonly="1"/>
+ <field name="refund_date" readonly="1"/>
+ <field name="amount_refund_text" readonly="1" optional="hide"/>
+ <field name="invoice_ids" readonly="1" optional="hide"/>
+ <field name="refund_type" readonly="1" optional="hide"/>
+ <field name="user_ids" readonly="1" optional="hide"/>
+ </tree>
+ </field>
+ </record>
+
+ <!-- Form View -->
+ <record id="view_refund_sale_order_form" model="ir.ui.view">
+ <field name="name">refund.sale.order.form</field>
+ <field name="model">refund.sale.order</field>
+ <field name="arch" type="xml">
+ <form string="Refund Sales Order">
+ <header>
+ <button name="action_ask_approval"
+ type="object"
+ string="Ask Approval"
+ attrs="{'invisible': [('status', '!=', 'draft')]}"/>
+
+ <button name="action_approve_flow"
+ type="object"
+ string="Approve"
+ class="oe_highlight"
+ attrs="{'invisible': [('status', 'in', ['refund', 'reject', 'draft'])]}"/>
+ <button name="action_trigger_cancel"
+ type="object"
+ string="Cancel"
+ attrs="{'invisible': ['|', ('status_payment', '!=', 'pending'), ('status', '=', 'reject')]}" />
+ <button name="action_confirm_refund"
+ type="object"
+ string="Confirm Refund"
+ class="btn-primary"
+ attrs="{'invisible': ['|', ('status', 'not in', ['pengajuan3','refund']), ('status_payment', '!=', 'pending')]}"/>
+ <button name="action_create_journal_refund"
+ string="Journal Refund"
+ type="object"
+ class="oe_highlight"
+ attrs="{'invisible': ['|', ('status', 'not in', ['pengajuan3','refund']), ('journal_refund_state', '=', 'posted')]}"/>
+
+ <field name="status"
+ widget="statusbar"
+ statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,reject"
+ attrs="{'invisible': [('status', '!=', 'reject')]}" />
+
+ <field name="status"
+ widget="statusbar"
+ statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,refund"
+ attrs="{'invisible': [('status', '=', 'reject')]}" />
+ </header>
+ <sheet>
+ <div class="oe_button_box" name="button_box">
+ <button name="action_open_journal_refund"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-book"
+ width="250px"
+ attrs="{'invisible': ['|', ('journal_refund_move_id', '=', False), ('journal_refund_state', '!=', 'posted')]}">
+ <field name="journal_refund_move_id" string="Journal Refund" widget="statinfo"/>
+ </button>
+ </div>
+ <widget name="web_ribbon"
+ title="PAID"
+ bg_color="bg-success"
+ attrs="{'invisible': [('status_payment', '!=', 'done')]}"/>
+
+ <widget name="web_ribbon"
+ title="CANCEL"
+ bg_color="bg-danger"
+ attrs="{'invisible': [('status_payment', '!=', 'reject')]}"/>
+ <h1>
+ <field name="name" readonly="1"/>
+ </h1>
+ <group col="2">
+ <group>
+ <field name="is_locked" invisible="1"/>
+ <field name="status_payment" invisible="1"/>
+ <field name="journal_refund_state" invisible="1"/>
+
+ <field name="partner_id" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="sale_order_ids" widget="many2many_tags" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="invoice_ids" widget="many2many_tags" readonly="1"/>
+ <field name="invoice_names" widget="html" readonly="1"/>
+ <field name="so_names" widget="html" readonly="1"/>
+ <field name="advance_move_names" widget="html" readonly="1"/>
+ <field name="refund_type" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="note_refund" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ </group>
+ <group>
+ <field name="uang_masuk" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="total_invoice" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="ongkir" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="amount_refund" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="amount_refund_text" readonly="1"/>
+ <field name="uang_masuk_type" required="1" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="bukti_uang_masuk_image" widget="image"
+ attrs="{'invisible': [('uang_masuk_type', '=', 'pdf')], 'readonly': [('is_locked', '=', True)]}"/>
+ <field name="bukti_uang_masuk_pdf" widget="pdf_viewer"
+ attrs="{'invisible': [('uang_masuk_type', '=', 'image')], 'readonly': [('is_locked', '=', True)]}"/>
+ </group>
+ </group>
+
+ <notebook>
+ <page string="Produk Line">
+ <field name="line_ids" attrs="{'readonly': [('is_locked', '=', True)]}">
+ <tree editable="bottom">
+ <field name="product_id"/>
+ <field name="quantity"/>
+ <field name="reason"/>
+ </tree>
+ </field>
+ </page>
+
+ <page string="Other Info">
+ <group col="2">
+ <group>
+ <field name="user_ids" widget="many2many_tags" readonly="1"/>
+ <field name="created_date" readonly="1"/>
+ <field name="refund_date" attrs="{'readonly': [('status', 'not in', ['pengajuan3','refund'])]}"/>
+ </group>
+ <group>
+ <field name="bank" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="account_name" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="account_no" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ </group>
+ </group>
+ </page>
+
+ <page string="Finance Note">
+ <group col="2">
+ <group>
+ <field name="finance_note" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ </group>
+ <group>
+ <field name="bukti_refund_type" reqiured="1" attrs="{'readonly': [('is_locked', '=', True)]}"/>
+ <field name="bukti_transfer_refund_pdf" widget="pdf_viewer" attrs="{'invisible': [('bukti_refund_type', '=', 'image')]}"/>
+ <field name="bukti_transfer_refund_image" widget="image" attrs="{'invisible': [('bukti_refund_type', '=', 'pdf')]}"/>
+ </group>
+ </group>
+ </page>
+
+ <page string="Cancel Reason" attrs="{'invisible': [('status', '=', 'refund')]}">
+ <group>
+ <field name="reason_reject"/>
+ </group>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+ <!-- Action -->
+ <record id="action_refund_sale_order" model="ir.actions.act_window">
+ <field name="name">Refund Sales Order</field>
+ <field name="res_model">refund.sale.order</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <!-- Menu -->
+ <menuitem id="menu_refund_sale_order"
+ name="Refund"
+ parent="sale.sale_order_menu"
+ sequence="10"
+ action="action_refund_sale_order"/>
+</odoo>
diff --git a/indoteknik_custom/views/res_partner.xml b/indoteknik_custom/views/res_partner.xml
index cb9fa3ac..a030a75c 100644
--- a/indoteknik_custom/views/res_partner.xml
+++ b/indoteknik_custom/views/res_partner.xml
@@ -65,6 +65,21 @@
<group name="purchase" position="inside">
<field name="leadtime"/>
</group>
+ <xpath expr="//notebook/page[@name='contact_addresses']" position="before">
+ <page string="Pin Point Location" name="map_location">
+ <group>
+ <button name="geocode_address" type="object" string="Get Pin Point Location" class="btn btn-primary"/>
+ </group>
+ <div style="margin: 16px 0;">
+ <field name="map_view" widget="googlemap" nolabel="1"/>
+ </div>
+ <group>
+ <field name="address_map" readonly="1" force_save="1"/>
+ <field name="latitude" readonly="1" force_save="1"/>
+ <field name="longtitude" readonly="1" force_save="1"/>
+ </group>
+ </page>
+ </xpath>
<field name="vat" position="after">
<field name="email_finance" widget="email"/>
<field name="email_sales" widget="email"/>
@@ -78,6 +93,15 @@
<field name="main_parent_id" invisible="1" />
<field name="site_id" attrs="{'readonly': [('parent_id', '=', False)]}" domain="[('partner_id', '=', main_parent_id)]" context="{'default_partner_id': active_id}" />
</xpath>
+ <xpath expr="//field[@name='child_ids']/form/sheet/group" position="after">
+ <div class="oe_left" style="margin-top: 16px;">
+ <button name="action_open_full_form"
+ type="object"
+ string="Detail Information"
+ class="btn btn-primary"
+ />
+ </div>
+ </xpath>
<xpath expr="//field[@name='property_payment_term_id']" position="attributes">
<attribute name="readonly">0</attribute>
</xpath>
@@ -189,6 +213,13 @@
<field name="dokumen_ktp_dirut" />
</group>
</page>
+ <page string="Aging Info">
+ <group string="Aging Info">
+ <field name="avg_aging" readonly="1"/>
+ <field name="payment_difficulty" attrs="{'readonly': [('parent_id', '!=', False)]}" />
+ <field name="payment_history_url" readonly="1" />
+ </group>
+ </page>
</notebook>
</field>
</record>
diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml
index 0fabf279..c1f1fe61 100755
--- a/indoteknik_custom/views/sale_order.xml
+++ b/indoteknik_custom/views/sale_order.xml
@@ -35,7 +35,32 @@
string="UangMuka"
type="action" attrs="{'invisible': [('approval_status', '!=', 'approved')]}"/>
</button>
- <field name="payment_term_id" position="after">
+ <!-- <xpath expr="//header" position="inside">
+ <button name="button_refund"
+ type="object"
+ string="Refund"
+ class="btn-primary"
+ attrs="{'invisible': ['|', ('state', 'not in', ['sale', 'done']), ('has_refund', '=', True)]}" />
+ </xpath> -->
+ <div class="oe_button_box" name="button_box">
+ <field name="advance_payment_move_ids" invisible="1"/>
+ <button name="action_open_advance_payment_moves"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-book"
+ attrs="{'invisible': [('advance_payment_move_ids', '=', [])]}">
+ <field name="advance_payment_move_count" widget="statinfo" string="Journals"/>
+ </button>
+
+ <!-- <button type="object"
+ name="action_view_related_refunds"
+ class="oe_stat_button"
+ icon="fa-refresh"
+ attrs="{'invisible': [('refund_count', '=', 0)]}">
+ <field name="refund_count" widget="statinfo" string="Refund"/>
+ </button> -->
+ </div>
+ <field name="payment_term_id" position="after">
<field name="create_uid" invisible="1"/>
<field name="create_date" invisible="1"/>
<field name="shipping_cost_covered"
@@ -103,10 +128,10 @@
<field name="compute_fullfillment" invisible="1" />
</field>
<field name="tag_ids" position="after">
- <field name="eta_date_start"/>
+ <!-- <field name="eta_date_start"/> -->
<t t-esc="' to '"/>
- <field name="eta_date" readonly="1"/>
- <field name="expected_ready_to_ship"/>
+ <!-- <field name="eta_date" readonly="1"/> -->
+ <!-- <field name="expected_ready_to_ship"/> -->
<field name="ready_to_ship_status_detail"/>
<field name="flash_sale"/>
<field name="margin_after_delivery_purchase"/>
@@ -134,15 +159,29 @@
<field name="real_invoice_id"/>
<field name="approval_status"/>
<field name="sales_tax_id"
- domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1"/>
- <field name="carrier_id" required="1"/>
- <field name="delivery_service_type" readonly="1"/>
- <field name="shipping_option_id"/>
+ domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1" />
+ <field name="select_shipping_option"/>
+ <field name="carrier_id" required="1" domain="[]" />
+ <field name="delivery_service_type" readonly="1" />
+ <field name="shipping_option_id" />
</field>
<field name="medium_id" position="after">
<field name="date_doc_kirim" readonly="1"/>
<field name="notification" readonly="1"/>
</field>
+ <xpath expr="//page[@name='other_information']/group/group[@name='sale_reporting']" position="after">
+ <group string="ETA">
+ <field name="et_products"/>
+ <field name="eta_date_reserved"/>
+ <field name="expected_ready_to_ship"/>
+ <field name="eta_date_start"/>
+ <field name="eta_date" readonly="1"/>
+ <!-- <field name="has_refund" readonly="1"/> -->
+ </group>
+ <group string="Return Doc">
+ <field name="ccm_id" readonly="1"/>
+ </group>
+ </xpath>
<xpath expr="//form/sheet/notebook/page/field[@name='order_line']"
position="attributes">
<attribute name="attrs">
@@ -208,6 +247,11 @@
<label for="item_percent_margin"/>
<field name="item_percent_margin"/>
</div>
+ <div name="item_percent_margin_before" groups="base.group_no_one"
+ attrs="{'invisible': [('display_type', '!=', False)]}">
+ <label for="item_percent_margin_before"/>
+ <field name="item_percent_margin_before"/>
+ </div>
</div>
<div name="invoice_lines" position="before">
<div name="price_subtotal" groups="base.group_no_one"
@@ -239,6 +283,7 @@
attrs="{'readonly': [('parent.approval_status', '!=', False)]}"
domain="[('type_tax_use','=','purchase')]" options="{'no_create':True}"/>
<field name="item_percent_margin"/>
+ <field name="item_percent_margin_before"/>
<field name="item_margin" optional="hide"/>
<field name="margin_md" optional="hide"/>
<field name="note" optional="hide"/>
@@ -296,10 +341,14 @@
<field name="picking_iu_id"/>
<field name="note_ekspedisi"/>
</field>
+ <field name="select_shipping_option" position="attributes">
+ <attribute name="attrs">
+ {'readonly': [('state', 'in', ['cancel', 'done'])]}
+ </attribute>
+ </field>
<field name="carrier_id" position="attributes">
<attribute name="attrs">
- {'readonly': [('approval_status', '=', 'approved'), ('state', 'not in',
- ['cancel', 'draft'])]}
+ {'readonly': [('state', 'in', ['cancel', 'done'])]}
</attribute>
</field>
<field name="payment_term_id" position="attributes">
@@ -403,7 +452,7 @@
<field name="state" position="after">
<field name="approval_status"/>
<field name="client_order_ref"/>
- <field name="notes"/>
+ <field name="notes" optional="hide"/>
<field name="payment_type" optional="hide"/>
<field name="payment_status" optional="hide"/>
<field name="pareto_status" optional="hide"/>
@@ -428,6 +477,7 @@
<field name="payment_type" optional="hide"/>
<field name="payment_status" optional="hide"/>
<field name="pareto_status" optional="hide"/>
+ <field name="notes" optional="hide"/>
</field>
</field>
</record>
@@ -611,6 +661,16 @@
</record>
</data>
+ <!-- <data>
+ <record id="sale_order_multi_create_refund_ir_actions_server" model="ir.actions.server">
+ <field name="name">Refund</field>
+ <field name="model_id" ref="sale.model_sale_order"/>
+ <field name="binding_model_id" ref="sale.model_sale_order"/>
+ <field name="state">code</field>
+ <field name="code">action = records.open_form_multi_create_refund()</field>
+ </record>
+ </data> -->
+
<data>
<record id="mail_template_sale_order_notification_to_salesperson" model="mail.template">
<field name="name">Sale Order: Notification to Salesperson</field>
diff --git a/indoteknik_custom/views/sale_order_delay.xml b/indoteknik_custom/views/sale_order_delay.xml
new file mode 100644
index 00000000..b2aad8eb
--- /dev/null
+++ b/indoteknik_custom/views/sale_order_delay.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="view_sale_order_delay_tree" model="ir.ui.view">
+ <field name="name">sale.order.delay.tree</field>
+ <field name="model">sale.order.delay</field>
+ <field name="arch" type="xml">
+ <tree>
+ <field name="so_number" />
+ <field name="days_delayed" />
+ <field name="status" />
+ </tree>
+ </field>
+ </record>
+
+ <record id="sale_order_delay_action" model="ir.actions.act_window">
+ <field name="name">Sale Order Delay</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">sale.order.delay</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <record id="view_sale_order_delay_form" model="ir.ui.view">
+ <field name="name">sale.order.delay.form</field>
+ <field name="model">sale.order.delay</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <group>
+ <field name="so_number" />
+ <field name="days_delayed" />
+ <field name="status" />
+ </group>
+ </sheet>
+ </form>
+ </field>
+ </record>
+
+ <menuitem id="menu_sale_order_delay"
+ name="Sale Order Delay"
+ action="sale_order_delay_action"
+ parent="sale.product_menu_catalog"
+ sequence="8"
+ />
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/stock_picking.xml b/indoteknik_custom/views/stock_picking.xml
index ae77ab9a..f9200dfa 100644
--- a/indoteknik_custom/views/stock_picking.xml
+++ b/indoteknik_custom/views/stock_picking.xml
@@ -17,6 +17,7 @@
<field name="driver_arrival_date" optional="hide"/>
<field name="note_logistic" optional="hide"/>
<field name="note" optional="hide"/>
+ <field name="sj_return_date" optional="hide"/>
<field name="date_reserved" optional="hide"/>
<field name="state_reserve" optional="hide"/>
<field name="state_packing" widget="badge" decoration-success="state_packing == 'packing_done'" decoration-danger="state_packing == 'not_packing'" optional="hide"/>
@@ -49,11 +50,11 @@
type="object"
attrs="{'invisible': ['|', ('state', 'in', ['done']), ('approval_receipt_status', '=', 'pengajuan1')]}"
/>
- <button name="ask_return_approval"
- string="Ask Return/Acc"
- type="object"
- attrs="{'invisible': [('state', 'in', ['draft', 'cancel', 'assigned'])]}"
- />
+<!-- <button name="ask_return_approval"-->
+<!-- string="Ask Return/Acc"-->
+<!-- type="object"-->
+<!-- attrs="{'invisible': [('state', 'in', ['draft', 'cancel', 'assigned'])]}"-->
+<!-- />-->
<button name="action_create_invoice_from_mr"
string="Create Bill"
type="object"
@@ -63,6 +64,12 @@
string="Biteship"
type="object"
/>
+ <!-- <button name="action_sync_biteship_tracking"
+ type="object"
+ string="Lacak dari Biteship"
+ class="btn-primary"
+ attrs="{'invisible': [('biteship_id', '=', False)]}"
+ /> -->
<button name="track_envio_shipment"
string="Tracking Envio"
type="object"
@@ -91,7 +98,9 @@
/>
</button>
<field name="backorder_id" position="after">
+ <field name="select_shipping_option_so"/>
<field name="shipping_method_so_id"/>
+ <field name="shipping_option_so_id"/>
<field name="summary_qty_detail"/>
<field name="count_line_detail"/>
<field name="dokumen_tanda_terima"/>
@@ -183,18 +192,33 @@
<field name="note_logistic"/>
<field name="note_info"/>
<field name="responsible" />
- <field name="carrier_id"/>
+ <field name="carrier_id" attrs="{'invisible': [('select_shipping_option_so', '=', 'biteship')]}" />
+ <field name="biteship_id" invisible="1"/>
<field name="out_code" attrs="{'invisible': [['out_code', '=', False]]}"/>
<field name="picking_code" attrs="{'invisible': [['picking_code', '=', False]]}"/>
<field name="picking_code" string="Picking code (akan digenerate ketika sudah di-validate)" attrs="{'invisible': [['picking_code', '!=', False]]}"/>
<field name="driver_departure_date" attrs="{'readonly':[('invoice_status', '=', 'invoiced')]}"/>
<field name="driver_arrival_date"/>
- <field name="delivery_tracking_no"/>
+ <field name="delivery_tracking_no" attrs="{'invisible': [('select_shipping_option_so', '=', 'biteship')]}"/>
<field name="driver_id"/>
<field name='sj_return_date'/>
<field name="sj_documentation" widget="image" />
<field name="paket_documentation" widget="image" />
</group>
+ <!-- Biteship Group -->
+ <group attrs="{'invisible': [('select_shipping_option_so', '!=', 'biteship')]}">
+ <field name="delivery_tracking_no" />
+ <field name="shipping_method_so_id"/>
+ <field name="shipping_option_so_id"/>
+ <field name="biteship_shipping_price" readonly="1"/>
+ <field name="currency_id" invisible="1"/>
+ <field name="biteship_shipping_status" readonly="1"/>
+ <field name="biteship_driver_name" readonly="1"/>
+ <field name="biteship_driver_phone" readonly="1"/>
+ <field name="biteship_driver_plate_number" readonly="1"/>
+ <button name="action_open_biteship_tracking" string="Visit Biteship Tracking" type="object"/>
+ </group>
+
<group attrs="{'invisible': [('carrier_id', '!=', 151)]}">
<field name="envio_id" invisible="1"/>
<field name="envio_code"/>
diff --git a/indoteknik_custom/views/tukar_guling.xml b/indoteknik_custom/views/tukar_guling.xml
new file mode 100644
index 00000000..a79f8b55
--- /dev/null
+++ b/indoteknik_custom/views/tukar_guling.xml
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+ <data>
+ <!-- Action -->
+ <record id="action_pengajuan_tukar_guling" model="ir.actions.act_window">
+ <field name="name">Pengajuan Return SO</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">tukar.guling</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+ <!-- Menu -->
+ <menuitem
+ id="menu_pengajuan_tukar_guling"
+ name="Pengajuan Return SO"
+ parent="sale.sale_order_menu"
+ sequence="7"
+ action="action_pengajuan_tukar_guling"
+ />
+ <!-- Tree View -->
+ <record id="pengajuan_tukar_guling_tree" model="ir.ui.view">
+ <field name="name">pengajuan.tukar.guling.tree</field>
+ <field name="model">tukar.guling</field>
+ <field name="arch" type="xml">
+ <tree create="1" delete="1" default_order="create_date desc">
+ <field name="name"/>
+ <field name="date"/>
+ <field name="origin" string="SO Number"/>
+ <field name="operations" string="Operations"/>
+ <field name="return_type" string="Return Type"/>
+ <field name="state" widget="badge"
+ decoration-info="state in ('draft', 'approval_sales', 'approval_finance','approval_logistic')"
+ decoration-success="state == 'done'"
+ decoration-muted="state == 'cancel'"
+ />
+ <field name="ba_num" string="Nomor BA"/>
+ <field name="date_logistic" string="Approved Date"/>
+ </tree>
+ </field>
+ </record>
+ <!-- Form View -->
+ <record id="pengajuan_tukar_guling_form" model="ir.ui.view">
+ <field name="name">pengajuan.tukar.guling.form</field>
+ <field name="model">tukar.guling</field>
+ <field name="arch" type="xml">
+ <form>
+ <header>
+ <button name="action_submit" string="Submit" type="object"
+ class="btn-primary"
+ attrs="{'invisible': [('state', '!=', 'draft')]}"/>
+ <button name="action_approve" string="Approve" type="object"
+ class="btn-primary"
+ attrs="{'invisible': [('state', 'not in', ['approval_sales', 'approval_finance', 'approval_logistic'])]}"/>
+ <button name="action_cancel" string="Cancel" type="object"
+ class="btn-secondary"
+ attrs="{'invisible': [('state', '=', 'cancel')]}"/>
+ <button name="action_draft" string="Set to Draft" type="object"
+ class="btn-secondary"
+ attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
+ <field name="state" widget="statusbar" readonly="1"
+ statusbar_visible="draft,approval_sales,approval_logistic,approval_finance,done"/>
+ </header>
+ <sheet>
+ <div class="oe_button_box">
+ <button name="action_view_picking"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-truck"
+ attrs="{'invisible': [('picking_ids', '=', False), ('state', 'in', ['draft', 'approval_sales', 'approval_logistic', 'approval_finance'])]}">
+ <field name="picking_ids" widget="statinfo" string="Delivery"/>
+ </button>
+ </div>
+ <div class="oe_title">
+ <h1>
+ <field name="name" readonly="1" class="oe_inline"/>
+ </h1>
+ </div>
+ <group>
+ <group>
+ <field name="date" string="Date" readonly="1"/>
+ <field name="partner_id" readonly="1"/>
+ <field name="return_type" attrs="{'readonly': [('state', 'not in', 'draft')]}"/>
+ <field name="operations"
+ attrs="{'readonly': [('state', 'not in', 'draft')]}"/>
+ <field name="origin" readonly="1"/>
+ </group>
+ <group>
+ <field name="ba_num" string="Nomor BA"/>
+ <field name="notes"/>
+ <field name="date_sales" readonly="1"/>
+ <field name="date_finance" readonly="1"/>
+ <field name="date_logistic" readonly="1"/>
+ </group>
+ </group>
+ <notebook>
+ <page string="Product Lines" name="product_lines">
+ <field name="line_ids">
+ <tree string="Product Lines" editable="top" create="0" delete="1">
+ <field name="sequence" widget="handle"/>
+ <field name="product_id" required="0"
+ options="{'no_create': True, 'no_create_edit': True}" readonly="0"/>
+ <field name="name" force_save="0" readonly="1"/>
+ <field name="product_uom_qty" string="Quantity"/>
+ <field name="product_uom" string="UoM"
+ options="{'no_create': True, 'no_create_edit': True}"/>
+ </tree>
+ </field>
+ </page>
+ <page string="Mapping Koli" name="mapping_koli">
+ <field name="mapping_koli_ids">
+ <tree editable="top" create="0" delete="1">
+ <field name="pick_id" readonly="1" force_save="1"/>
+ <field name="product_id" readonly="1" force_save="1"/>
+ <field name="qty_done" force_save="1" readonly="1"/>
+ <field name="qty_return"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+ </data>
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/tukar_guling_po.xml b/indoteknik_custom/views/tukar_guling_po.xml
new file mode 100644
index 00000000..77feb05f
--- /dev/null
+++ b/indoteknik_custom/views/tukar_guling_po.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+ <data>
+ <!-- Action -->
+ <record id="action_pengajuan_tukar_guling_po" model="ir.actions.act_window">
+ <field name="name">Pengajuan Return PO</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">tukar.guling.po</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+ <!-- Menu -->
+ <menuitem
+ id="menu_pengajuan_tukar_guling_po"
+ name="Pengajuan Return PO"
+ parent="purchase.menu_procurement_management"
+ sequence="3"
+ action="action_pengajuan_tukar_guling_po"
+ />
+ <!-- Tree View -->
+ <record id="pengajuan_tukar_guling_po_tree" model="ir.ui.view">
+ <field name="name">pengajuan.tukar.guling.po.tree</field>
+ <field name="model">tukar.guling.po</field>
+ <field name="arch" type="xml">
+ <tree create="1" delete="1" default_order="create_date desc">
+ <field name="name"/>
+ <field name="date"/>
+ <field name="origin" string="PO Number"/>
+ <field name="operations" string="Operations"/>
+ <field name="return_type" string="Return Type"/>
+ <field name="ba_num" string="Nomor BA"/>
+ <field name="return_type" string="Return Type"/>
+ <field name="state" widget="badge"
+ decoration-info="state in ('draft', 'approval_purchase', 'approval_finance','approval_logistic')"
+ decoration-success="state == 'done'"
+ decoration-muted="state == 'cancel'"
+ />
+ <field name="date_logistic" string="Approved Date"/>
+ </tree>
+ </field>
+ </record>
+ <!-- Form View -->
+ <record id="pengajuan_tukar_guling_po_form" model="ir.ui.view">
+ <field name="name">pengajuan.tukar.guling.po.form</field>
+ <field name="model">tukar.guling.po</field>
+ <field name="arch" type="xml">
+ <form>
+ <header>
+ <button name="action_submit" string="Submit" type="object"
+ class="btn-primary"
+ attrs="{'invisible': [('state', '!=', 'draft')]}"/>
+ <button name="action_approve" string="Approve" type="object"
+ class="btn-primary"
+ attrs="{'invisible': [('state', 'not in', ['approval_purchase', 'approval_finance', 'approval_logistic'])]}"/>
+ <button name="action_cancel" string="Cancel" type="object"
+ class="btn-secondary"
+ attrs="{'invisible': [('state', '=', 'cancel')]}"
+ confirm="Are you sure you want to cancel this record?"/>
+ <button name="action_draft" string="Set to Draft" type="object"
+ class="btn-secondary"
+ attrs="{'invisible': [('state', '!=', 'cancel')]}"
+ confirm="Are you sure you want to reset this record to draft?"/>
+ <field name="state" widget="statusbar" readonly="1"
+ statusbar_visible="draft,approval_purchase,approval_logistic,approval_finance,done"/>
+ </header>
+ <sheet>
+ <div class="oe_button_box">
+ <button name="action_view_picking"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-truck"
+ attrs="{'invisible': [('po_picking_ids', '=', False)]}">
+ <field name="po_picking_ids" widget="statinfo" string="Delivery"/>
+ </button>
+ </div>
+ <div class="oe_title">
+ <h1>
+ <field name="name" readonly="1" class="oe_inline"/>
+ </h1>
+ </div>
+ <group>
+ <group>
+ <field name="vendor_id" readonly="1"/>
+ <field name="date" string="Date" readonly="1"/>
+ <field name="return_type"/>
+ <!-- <field name="ort_num" readonly="1"/>-->
+ <!-- <field name="srt_num" readonly="1"/>-->
+ <field name="operations" string="Operations"
+ attrs="{
+ 'required': [('return_type', 'in', ['revisi_po', 'tukar_guling'])]
+ }"/>
+ <field name="origin" readonly="1"/>
+ <!-- <field name="origin_so" readonly="1"/>-->
+ </group>
+ <group>
+ <field name="ba_num" string="Nomor BA"/>
+ <field name="notes"/>
+ <field name="date_purchase" readonly="1"/>
+ <field name="date_finance" readonly="1"/>
+ <field name="date_logistic" readonly="1"/>
+ </group>
+ </group>
+ <!-- Product Lines -->
+ <notebook>
+ <page string="Product Lines" name="product_lines" create="0" edit="0">
+ <field name="line_ids" delete="1" readonly="1">
+ <tree string="Product Lines">
+ <field name="sequence" widget="handle"/>
+ <field name="product_id" required="1"
+ options="{'no_create': True, 'no_create_edit': True}"/>
+ <field name="name" force_save="1"/>
+ <field name="product_uom_qty" string="Quantity"/>
+ <field name="product_uom" string="UoM"
+ options="{'no_create': True, 'no_create_edit': True}"/>
+ </tree>
+ </field>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+ </data>
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/tukar_guling_return_views.xml b/indoteknik_custom/views/tukar_guling_return_views.xml
new file mode 100644
index 00000000..9312005a
--- /dev/null
+++ b/indoteknik_custom/views/tukar_guling_return_views.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <!-- Inherit the return picking form view -->
+ <record id="view_stock_return_picking_form_inherit" model="ir.ui.view">
+ <field name="name">stock.return.picking.form.inherit.tukar.guling</field>
+ <field name="model">stock.return.picking</field>
+ <field name="inherit_id" ref="stock.view_stock_return_picking_form"/>
+ <field name="priority" eval="20"/> <!-- Higher than stock_account -->
+ <field name="arch" type="xml">
+ <!-- Add fields above the product moves table -->
+ <xpath expr="//field[@name='product_return_moves']" position="before">
+ <div class="row mb-3">
+ <div class="col-12">
+ <field name="return_type" class="oe_inline"/>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/user_pengajuan_tempo.xml b/indoteknik_custom/views/user_pengajuan_tempo.xml
index 4eebe9e4..f9dca4ca 100644
--- a/indoteknik_custom/views/user_pengajuan_tempo.xml
+++ b/indoteknik_custom/views/user_pengajuan_tempo.xml
@@ -204,7 +204,7 @@
<field name="subject">Pengajuan Tempo Harus di Periksa!</field>
<field name="email_from">"Indoteknik.com" &lt;noreply@indoteknik.com&gt;</field>
<field name="reply_to">sales@indoteknik.com</field>
- <field name="email_to">vita@indoteknik.co.id</field>
+ <field name="email_to">putra@indoteknik.co.id,Darren@indoteknik.co.id</field>
<!-- <field name="email_to">sapiabon768@gmail.com</field>-->
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Inter, Helvetica, Verdana, Arial,sans-serif; line-height: 24px; color: #454748; width: 100%; border-collapse:separate;">
diff --git a/indoteknik_custom/views/x_manufactures.xml b/indoteknik_custom/views/x_manufactures.xml
index 01e90a1e..02061251 100755
--- a/indoteknik_custom/views/x_manufactures.xml
+++ b/indoteknik_custom/views/x_manufactures.xml
@@ -3,7 +3,7 @@
<record id="x_manufactures_action" model="ir.actions.act_window">
<field name="name">Manufacture</field>
<field name="res_model">x_manufactures</field>
- <field name="view_mode">tree,form</field>
+ <field name="view_mode">kanban,tree,form</field> <!-- Tambahkan kanban di sini -->
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add Manufactures!
@@ -11,6 +11,44 @@
</field>
</record>
+ <record id="x_manufactures_kanban" model="ir.ui.view">
+ <field name="name">Manufactures Kanban</field>
+ <field name="model">x_manufactures</field>
+ <field name="arch" type="xml">
+ <kanban default_group_by="x_manufacture_level">
+ <field name="id"/> <!-- Tambahkan ini -->
+ <field name="x_name"/>
+ <field name="x_logo_manufacture"/>
+ <field name="x_manufacture_level"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div class="oe_kanban_global_click">
+ <div class="o_kanban_image">
+ <img t-if="record.x_logo_manufacture.raw_value"
+ t-att-src="kanban_image('x_manufactures', 'x_logo_manufacture', record.id.raw_value)"
+ alt="Logo" class="img-fluid" width="64" height="64"/>
+ <img t-else=""
+ src="/web/static/src/img/placeholder.png"
+ alt="No Logo" class="img-fluid" width="64" height="64"/>
+ </div>
+ <div class="o_kanban_details">
+ <strong><field name="x_name"/></strong>
+ <div>
+ <span class="badge badge-secondary">
+ <field name="x_manufacture_level"/>
+ </span>
+ </div>
+ <div t-if="record.x_short_desc.raw_value">
+ <small><field name="x_short_desc"/></small>
+ </div>
+ </div>
+ </div>
+ </t>
+ </templates>
+ </kanban>
+ </field>
+ </record>
+
<record id="x_manufactures_tree" model="ir.ui.view">
<field name="name">Manufactures</field>
<field name="model">x_manufactures</field>