summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIndoteknik . <it@fixcomart.co.id>2025-06-09 15:11:05 +0700
committerIndoteknik . <it@fixcomart.co.id>2025-06-09 15:11:05 +0700
commit1bc03c1482a664ffcd58f19022a40e65e21774c6 (patch)
treef491d00cd1d371c8fd76ad25e014ac8d662c3d02
parent1bd3a91889f8616d7042c0d15315c2f25c974ed3 (diff)
parentf43855aa55265794c7774af79089258e830b0df4 (diff)
(andri) fix merge biteship with odoo-backup
-rw-r--r--indoteknik_api/controllers/api_v1/product.py114
-rw-r--r--indoteknik_api/controllers/api_v1/sale_order.py2
-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
-rwxr-xr-xindoteknik_custom/models/sale_order.py949
-rw-r--r--indoteknik_custom/models/sale_order_delay.py31
-rw-r--r--indoteknik_custom/models/stock_picking.py319
-rw-r--r--indoteknik_custom/patch.py16
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv1
-rw-r--r--indoteknik_custom/views/res_partner.xml4
-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
16 files changed, 1304 insertions, 223 deletions
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..12eac2e5 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']
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 9fe3dcdb..ad019d4b 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',
],
'demo': [],
'css': [],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 08fa9803..605d1016 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -149,4 +149,5 @@ 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
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/sale_order.py b/indoteknik_custom/models/sale_order.py
index 705d16ef..a0576ad8 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")
@@ -324,8 +327,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)
+
hold_outgoing = fields.Boolean('Hold Outgoing SO', tracking=3)
state_ask_cancel = fields.Selection([
('hold', 'Hold'),
@@ -340,6 +348,279 @@ 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):
+ # ───────────────────────────────────────────────────────────────
+ # 1. abaikan onchange kalau SO masih draft / belum tersimpan
+ # ───────────────────────────────────────────────────────────────
+ if not self._origin or not self._origin.id:
+ return
+
+ sale_order_id = self._origin.id # id SO asli (sudah tersimpan)
+
+ # ───────────────────────────────────────────────────────────────
+ # 2. Jika SO BELUM mempunyai satupun shipping.option ⇒
+ # jangan lakukan validasi apa-apa; cukup reset field & domain
+ # ───────────────────────────────────────────────────────────────
+ total_so_options = self.env['shipping.option'].search_count(
+ [('sale_order_id', '=', sale_order_id)]
+ )
+ if total_so_options == 0:
+ # belum pernah estimasi ongkir ⇒ biarkan user ganti carrier
+ self.shipping_option_id = False
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # ───────────────────────────────────────────────────────────────
+ # 3. (kode lama) – mulai validasi hanya jika sudah ada option
+ # ───────────────────────────────────────────────────────────────
+ self.shipping_option_id = False
+
+ if not self.carrier_id:
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # cari provider dari mapping rajaongkir_kurir
+ 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}")
+
+ # hitung berapa option yg match provider BARU
+ self.env.cr.execute("""
+ SELECT COUNT(*) FROM shipping_option
+ WHERE LOWER(provider) LIKE %s AND sale_order_id = %s
+ """, (f'%{provider}%', sale_order_id))
+ matched = self.env.cr.fetchone()[0] or 0
+
+ if matched == 0:
+ # provider baru tidak ada di option yang SUDAH dibuat → kembalikan ke carrier lama
+ prev_carrier = self._origin.carrier_id
+ self.carrier_id = prev_carrier
+ self.shipping_option_id = self._origin.shipping_option_id or False
+ return {
+ 'warning': {
+ 'title': "Shipping Option Tidak Ditemukan",
+ 'message': (
+ "Layanan kurir tidak tersedia untuk pengiriman ini.\n"
+ "Pilihan dikembalikan ke sebelumnya."
+ )
+ },
+ 'domain': {'shipping_option_id': [('id', '=', -1)]}
+ }
+
+ # kalau match ada → set domain normal (hanya option dengan provider itu)
+ 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 +696,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,10 +768,78 @@ 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 = []
@@ -512,50 +855,275 @@ class SaleOrder(models.Model):
self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}")
if total_weight == 0:
- raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
+ raise UserError("Tidak dapat mengestimasi ongkir tanpa karena berat produk = 0 kg.")
+
+ # Validasi alamat pengiriman
+ if not self.real_shipping_id:
+ raise UserError("Alamat pengiriman (Real Delivery Address) harus diisi.")
+
+ if not self.real_shipping_id.kota_id:
+ raise UserError("Kota pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.zip:
+ raise UserError("Kode pos pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.state_id:
+ raise UserError("Provinsi pada alamat pengiriman harus diisi.")
+
+ return total_weight
+
+ def action_estimate_shipping_biteship(self):
+ total_weight = self._validate_for_shipping_estimate()
+
+ weight_gram = int(total_weight * 1000)
+ if weight_gram < 100:
+ weight_gram = 100
+
+ value = int(self.amount_untaxed or sum(line.price_subtotal for line in self.order_line))
+
+ items = [{
+ "name": "Paket Pesanan",
+ "description": f"Sale Order {self.name}",
+ "value": value,
+ "weight": weight_gram,
+ "quantity": 1,
+ }]
+
+ shipping_address = self.real_shipping_id
+ _logger.info(f"Shipping Address: {shipping_address}")
+
+ origin_data = {
+ "origin_latitude": -6.3031123,
+ "origin_longitude": 106.7794934,
+ }
+
+ destination_data = {}
+ use_coordinate = False
+
+ if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'):
+ if shipping_address.latitude and shipping_address.longtitude:
+ try:
+ lat = float(shipping_address.latitude)
+ lng = float(shipping_address.longtitude)
+ destination_data = {
+ "destination_latitude": lat,
+ "destination_longitude": lng
+ }
+ use_coordinate = True
+ _logger.info(f"Using coordinates: lat={lat}, lng={lng}")
+ except (ValueError, TypeError):
+ _logger.warning(f"Invalid coordinates, falling back to postal code")
+ use_coordinate = False
+
+ if not use_coordinate:
+ if shipping_address.zip:
+ origin_data = {"origin_postal_code": 14440}
+ destination_data = {
+ "destination_postal_code": shipping_address.zip
+ }
+ _logger.info(f"Using postal code: {shipping_address.zip}")
+ else:
+ raise UserError("Tidak dapat mengestimasikan ongkir: Kode pos tujuan tidak tersedia.")
+
+ couriers = ','.join(self._get_biteship_courier_codes())
+
+ api_mode = "koordinat" if use_coordinate else "kode_pos"
+ _logger.info(f"Calling Biteship API with mode: {api_mode}")
+
+ result = self._call_biteship_api(origin_data, destination_data, items, couriers)
+
+ if not result:
+ raise UserError("Gagal mendapatkan estimasi ongkir dari Biteship.")
+
+ self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
+
+ shipping_options = []
+ courier_options = {}
+ shipping_services = result.get('pricing', [])
+
+ _logger.info(f"Ditemukan {len(shipping_services)} layanan pengiriman")
- 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,
+ 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()
+
+ 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")
- # 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")
+ # 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
+ if use_coordinate:
+ origin_info = f"Koordinat ({origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})"
+ destination_info = f"Koordinat ({destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})"
else:
- raise UserError("Gagal mendapatkan estimasi ongkir.")
+ origin_info = f"Kode Pos {origin_data.get('origin_postal_code')}"
+ destination_info = f"Kode Pos {destination_data.get('destination_postal_code')}"
+
+ message_lines = [f"<b>Estimasi Ongkos Kirim Biteship ({origin_info} → {destination_info}):</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/>")
+
+ 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_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
+
+ 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 +1252,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 +1356,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 +1390,109 @@ class SaleOrder(models.Model):
if not rec.date_order:
rec.expected_ready_to_ship = False
return
-
- current_date = datetime.now().date()
-
+
+ jakarta = pytz.timezone("Asia/Jakarta")
+ current_date = datetime.now(jakarta)
+
max_slatime = 1 # Default SLA jika tidak ada
slatime = self.calculate_sla_by_vendor(rec.order_line)
max_slatime = max(max_slatime, slatime['slatime'])
-
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
+
+ offset , is3pm = self.get_days_until_next_business_day(current_date)
+ sum_days = max_slatime + offset
+ sum_days -= 1
if not rec.estimated_arrival_days:
rec.estimated_arrival_days = sum_days
eta_date = current_date + timedelta(days=sum_days)
+ if is3pm:
+ eta_date = datetime.combine(eta_date, time(10, 0)) # jam 10:00
+ eta_date = jakarta.localize(eta_date).astimezone(timezone.utc) # ubah ke UTC
+
+
+ eta_date = eta_date.astimezone(timezone.utc).replace(tzinfo=None)
rec.commitment_date = eta_date
rec.expected_ready_to_ship = eta_date
@api.depends("order_line.product_id", "date_order")
def _compute_etrts_date(self): # Function to calculate Estimated Ready To Ship Date
self._calculate_etrts_date()
+
+
+ # def _validate_expected_ready_ship_date(self):
+ # for rec in self:
+ # if not rec.order_line:
+ # _logger.info("⏩ Lewati validasi ERTS karena belum ada produk.")
+ # return # Lewati validasi jika belum ada produk
+
+ # now = fields.Datetime.now()
+ # expected_date = rec.expected_ready_to_ship and rec.expected_ready_to_ship.date() or None
+ # if not expected_date:
+ # return # Tidak validasi jika tidak ada input sama sekali
+
+ # sla = rec.calculate_sla_by_vendor()
+ # offset_day, lewat_jam_3 = rec.get_days_until_next_business_day()
+ # eta_minimum = now + timedelta(days=sla + offset_day)
+
+ # if expected_date < eta_minimum.date():
+ # rec.expected_ready_to_ship = eta_minimum
+ # raise ValidationError(
+ # "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
+ # .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
+ # )
def _validate_expected_ready_ship_date(self):
+ """
+ Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum.
+ Dipanggil setiap onchange / simpan SO.
+ """
for rec in self:
- if rec.expected_ready_to_ship and rec.commitment_date:
- current_date = datetime.now().date()
- # Hanya membandingkan tanggal saja, tanpa jam
- expected_date = rec.expected_ready_to_ship.date()
-
- max_slatime = 1 # Default SLA jika tidak ada
- slatime = self.calculate_sla_by_vendor(rec.order_line)
- max_slatime = max(max_slatime, slatime['slatime'])
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
- eta_minimum = current_date + timedelta(days=sum_days)
-
- if expected_date < eta_minimum:
- rec.expected_ready_to_ship = eta_minimum
- raise ValidationError(
- "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
- .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
- )
- else:
- rec.commitment_date = rec.expected_ready_to_ship
+ # ─────────────────────────────────────────────────────
+ # 1. Hanya validasi kalau field sudah terisi
+ # (quotation baru / belum ada tanggal → abaikan)
+ # ─────────────────────────────────────────────────────
+ if not rec.expected_ready_to_ship:
+ continue
+
+ current_date = datetime.now()
+
+ # ─────────────────────────────────────────────────────
+ # 2. Hitung SLA berdasarkan product lines (jika ada)
+ # ─────────────────────────────────────────────────────
+ products = rec.order_line
+ if products:
+ sla_data = rec.calculate_sla_by_vendor(products)
+ max_sla_time = sla_data.get('slatime', 1)
+ else:
+ # belum ada item → gunakan default 1 hari
+ max_sla_time = 1
+
+ # offset hari libur / weekend
+ offset, is3pm = rec.get_days_until_next_business_day(current_date)
+ min_days = max_sla_time + offset - 1
+ eta_minimum = current_date + timedelta(days=min_days)
+
+ # ─────────────────────────────────────────────────────
+ # 3. Validasi - raise error bila terlalu cepat
+ # ─────────────────────────────────────────────────────
+ if rec.expected_ready_to_ship.date() < eta_minimum.date():
+ # set otomatis ke tanggal minimum supaya user tidak perlu
+ # menekan Save dua kali
+ rec.expected_ready_to_ship = eta_minimum
+
+ raise ValidationError(
+ _("Tanggal 'Expected Ready to Ship' tidak boleh "
+ "lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.")
+ % {'tgl': eta_minimum.strftime('%d-%m-%Y')}
+ )
+ else:
+ # sinkronkan ke field commitment_date
+ rec.commitment_date = rec.expected_ready_to_ship
+
- @api.onchange('expected_ready_to_ship') # Hangle Onchange form Expected Ready to Ship
+
+
+ @api.onchange('expected_ready_to_ship') #Hangle Onchange form Expected Ready to Ship
def _onchange_expected_ready_ship_date(self):
self._validate_expected_ready_ship_date()
@@ -2032,10 +2725,78 @@ 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 order.carrier_id and order.carrier_id.id == 32:
+ 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()
@@ -2131,6 +2892,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 3921ed5a..71eca020 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -271,14 +271,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')
@@ -688,46 +688,52 @@ 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}")
+
+ 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
- # Mencari data sale.order.line berdasarkan sale_id
- products = self.env['sale.order.line'].search([('order_id', '=', self.sale_id.id)])
+ 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', '=', ml.product_id.id)
+ ], limit=1)
- # 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]
+ value = line.price_unit if line else 0
+ description = line.name if line else product.name
- # Items untuk pengiriman standard
- items_data_standard = build_items_data(products)
+ items.append({
+ "name": product.name,
+ "description": description,
+ "value": value,
+ "quantity": ml.qty_done,
+ "weight": int(weight * 1000),
+ })
- # 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([
- ('order_id', '=', self.sale_id.id),
- ('product_id', '=', move_line.product_id.id)
- ], limit=1)
+ if not items:
+ raise UserError("Pengiriman tidak dapat dilakukan karena tidak ada barang yang divalidasi (qty_done = 0).")
- 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
- })
+ 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,
@@ -735,41 +741,39 @@ class StockPicking(models.Model):
"origin_contact_phone": "081717181922",
"origin_address": "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN",
"origin_postal_code": 14440,
- "destination_contact_name": self.real_shipping_id.name,
- "destination_contact_phone": self.real_shipping_id.phone or self.real_shipping_id.mobile,
- "destination_address": self.real_shipping_id.street,
- "destination_postal_code": self.real_shipping_id.zip,
+ "destination_contact_name": shipping_partner.name,
+ "destination_contact_phone": shipping_partner.phone or shipping_partner.mobile,
+ "destination_address": shipping_partner.street,
+ "destination_postal_code": shipping_partner.zip,
"origin_note": "BELAKANG INDOMARET",
- "courier_type": self.sale_id.delivery_service_type or "reg",
+ "destination_note": f"SO: {self.sale_id.name}",
+ "order_note": f"SO: {self.sale_id.name}",
+ "courier_type": courier_service_code,
"courier_company": self.carrier_id.name.lower(),
"delivery_type": "now",
- "destination_postal_code": self.real_shipping_id.zip,
- "items": items_data_standard
+ "items": items
}
- # Cek jika pengiriman instant atau same_day
- if self.sale_id.delivery_service_type and (
- "instant" in self.sale_id.delivery_service_type or "same_day" in self.sale_id.delivery_service_type):
- payload.update({
- "origin_coordinate": {
- "latitude": -6.3031123,
- "longitude": 106.7794934999
- },
- "destination_coordinate": {
- "latitude": self.real_shipping_id.latitude,
- "longitude": self.real_shipping_id.longtitude,
- },
- "items": items_data_instant
- })
+ if is_courier_need_coordinates(courier_service_code):
+ if not shipping_partner.latitude or not shipping_partner.longtitude:
+ raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).")
+ payload["destination_coordinate"] = {
+ "latitude": shipping_partner.latitude,
+ "longitude": shipping_partner.longtitude,
+ }
+
+ _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()
@@ -777,17 +781,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:
@@ -1629,28 +1643,42 @@ 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)
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
}
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", [])
@@ -1677,7 +1705,7 @@ class StockPicking(models.Model):
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
-
+
manifests = []
try:
@@ -1686,14 +1714,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", [])
@@ -1711,19 +1738,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)
@@ -1744,20 +1851,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 601f04c5..3dabae6d 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..9fb6530c 100644
--- a/indoteknik_custom/views/res_partner.xml
+++ b/indoteknik_custom/views/res_partner.xml
@@ -65,6 +65,10 @@
<group name="purchase" position="inside">
<field name="leadtime"/>
</group>
+ <field name="vat" position="before">
+ <field name="latitude"/>
+ <field name="longtitude"/>
+ </field>
<field name="vat" position="after">
<field name="email_finance" widget="email"/>
<field name="email_sales" widget="email"/>
diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml
index 4b74825e..67bf7e76 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" required="1"/>
+ <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]]}"/>