diff options
| author | Miqdad <ahmadmiqdad27@gmail.com> | 2025-06-17 08:11:32 +0700 |
|---|---|---|
| committer | Miqdad <ahmadmiqdad27@gmail.com> | 2025-06-17 08:11:32 +0700 |
| commit | a2a003a86379fab81b2df36cff5022e1d22a589d (patch) | |
| tree | 72cb451d0793837d785b74beb79cfe5df000bfc4 /indoteknik_custom/models | |
| parent | abd7da741c6eec02dbefa195b91dbedd70b3323e (diff) | |
| parent | a8460239603b7a73a185fec394b0f95ab0247207 (diff) | |
<miqdad> merge odoo-backup
Diffstat (limited to 'indoteknik_custom/models')
| -rwxr-xr-x | indoteknik_custom/models/__init__.py | 1 | ||||
| -rwxr-xr-x | indoteknik_custom/models/product_template.py | 3 | ||||
| -rw-r--r-- | indoteknik_custom/models/res_partner.py | 158 | ||||
| -rwxr-xr-x | indoteknik_custom/models/sale_order.py | 978 | ||||
| -rw-r--r-- | indoteknik_custom/models/sale_order_delay.py | 31 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_picking.py | 349 |
6 files changed, 1311 insertions, 209 deletions
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 |
