summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ab_openstreetmap/__init__.py0
-rw-r--r--ab_openstreetmap/__manifest__.py17
-rw-r--r--ab_openstreetmap/static/src/js/googlemap_widget.js134
-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/partner.py184
-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/stock_picking.py2
-rw-r--r--indoteknik_api/models/sale_order.py16
-rwxr-xr-xindoteknik_custom/__manifest__.py1
-rwxr-xr-xindoteknik_custom/models/__init__.py1
-rwxr-xr-xindoteknik_custom/models/product_template.py3
-rw-r--r--indoteknik_custom/models/res_partner.py158
-rwxr-xr-xindoteknik_custom/models/sale_order.py978
-rw-r--r--indoteknik_custom/models/sale_order_delay.py31
-rw-r--r--indoteknik_custom/models/stock_picking.py349
-rw-r--r--indoteknik_custom/patch.py16
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv1
-rw-r--r--indoteknik_custom/views/res_partner.xml24
-rwxr-xr-xindoteknik_custom/views/sale_order.xml15
-rw-r--r--indoteknik_custom/views/sale_order_delay.xml44
-rw-r--r--indoteknik_custom/views/stock_picking.xml9
23 files changed, 1797 insertions, 320 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..1728fa54
--- /dev/null
+++ b/ab_openstreetmap/static/src/js/googlemap_widget.js
@@ -0,0 +1,134 @@
+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();
+ },
+
+ _waitForMapReady: function () {
+ const mapEl = document.getElementById("mapid");
+ if (mapEl && mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0) {
+ this._loadGoogle();
+ } else {
+ setTimeout(() => this._waitForMapReady(), 100);
+ }
+ },
+
+ async _loadGoogle() {
+ // const apiKey = await rpc.query({
+ // model: "ir.config_parameter",
+ // method: "get_param",
+ // args: ["google.maps.api_key"],
+ // });
+ // const mapId = await rpc.query({
+ // model: "ir.config_parameter",
+ // method: "get_param",
+ // args: ["google.maps.map_id"],
+ // });
+
+ const apiKey = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE"; // Ganti dengan API Key Anda
+ const mapId = "1af072c8d80a2adec8057f34";
+ // Ganti dengan Map ID Anda
+ 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";
+
+ 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,
+ });
+
+ 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);
+
+ // Gunakan Autocomplete klasik (deprecated tapi stabil)
+ 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/partner.py b/indoteknik_api/controllers/api_v1/partner.py
index 126fded4..37ae8d5f 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()
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 e8c2c75a..e1c643e5 100644
--- a/indoteknik_api/controllers/api_v1/sale_order.py
+++ b/indoteknik_api/controllers/api_v1/sale_order.py
@@ -462,7 +462,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']
@@ -481,6 +481,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']
@@ -516,6 +517,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/stock_picking.py b/indoteknik_api/controllers/api_v1/stock_picking.py
index 7cbd3c96..c5a4f7ed 100644
--- a/indoteknik_api/controllers/api_v1/stock_picking.py
+++ b/indoteknik_api/controllers/api_v1/stock_picking.py
@@ -3,6 +3,7 @@ from odoo import http
from odoo.http import request
from pytz import timezone
from datetime import datetime
+import json
class StockPicking(controller.Controller):
@@ -103,7 +104,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'])
diff --git a/indoteknik_api/models/sale_order.py b/indoteknik_api/models/sale_order.py
index baba7c37..45461974 100644
--- a/indoteknik_api/models/sale_order.py
+++ b/indoteknik_api/models/sale_order.py
@@ -29,13 +29,23 @@ class SaleOrder(models.Model):
'pickings': []
}
for picking in sale_order.picking_ids:
- if not picking.name.startswith('BU/OUT'):
+ 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 '',
- 'delivered': picking.waybill_id.delivered or picking.driver_arrival_date != False or picking.sj_return_date != False,
+ # '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 9f8fad01..b9365ba9 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -167,6 +167,7 @@
'views/coretax_faktur.xml',
'views/public_holiday.xml',
'views/stock_inventory.xml',
+ 'views/sale_order_delay.xml',
'views/tukar_guling.xml',
'views/tukar_guling_po.xml',
],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 72bd7cee..8f08828b 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -149,5 +149,6 @@ 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 tukar_guling
diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py
index 2c07824a..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:
diff --git a/indoteknik_custom/models/res_partner.py b/indoteknik_custom/models/res_partner.py
index f1e362e6..a8ce95d1 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)
@@ -184,6 +188,10 @@ 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()
#
# # if 'property_payment_term_id' in vals:
# # if not self.env.user.is_accounting and vals['property_payment_term_id'] != 26:
@@ -195,6 +203,14 @@ 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()
+ return records
@api.constrains('name')
def _check_duplicate_name(self):
@@ -521,4 +537,142 @@ 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 = 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 = 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 baa8207f..1771f210 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:
@@ -324,8 +328,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,6 +349,292 @@ class SaleOrder(models.Model):
date_unhold = fields.Datetime(string='Date Unhold', tracking=True, readonly=True, help='Waktu ketika SO di Unhold'
)
+ @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 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):
+ if self.select_shipping_option == 'biteship' and 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."
+ )
+ }
+ }
+
+ self.shipping_option_id = False
+ self.carrier_id = False
+ self.delivery_amt = 0
+
+ # Dapatkan semua ID carrier untuk Biteship
+ biteship_carrier_ids = []
+
+ # Gunakan SQL langsung untuk menghindari masalah ORM
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id
+ FROM rajaongkir_kurir
+ WHERE name IN %s
+ """, (tuple(self._get_biteship_courier_codes()),))
+
+ # Ambil ID numerik hasil query
+ biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]]
+
+ if self.select_shipping_option == 'biteship':
+ domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else []
+ else: # 'custom'
+ domain = [('id', 'not in', biteship_carrier_ids)] if biteship_carrier_ids else []
+
+ return {'domain': {'carrier_id': domain}}
+
# def _compute_total_margin_excl_third_party(self):
# for order in self:
# if order.amount_untaxed == 0:
@@ -415,12 +710,6 @@ class SaleOrder(models.Model):
# """, (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 +782,86 @@ 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.")
+
+ 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,
+ "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]} hari, Cost: Rp {s[3]}' for s in shipping_options])}",
+ message_type="comment"
+ )
+
+ # 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")
+ 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,50 +870,282 @@ 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.")
- 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,
+ 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"
+
+ 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)
+
+ 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
+ })
+
+ _logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}")
+ except Exception as e:
+ _logger.error(f"Gagal membuat opsi pengiriman: {str(e)}")
- _logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
+ 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.")
- 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"
- )
+ # Temukan shipping option yang cocok berdasarkan carrier_id
+ selected_option = None
+
+ 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()
- # 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 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 = shipping_address.alamat_lengkap_text or shipping_address.street or shipping_address.name or ''
+ 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'
@@ -684,38 +1274,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
+
+ # 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
- 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 +1378,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 +1412,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
- @api.onchange('expected_ready_to_ship') # Hangle Onchange form Expected Ready to Ship
+ 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
def _onchange_expected_ready_ship_date(self):
self._validate_expected_ready_ship_date()
@@ -2032,10 +2747,79 @@ 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()
@@ -2153,6 +2937,8 @@ class SaleOrder(models.Model):
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:
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/stock_picking.py b/indoteknik_custom/models/stock_picking.py
index 622c537e..eabef37c 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -19,11 +19,9 @@ 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'
@@ -170,6 +168,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:
@@ -271,14 +273,14 @@ class StockPicking(models.Model):
biteship_id = fields.Char(string="Biteship Respon ID")
biteship_tracking_id = fields.Char(string="Biteship Trackcking 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)
final_seq = fields.Float(string='Remaining Time')
- shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO',
- related='sale_id.carrier_id')
- state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')],
- string='Packing Status')
+ shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO', related='sale_id.carrier_id')
+ shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option SO', related='sale_id.shipping_option_id')
+ select_shipping_option_so = fields.Selection([
+ ('biteship', 'Biteship'),
+ ('custom', 'Custom'),
+ ], string='Shipping Type SO', related='sale_id.select_shipping_option')
+ 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')
last_update_date_doc_kirim = fields.Datetime(string='Last Update Tanggal Kirim', copy=False)
update_date_doc_kirim_add = fields.Boolean(string='Update Tanggal Kirim Lewat ADD')
@@ -682,46 +684,55 @@ 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,
@@ -729,41 +740,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).")
- api_key = _biteship_api_key
+ payload["destination_coordinate"] = {
+ "latitude": shipping_partner.latitude,
+ "longitude": shipping_partner.longtitude,
+ }
+
+ _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()
@@ -771,17 +780,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:
@@ -1346,7 +1365,7 @@ class StockPicking(models.Model):
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')
@@ -1623,28 +1642,51 @@ class StockPicking(models.Model):
self.ensure_one()
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", [])
@@ -1666,12 +1708,12 @@ 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"
}
-
+
manifests = []
try:
@@ -1680,14 +1722,13 @@ class StockPicking(models.Model):
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", "")
+ '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", [])
@@ -1705,19 +1746,99 @@ class StockPicking(models.Model):
"delivered": status
}
- return manifests
- except Exception as e:
+ 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)}
+ return { 'error': str(e) }
- def _convert_to_local_time(self, iso_date):
+ 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 _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:
- dt_with_tz = waktu.fromisoformat(iso_date)
- utc_dt = dt_with_tz.astimezone(pytz.utc)
+ 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 = utc_dt.astimezone(local_tz)
-
+ local_dt = waktu.astimezone(local_tz)
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
return str(e)
@@ -1738,20 +1859,26 @@ class StockPicking(models.Model):
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
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 45ce23dd..63e7b53a 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -178,6 +178,7 @@ 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
diff --git a/indoteknik_custom/views/res_partner.xml b/indoteknik_custom/views/res_partner.xml
index cb9fa3ac..2a4b03a7 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>
diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml
index 4b74825e..fbca3705 100755
--- a/indoteknik_custom/views/sale_order.xml
+++ b/indoteknik_custom/views/sale_order.xml
@@ -134,10 +134,11 @@
<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"/>
@@ -302,6 +303,12 @@
<field name="picking_iu_id"/>
<field name="note_ekspedisi"/>
</field>
+ <field name="select_shipping_option" position="attributes">
+ <attribute name="attrs">
+ {'readonly': [('approval_status', '=', 'approved'), ('state', 'not in',
+ ['cancel','draft'])]}
+ </attribute>
+ </field>
<field name="carrier_id" position="attributes">
<attribute name="attrs">
{'readonly': [('approval_status', '=', 'approved'), ('state', 'not in',
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..97a9fbed 100644
--- a/indoteknik_custom/views/stock_picking.xml
+++ b/indoteknik_custom/views/stock_picking.xml
@@ -63,6 +63,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 +97,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"/>
@@ -184,6 +192,7 @@
<field name="note_info"/>
<field name="responsible" />
<field name="carrier_id"/>
+ <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]]}"/>