summaryrefslogtreecommitdiff
path: root/indoteknik_custom
diff options
context:
space:
mode:
authorMiqdad <ahmadmiqdad27@gmail.com>2025-06-17 08:11:32 +0700
committerMiqdad <ahmadmiqdad27@gmail.com>2025-06-17 08:11:32 +0700
commita2a003a86379fab81b2df36cff5022e1d22a589d (patch)
tree72cb451d0793837d785b74beb79cfe5df000bfc4 /indoteknik_custom
parentabd7da741c6eec02dbefa195b91dbedd70b3323e (diff)
parenta8460239603b7a73a185fec394b0f95ab0247207 (diff)
<miqdad> merge odoo-backup
Diffstat (limited to 'indoteknik_custom')
-rwxr-xr-xindoteknik_custom/__manifest__.py1
-rwxr-xr-xindoteknik_custom/models/__init__.py1
-rwxr-xr-xindoteknik_custom/models/product_template.py3
-rw-r--r--indoteknik_custom/models/res_partner.py158
-rwxr-xr-xindoteknik_custom/models/sale_order.py978
-rw-r--r--indoteknik_custom/models/sale_order_delay.py31
-rw-r--r--indoteknik_custom/models/stock_picking.py349
-rw-r--r--indoteknik_custom/patch.py16
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv1
-rw-r--r--indoteknik_custom/views/res_partner.xml24
-rwxr-xr-xindoteknik_custom/views/sale_order.xml15
-rw-r--r--indoteknik_custom/views/sale_order_delay.xml44
-rw-r--r--indoteknik_custom/views/stock_picking.xml9
13 files changed, 1417 insertions, 213 deletions
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index 9f8fad01..b9365ba9 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -167,6 +167,7 @@
'views/coretax_faktur.xml',
'views/public_holiday.xml',
'views/stock_inventory.xml',
+ 'views/sale_order_delay.xml',
'views/tukar_guling.xml',
'views/tukar_guling_po.xml',
],
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 72bd7cee..8f08828b 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -149,5 +149,6 @@ from . import sales_order_koli
from . import stock_backorder_confirmation
from . import account_payment_register
from . import stock_inventory
+from . import sale_order_delay
from . import approval_invoice_date
from . import tukar_guling
diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py
index 2c07824a..f59bea6b 100755
--- a/indoteknik_custom/models/product_template.py
+++ b/indoteknik_custom/models/product_template.py
@@ -349,6 +349,9 @@ class ProductTemplate(models.Model):
'search_key':[item_code],
}
response = requests.post(url, headers=headers, json=json_data)
+ if response.status_code != 200:
+ return 0
+
datas = json.loads(response.text)['data']
qty = 0
for data in datas:
diff --git a/indoteknik_custom/models/res_partner.py b/indoteknik_custom/models/res_partner.py
index f1e362e6..a8ce95d1 100644
--- a/indoteknik_custom/models/res_partner.py
+++ b/indoteknik_custom/models/res_partner.py
@@ -3,6 +3,9 @@ from odoo.exceptions import UserError, ValidationError
from datetime import datetime
from odoo.http import request
import re
+import requests
+import logging
+_logger = logging.getLogger(__name__)
class GroupPartner(models.Model):
_name = 'group.partner'
@@ -145,7 +148,8 @@ class ResPartner(models.Model):
date_payment_terms_purchase = fields.Datetime(string='Date Update Payment Terms')
longtitude = fields.Char(string='Longtitude')
latitude = fields.Char(string='Latitude')
- address_map = fields.Char(string='Address Map')
+ map_view = fields.Char(string='Map')
+ address_map = fields.Char(string='Address Map', help='Alamat ini diisi otomatis berdasarkan koordinat pin pada peta. Silakan koreksi dan ubah jika terdapat ketidaksesuaian', tracking=3)
company_type = fields.Selection(string='Company Type',
selection=[('person', 'Individual'), ('company', 'Company')],
compute='_compute_company_type', inverse='_write_company_type', tracking=3)
@@ -184,6 +188,10 @@ class ResPartner(models.Model):
def write(self, vals):
res = super(ResPartner, self).write(vals)
+
+ for rec in self:
+ if 'latitude' in vals or 'longtitude' in vals:
+ rec._update_address_from_coords()
#
# # if 'property_payment_term_id' in vals:
# # if not self.env.user.is_accounting and vals['property_payment_term_id'] != 26:
@@ -195,6 +203,14 @@ class ResPartner(models.Model):
# # raise UserError('You name it')
#
return res
+
+ @api.model
+ def create(self, vals):
+ records = super().create(vals)
+ for rec in records:
+ if vals.get('latitude') and vals.get('longtitude'):
+ rec._update_address_from_coords()
+ return records
@api.constrains('name')
def _check_duplicate_name(self):
@@ -521,4 +537,142 @@ class ResPartner(models.Model):
@api.onchange('name')
def _onchange_name(self):
if self.company_type == 'person':
- self.nama_wajib_pajak = self.name \ No newline at end of file
+ self.nama_wajib_pajak = self.name
+
+ def action_open_full_form(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Partner',
+ 'res_model': 'res.partner',
+ 'res_id': self.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def geocode_address(self):
+ for rec in self:
+ # Daftar field penting
+ required_fields = {
+ 'Alamat Jalan (street)': rec.street,
+ 'Kelurahan': rec.kelurahan_id.name if rec.kelurahan_id else '',
+ 'Kecamatan': rec.kecamatan_id.name if rec.kecamatan_id else '',
+ 'Kota': rec.kota_id.name if rec.kota_id else '',
+ 'Kode Pos': rec.zip,
+ 'Provinsi': rec.state_id.name if rec.state_id else '',
+ }
+
+ # Cek jika ada yang kosong
+ missing = [label for label, val in required_fields.items() if not val]
+ if missing:
+ raise UserError(
+ "Alamat tidak lengkap. Mohon lengkapi field berikut:\n- " + "\n- ".join(missing)
+ )
+
+ # Susun alamat lengkap
+ address = ', '.join([
+ required_fields['Alamat Jalan (street)'],
+ required_fields['Kelurahan'],
+ required_fields['Kecamatan'],
+ required_fields['Kota'],
+ required_fields['Kode Pos'],
+ required_fields['Provinsi'],
+ ])
+
+ # Ambil API Key
+ api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key')
+ if not api_key:
+ raise UserError("API Key Google Maps belum dikonfigurasi. Silakan isi melalui Settings.")
+
+ # Request ke Google Maps
+ url = f'https://maps.googleapis.com/maps/api/geocode/json?address={address}&key={api_key}'
+ response = requests.get(url)
+
+ if response.ok:
+ result = response.json()
+ if result.get('results'):
+ location = result['results'][0]['geometry']['location']
+ formatted_address = result['results'][0].get('formatted_address', '')
+
+ rec.latitude = location['lat']
+ rec.longtitude = location['lng']
+ rec.address_map = formatted_address # ✅ Simpan alamat lengkap
+ else:
+ raise UserError("Tidak ditemukan hasil geocode untuk alamat tersebut.")
+ else:
+ raise UserError("Permintaan ke Google Maps gagal. Periksa koneksi internet atau API Key.")
+
+ def _update_address_from_coords(self):
+ for rec in self:
+ if rec.latitude and rec.longtitude:
+ try:
+ components, formatted, parsed = rec._reverse_geocode(rec.latitude, rec.longtitude)
+ if not parsed:
+ continue
+
+ updates = {
+ 'street': parsed.get('road') or '',
+ 'zip': parsed.get('postcode') or '',
+ 'address_map': formatted or '',
+ }
+
+ state = self.env['res.country.state'].search([('name', 'ilike', parsed.get('state'))], limit=1)
+ if state:
+ updates['state_id'] = state.id
+
+ kota = self.env['vit.kota'].search([('name', 'ilike', parsed.get('city'))], limit=1)
+ if kota:
+ updates['kota_id'] = kota.id
+
+ kec = self.env['vit.kecamatan'].search([('name', 'ilike', parsed.get('district'))], limit=1)
+ if kec:
+ updates['kecamatan_id'] = kec.id
+
+ kel = self.env['vit.kelurahan'].search([('name', 'ilike', parsed.get('suburb'))], limit=1)
+ if kel:
+ updates['kelurahan_id'] = kel.id
+
+ rec.update(updates)
+
+ except Exception as e:
+ raise UserError(f"Gagal update alamat dari koordinat: {str(e)}")
+
+
+ def _reverse_geocode(self, lat, lng):
+ api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key')
+ if not api_key:
+ raise UserError("API Key Google Maps belum dikonfigurasi.")
+
+ url = f'https://maps.googleapis.com/maps/api/geocode/json?latlng={lat},{lng}&key={api_key}'
+ response = requests.get(url)
+ if response.ok:
+ result = response.json()
+ if result.get('results'):
+ components = result['results'][0]['address_components']
+ formatted = result['results'][0]['formatted_address']
+ return components, formatted, self._parse_google_address(components)
+ return {}, '', {}
+
+ def _parse_google_address(self, components):
+ def get(types):
+ for comp in components:
+ if types in comp['types']:
+ return comp['long_name']
+ return ''
+
+ street_number = get('street_number')
+ route = get('route')
+ neighborhood = get('neighborhood') # Bisa jadi nama RW
+ subpremise = get('subpremise') # Bisa jadi no kamar/ruko
+
+ # Gabungkan informasi jalan
+ road = " ".join(filter(None, [route, street_number, subpremise, neighborhood]))
+
+ return {
+ 'road': road.strip(),
+ 'postcode': get('postal_code'),
+ 'state': get('administrative_area_level_1'),
+ 'city': get('administrative_area_level_2') or get('locality'),
+ 'district': get('administrative_area_level_3'),
+ 'suburb': get('administrative_area_level_4'),
+ 'formatted': get('formatted_address'),
+ }
diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py
index baa8207f..1771f210 100755
--- a/indoteknik_custom/models/sale_order.py
+++ b/indoteknik_custom/models/sale_order.py
@@ -2,11 +2,13 @@ from re import search
from odoo import fields, models, api, _
from odoo.exceptions import UserError, ValidationError
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone, time
import logging, random, string, requests, math, json, re, qrcode, base64
import pytz
from io import BytesIO
from collections import defaultdict
+import pytz
+from lxml import etree
_logger = logging.getLogger(__name__)
@@ -65,6 +67,7 @@ class ShippingOption(models.Model):
price = fields.Float(string="Price", required=True)
provider = fields.Char(string="Provider")
etd = fields.Char(string="Estimated Delivery Time")
+ courier_service_code = fields.Char(string="Courier Service Code")
sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete="cascade")
@@ -72,6 +75,7 @@ class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def unlink(self):
+
lines_to_reject = []
for line in self:
if line.order_id:
@@ -324,8 +328,13 @@ class SaleOrder(models.Model):
string="Attachment Bukti Cancel", readonly=False,
)
nomor_so_pengganti = fields.Char(string='Nomor SO Pengganti', copy=False, tracking=3)
- shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option",
- domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]")
+
+ shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option", domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]")
+ select_shipping_option = fields.Selection([
+ ('biteship', 'Biteship'),
+ ('custom', 'Custom'),
+ ], string='Shipping Option', help="Select shipping option for delivery", tracking=True, default='custom')
+
hold_outgoing = fields.Boolean('Hold Outgoing SO', tracking=3)
state_ask_cancel = fields.Selection([
('hold', 'Hold'),
@@ -340,6 +349,292 @@ class SaleOrder(models.Model):
date_unhold = fields.Datetime(string='Date Unhold', tracking=True, readonly=True, help='Waktu ketika SO di Unhold'
)
+ @api.onchange('shipping_cost_covered')
+ def _onchange_shipping_cost_covered(self):
+ if self.shipping_cost_covered == 'indoteknik' and self.select_shipping_option == 'biteship':
+ self.shipping_cost_covered = 'customer'
+ return {
+ 'warning': {
+ 'title': "Biteship Tidak Diizinkan",
+ 'message': (
+ "Biaya pengiriman ditanggung Indoteknik, sehingga tidak diizinkan menggunakan metode Biteship. "
+ "Pilihan penanggung biaya akan dikembalikan sebelumnya"
+ )
+ }
+ }
+
+ def get_biteship_carrier_ids(self):
+ courier_codes = tuple(self._get_biteship_courier_codes() or [])
+ if not courier_codes:
+ return []
+
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id
+ FROM rajaongkir_kurir
+ WHERE name IN %s AND delivery_carrier_id IS NOT NULL
+ """, (courier_codes,))
+ result = self.env.cr.fetchall()
+ carrier_ids = [row[0] for row in result if row[0]]
+ return carrier_ids
+
+ @api.model
+ def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
+ res = super(SaleOrder, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
+
+ if view_type == 'form':
+ doc = etree.XML(res['arch'])
+
+ # Ambil semua delivery_carrier_id dari mapping rajaongkir_kurir
+ biteship_ids = self.env['rajaongkir.kurir'].search([]).mapped('delivery_carrier_id.id')
+ biteship_ids = list(set(filter(None, biteship_ids))) # pastikan unik dan bukan None
+
+ all_ids = self.env['delivery.carrier'].search([]).ids
+ custom_ids = list(set(all_ids) - set(biteship_ids))
+
+ # Format sebagai string Python list
+ biteship_ids_str = ','.join(str(i) for i in biteship_ids) or '-1'
+ custom_ids_str = ','.join(str(i) for i in custom_ids) or '-1'
+
+ # Terapkan domain ke field carrier_id
+ for node in doc.xpath("//field[@name='carrier_id']"):
+ # Domain tergantung select_shipping_option
+ node.set(
+ 'domain',
+ "[('id', 'in', [%s]) if select_shipping_option == 'biteship' else ('id', 'in', [%s])]" %
+ (biteship_ids_str, custom_ids_str)
+ )
+
+ # Simpan kembali hasil XML ke arsitektur form
+ res['arch'] = etree.tostring(doc, encoding='unicode')
+
+ return res
+
+ # @api.onchange('shipping_option_id')
+ # def _onchange_shipping_option_id(self):
+ # if self.shipping_option_id:
+ # self.delivery_amt = self.shipping_option_id.price
+ # self.delivery_service_type = self.shipping_option_id.courier_service_code
+
+ def _get_biteship_courier_codes(self):
+ return [
+ 'gojek','grab','deliveree','lalamove','jne','tiki','ninja','lion','rara','sicepat','jnt','pos','idexpress','rpx','wahana','jdl','pos','anteraja','sap','paxel','borzo'
+ ]
+
+ @api.onchange('carrier_id')
+ def _onchange_carrier_id(self):
+ if not self._origin or not self._origin.id:
+ return
+
+ sale_order_id = self._origin.id
+ self.shipping_option_id = False
+
+ if not self.carrier_id:
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # Ambil provider dari mapping
+ self.env.cr.execute("""
+ SELECT name FROM rajaongkir_kurir
+ WHERE delivery_carrier_id = %s LIMIT 1
+ """, (self.carrier_id.id,))
+ row = self.env.cr.fetchone()
+ provider = row[0].lower() if row and row[0] else (
+ self.carrier_id.name.lower().split()[0] if self.carrier_id.name else False
+ )
+
+ _logger.info(f"[Carrier Changed] {self.carrier_id.name}, Detected Provider: {provider}")
+
+ # ───────────────────────────────────────────────────────────────
+ # Validasi koordinat untuk kurir instan
+ # ───────────────────────────────────────────────────────────────
+ instan_kurir = ['gojek', 'grab', 'lalamove', 'borzo', 'rara', 'deliveree']
+ if provider in instan_kurir:
+ lat = self.real_shipping_id.latitude
+ lng = self.real_shipping_id.longtitude
+ def is_invalid(val):
+ try:
+ return not val or float(val) == 0.0
+ except (ValueError, TypeError):
+ return True
+
+ if is_invalid(lat) or is_invalid(lng):
+ self.carrier_id = self._origin.carrier_id
+ self.shipping_option_id = self._origin.shipping_option_id or False
+ return {
+ 'warning': {
+ 'title': "Alamat Belum Pin Point",
+ 'message': (
+ "Kurir instan seperti Gojek, Grab, Lalamove, Borzo, Rara, dan Deliveree "
+ "membutuhkan alamat pengiriman yang sudah Pin Point.\n\n"
+ "Silakan tentukan lokasi dengan tepat pada Pin Point Location yang tersedia di kontak."
+ )
+ },
+ 'domain': {'shipping_option_id': [('id', '=', -1)]}
+ }
+
+ # ───────────────────────────────────────────────────────────────
+ # Baru cek apakah shipping option sudah ada
+ # ───────────────────────────────────────────────────────────────
+ total_so_options = self.env['shipping.option'].search_count([
+ ('sale_order_id', '=', sale_order_id)
+ ])
+
+ if total_so_options == 0:
+ return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
+
+ # Validasi: apakah shipping option ada untuk provider ini?
+ matched = self.env['shipping.option'].search_count([
+ ('sale_order_id', '=', sale_order_id),
+ ('provider', 'ilike', provider),
+ ])
+ if matched == 0:
+ self.carrier_id = self._origin.carrier_id
+ self.shipping_option_id = self._origin.shipping_option_id or False
+ return {
+ 'warning': {
+ 'title': "Shipping Option Tidak Ditemukan",
+ 'message': (
+ "Layanan kurir ini tidak tersedia pada pengiriman ini. "
+ "Pilihan dikembalikan ke sebelumnya."
+ )
+ },
+ 'domain': {'shipping_option_id': [('id', '=', -1)]}
+ }
+
+ # Kalau semua valid, kembalikan domain normal
+ domain = [
+ '|',
+ '&', ('sale_order_id', '=', sale_order_id), ('provider', 'ilike', f'%{provider}%'),
+ '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
+ ]
+ return {'domain': {'shipping_option_id': domain}}
+
+ @api.onchange('shipping_option_id')
+ def _onchange_shipping_option_id(self):
+ if not self.shipping_option_id:
+ return
+
+ if not self.carrier_id:
+ # Jika belum pilih carrier, tetap update harga dan service type
+ self.delivery_amt = self.shipping_option_id.price
+ self.delivery_service_type = self.shipping_option_id.courier_service_code
+ return
+
+ # Ambil provider dari carrier
+ self.env.cr.execute("""
+ SELECT name FROM rajaongkir_kurir
+ WHERE delivery_carrier_id = %s LIMIT 1
+ """, (self.carrier_id.id,))
+ row = self.env.cr.fetchone()
+ provider = row[0].lower() if row and row[0] else self.carrier_id.name.lower().split()[0]
+
+ selected_provider = (self.shipping_option_id.provider or '').lower()
+
+ if provider not in selected_provider:
+ warning_msg = {
+ 'title': "Opsi Tidak Valid",
+ 'message': f"Opsi pengiriman '{self.shipping_option_id.name}' tidak cocok dengan metode '{self.carrier_id.name}'. Dikembalikan ke sebelumnya."
+ }
+
+ # Kembalikan ke nilai lama (jika record sudah disimpan)
+ self.shipping_option_id = self._origin.shipping_option_id if self._origin else False
+ return {'warning': warning_msg}
+
+ # Jika valid
+ self.delivery_amt = self.shipping_option_id.price
+ self.delivery_service_type = self.shipping_option_id.courier_service_code
+
+ def _update_delivery_service_type_from_shipping_option(self, vals):
+ shipping_option_id = vals.get('shipping_option_id') or self.shipping_option_id.id
+ if shipping_option_id:
+ shipping_option = self.env['shipping.option'].browse(shipping_option_id)
+ if shipping_option.exists():
+ courier_service = shipping_option.courier_service_code
+ vals['delivery_service_type'] = courier_service
+ _logger.info("🛰️ Set delivery_service_type: %s from shipping_option_id: %s", courier_service, shipping_option_id)
+ else:
+ _logger.warning("⚠️ shipping_option_id %s not found or invalid.", shipping_option_id)
+ else:
+ _logger.info("ℹ️ shipping_option_id not found in vals or record.")
+
+ # @api.model
+ # def fields_get(self, allfields=None, attributes=None):
+ # res = super().fields_get(allfields=allfields, attributes=attributes)
+
+ # # Aktifkan hanya kalau sedang buka form Sales Order (safety check)
+ # if self.env.context.get('params', {}).get('model') == 'sale.order' and \
+ # self.env.context.get('params', {}).get('id'):
+
+ # sale_id = self.env.context['params']['id']
+
+ # # Ambil carrier_id dari SO yang sedang dibuka
+ # self.env.cr.execute("SELECT carrier_id FROM sale_order WHERE id = %s", (sale_id,))
+ # row = self.env.cr.fetchone()
+ # carrier_id = row[0] if row else None
+
+ # provider = None
+ # if carrier_id:
+ # self.env.cr.execute("""
+ # SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1
+ # """, (carrier_id,))
+ # row = self.env.cr.fetchone()
+ # if row and row[0]:
+ # provider = row[0].lower()
+ # else:
+ # self.env.cr.execute("SELECT name FROM delivery_carrier WHERE id = %s", (carrier_id,))
+ # row = self.env.cr.fetchone()
+ # provider = row[0].lower().split()[0] if row and row[0] else ''
+
+ # if provider:
+ # domain = [
+ # '|',
+ # '&', ('sale_order_id', '=', sale_id), ('provider', 'ilike', f'%{provider}%'),
+ # '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
+ # ]
+
+ # if 'shipping_option_id' in res:
+ # res['shipping_option_id']['domain'] = domain
+ # _logger.info(f"fields_get - Injected domain for shipping_option_id: {domain}")
+ # return res
+
+
+ @api.onchange('select_shipping_option')
+ def _onchange_select_shipping_option(self):
+ if self.select_shipping_option == 'biteship' and self.shipping_cost_covered == 'indoteknik':
+ self.select_shipping_option = self._origin.select_shipping_option if self._origin else 'custom'
+ return {
+ 'warning': {
+ 'title': "Biteship Tidak Diizinkan",
+ 'message': (
+ "Biaya pengiriman ditanggung Indoteknik. Tidak diizinkan memilih metode Biteship. "
+ "Opsi pengiriman dikembalikan ke sebelumnya."
+ )
+ }
+ }
+
+ self.shipping_option_id = False
+ self.carrier_id = False
+ self.delivery_amt = 0
+
+ # Dapatkan semua ID carrier untuk Biteship
+ biteship_carrier_ids = []
+
+ # Gunakan SQL langsung untuk menghindari masalah ORM
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id
+ FROM rajaongkir_kurir
+ WHERE name IN %s
+ """, (tuple(self._get_biteship_courier_codes()),))
+
+ # Ambil ID numerik hasil query
+ biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]]
+
+ if self.select_shipping_option == 'biteship':
+ domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else []
+ else: # 'custom'
+ domain = [('id', 'not in', biteship_carrier_ids)] if biteship_carrier_ids else []
+
+ return {'domain': {'carrier_id': domain}}
+
# def _compute_total_margin_excl_third_party(self):
# for order in self:
# if order.amount_untaxed == 0:
@@ -415,12 +710,6 @@ class SaleOrder(models.Model):
# """, (rec.total_percent_margin, rec.id))
# self.invalidate_cache()
- @api.constrains('shipping_option_id')
- def _check_shipping_option(self):
- for rec in self:
- if rec.shipping_option_id:
- rec.delivery_amt = rec.shipping_option_id.price
-
def _compute_shipping_method_picking(self):
for order in self:
if order.picking_ids:
@@ -493,17 +782,86 @@ class SaleOrder(models.Model):
)
def action_estimate_shipping(self):
- if self.carrier_id.id in [1, 151]:
- self.action_indoteknik_estimate_shipping()
- return
+ # if self.carrier_id.id in [1, 151]:
+ # self.action_indoteknik_estimate_shipping()
+ # return
+
+ if self.select_shipping_option == 'biteship':
+ return self.action_estimate_shipping_biteship()
+ elif self.carrier_id.id in [1, 151]: # ID untuk Indoteknik Delivery
+ return self.action_indoteknik_estimate_shipping()
+ else:
+ total_weight = 0
+ missing_weight_products = []
+
+ for line in self.order_line:
+ if line.weight > 0:
+ total_weight += line.weight * line.product_uom_qty
+ line.product_id.weight = line.weight
+ else:
+ missing_weight_products.append(line.product_id.name)
+
+ if missing_weight_products:
+ product_names = '<br/>'.join(missing_weight_products)
+ self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}")
+
+ if total_weight == 0:
+ raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
+
+ destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id
+ if not destination_subsdistrict_id:
+ raise UserError("Gagal mendapatkan ID kota tujuan.")
+
+ result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id)
+ if result:
+ shipping_options = []
+ for courier in result['rajaongkir']['results']:
+ for cost_detail in courier['costs']:
+ service = cost_detail['service']
+ description = cost_detail['description']
+ etd = cost_detail['cost'][0]['etd']
+ value = cost_detail['cost'][0]['value']
+ shipping_options.append((service, description, etd, value, courier['code']))
+
+ self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
+
+ _logger.info(f"Shipping options: {shipping_options}")
+
+ for service, description, etd, value, provider in shipping_options:
+ self.env["shipping.option"].create({
+ "name": service,
+ "price": value,
+ "provider": provider,
+ "etd": etd,
+ "sale_order_id": self.id,
+ })
+
+
+ self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id
+
+ _logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
+
+ self.message_post(
+ body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>"
+ f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}",
+ message_type="comment"
+ )
+
+ # self.message_post(body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}", message_type="comment")
+ else:
+ raise UserError("Gagal mendapatkan estimasi ongkir.")
+
+ def _validate_for_shipping_estimate(self):
+ # Cek berat produk
total_weight = 0
missing_weight_products = []
for line in self.order_line:
- if line.weight > 0:
- total_weight += line.weight * line.product_uom_qty
- line.product_id.weight = line.weight
+ product_weight = line.product_id.weight or 0
+ if product_weight > 0:
+ total_weight += product_weight * line.product_uom_qty
+ line.weight = product_weight
else:
missing_weight_products.append(line.product_id.name)
@@ -512,50 +870,282 @@ class SaleOrder(models.Model):
self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}")
if total_weight == 0:
- raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
+ raise UserError("Tidak dapat mengestimasi ongkir tanpa karena berat produk = 0 kg.")
+
+ # Validasi alamat pengiriman
+ if not self.real_shipping_id:
+ raise UserError("Alamat pengiriman (Real Delivery Address) harus diisi.")
+
+ if not self.real_shipping_id.kota_id:
+ raise UserError("Kota pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.zip:
+ raise UserError("Kode pos pada alamat pengiriman harus diisi.")
+
+ if not self.real_shipping_id.state_id:
+ raise UserError("Provinsi pada alamat pengiriman harus diisi.")
+
+ return total_weight
+
+ def action_estimate_shipping_biteship(self):
+ total_weight = self._validate_for_shipping_estimate()
+
+ weight_gram = int(total_weight * 1000)
+ if weight_gram < 100:
+ weight_gram = 100
+
+ value = int(self.amount_untaxed or sum(line.price_subtotal for line in self.order_line))
+
+ items = [{
+ "name": "Paket Pesanan",
+ "description": f"Sale Order {self.name}",
+ "value": value,
+ "weight": weight_gram,
+ "quantity": 1,
+ }]
+
+ shipping_address = self.real_shipping_id
+ _logger.info(f"Shipping Address: {shipping_address}")
+
+ origin_data = {
+ "origin_latitude": -6.3031123,
+ "origin_longitude": 106.7794934,
+ }
+
+ destination_data = {}
+ use_coordinate = False
+
+ if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'):
+ if shipping_address.latitude and shipping_address.longtitude:
+ try:
+ lat = float(shipping_address.latitude)
+ lng = float(shipping_address.longtitude)
+ destination_data = {
+ "destination_latitude": lat,
+ "destination_longitude": lng
+ }
+ use_coordinate = True
+ _logger.info(f"Using coordinates: lat={lat}, lng={lng}")
+ except (ValueError, TypeError):
+ _logger.warning(f"Invalid coordinates, falling back to postal code")
+ use_coordinate = False
+
+ if not use_coordinate:
+ if shipping_address.zip:
+ origin_data = {"origin_postal_code": 14440}
+ destination_data = {
+ "destination_postal_code": shipping_address.zip
+ }
+ _logger.info(f"Using postal code: {shipping_address.zip}")
+ else:
+ raise UserError("Tidak dapat mengestimasikan ongkir: Kode pos tujuan tidak tersedia.")
- destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id
- if not destination_subsdistrict_id:
- raise UserError("Gagal mendapatkan ID kota tujuan.")
-
- result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id)
- if result:
- shipping_options = []
- for courier in result['rajaongkir']['results']:
- for cost_detail in courier['costs']:
- service = cost_detail['service']
- description = cost_detail['description']
- etd = cost_detail['cost'][0]['etd']
- value = cost_detail['cost'][0]['value']
- shipping_options.append((service, description, etd, value, courier['code']))
-
- self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
-
- _logger.info(f"Shipping options: {shipping_options}")
-
- for service, description, etd, value, provider in shipping_options:
- self.env["shipping.option"].create({
- "name": service,
- "price": value,
- "provider": provider,
+ couriers = ','.join(self._get_biteship_courier_codes())
+
+ api_mode = "koordinat" if use_coordinate else "kode_pos"
+ _logger.info(f"Calling Biteship API with mode: {api_mode}")
+
+ result = self._call_biteship_api(origin_data, destination_data, items, couriers)
+
+ if not result:
+ raise UserError("Gagal mendapatkan estimasi ongkir dari Biteship.")
+
+ self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
+
+ shipping_options = []
+ courier_options = {}
+ shipping_services = result.get('pricing', [])
+
+ _logger.info(f"Ditemukan {len(shipping_services)} layanan pengiriman")
+
+ for service in shipping_services:
+ courier_code = service.get('courier_code', '').lower()
+ courier_name = service.get('courier_name', '')
+ service_name = service.get('courier_service_name', '')
+ raw_price = service.get('price', 0)
+ markup_price = int(raw_price * 1.1)
+ price = round(markup_price / 1000) * 1000
+
+ _logger.info(f"Layanan: {courier_name} - {service_name}, Harga: {price}")
+
+ if not price:
+ _logger.warning(f"Melewati layanan dengan harga 0: {courier_name} - {service_name}")
+ continue
+
+ duration = service.get('duration', '')
+ shipment_range = service.get('shipment_duration_range', '')
+ shipment_unit = service.get('shipment_duration_unit', 'days')
+
+ if duration:
+ etd = duration
+ elif shipment_range:
+ etd = f"{shipment_range} {shipment_unit}"
+ else:
+ etd = "1-3 days"
+
+ try:
+ shipping_option = self.env["shipping.option"].create({
+ "name": f"{courier_name} - {service_name}",
+ "price": price,
+ "provider": courier_code,
"etd": etd,
+ "courier_service_code": service.get('courier_service_code'),
"sale_order_id": self.id,
})
- self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id
+ shipping_options.append(shipping_option)
+
+ courier_upper = courier_code.upper()
+ if courier_upper not in courier_options:
+ courier_options[courier_upper] = []
+ courier_options[courier_upper].append({
+ "name": service_name,
+ "etd": etd,
+ "price": price
+ })
+
+ _logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}")
+ except Exception as e:
+ _logger.error(f"Gagal membuat opsi pengiriman: {str(e)}")
- _logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
+ if not shipping_options:
+ raise UserError(f"Tidak ada layanan pengiriman ditemukan untuk kode pos {destination_data.get('destination_postal_code', '')}. Mohon periksa kembali kode pos atau gunakan metode pengiriman lain.")
- self.message_post(
- body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>"
- f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}",
- message_type="comment"
- )
+ # Temukan shipping option yang cocok berdasarkan carrier_id
+ selected_option = None
+
+ if self.carrier_id:
+ rajaongkir_kurir = self.env['rajaongkir.kurir'].search([
+ ('delivery_carrier_id', '=', self.carrier_id.id)
+ ], limit=1)
+
+ if rajaongkir_kurir:
+ courier_code = rajaongkir_kurir.name.lower()
+ carrier_name = self.carrier_id.name.lower()
+
+ possible_codes = list({
+ courier_code,
+ carrier_name,
+ carrier_name.split()[0] if ' ' in carrier_name else carrier_name
+ })
+
+ _logger.info(f"[MATCHING] Mencari shipping option untuk kurir: {possible_codes}")
+
+ for option in shipping_options:
+ option_provider = (option.provider or '').lower()
+ option_name = (option.name or '').lower()
- # self.message_post(body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}", message_type="comment")
+ if any(code in option_provider or code in option_name for code in possible_codes):
+ selected_option = option
+ _logger.info(f"[MATCHED] Shipping option cocok: {option.name}")
+ break
+
+ if not selected_option and shipping_options:
+ selected_option = shipping_options[0]
+ _logger.info(f"[DEFAULT] Tidak ada yang cocok, pakai opsi pertama: {selected_option.name}")
+ # ❗ Ganti carrier_id hanya jika BELUM terisi sama sekali (contoh: user dari backend)
+ if not self.carrier_id:
+ provider = selected_option.provider.lower()
+ self.env.cr.execute("""
+ SELECT delivery_carrier_id FROM rajaongkir_kurir
+ WHERE LOWER(name) = %s AND delivery_carrier_id IS NOT NULL
+ LIMIT 1
+ """, (provider,))
+ row = self.env.cr.fetchone()
+ matched_carrier_id = row[0] if row else False
+ if matched_carrier_id:
+ self.carrier_id = matched_carrier_id
+ _logger.info(f"[AUTO-SET] Carrier diisi otomatis ke ID {matched_carrier_id} (provider: {provider})")
+ else:
+ _logger.warning(f"[WARNING] Provider {provider} tidak ditemukan di rajaongkir_kurir")
+
+ # Set shipping option dan nilai ongkir
+ if selected_option:
+ self.shipping_option_id = selected_option.id
+ self.delivery_amt = selected_option.price
+ self.delivery_service_type = selected_option.courier_service_code
+ message_lines = [f"<b>Estimasi Ongkos Kirim Biteship:</b><br/>"]
+
+ for courier, options in courier_options.items():
+ message_lines.append(f"<b>{courier}:</b><br/>")
+ for opt in options:
+ message_lines.append(f"Service: {opt['name']}, ETD: {opt['etd']}, Cost: Rp {int(opt['price']):,}<br/>")
+ if courier != list(courier_options.keys())[-1]:
+ message_lines.append("<br/>")
+
+ origin_address = "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN"
+ destination_address = shipping_address.alamat_lengkap_text or shipping_address.street or shipping_address.name or ''
+ if use_coordinate:
+ origin_suffix = f"(Koordinat: {origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})"
+ destination_suffix = f"(Koordinat: {destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})"
else:
- raise UserError("Gagal mendapatkan estimasi ongkir.")
+ origin_suffix = f"(Kode Pos: {origin_data.get('origin_postal_code')})"
+ destination_suffix = f"(Kode Pos: {destination_data.get('destination_postal_code')})"
+
+ message_lines.append("<br/><br/><br><b>Info Lokasi:</b><br/>")
+ message_lines.append(f"<b>Asal</b>: {origin_address} {origin_suffix}<br/>")
+ message_lines.append(f"<b>Tujuan</b>: {destination_address} {destination_suffix}<br/>")
+
+ message_body = "".join(message_lines)
+
+ self.message_post(
+ body=message_body,
+ message_type="comment"
+ )
+
+ # Simpan informasi untuk note ekspedisi
+ # selected_option = shipping_options[0] # Opsi pertama dipilih sebagai default
+ # self.note_ekspedisi = f"Pengiriman: {selected_option.name} - Rp {selected_option.price:,.0f} ({selected_option.etd}) [via {api_mode}]"
+
+
+ def _call_biteship_api(self, origin_data, destination_data, items, couriers=None):
+
+ url = "https://api.biteship.com/v1/rates/couriers"
+ api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
+ # api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live')
+ # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test')
+ headers = {
+ 'Authorization': api_key,
+ 'Content-Type': 'application/json'
+ }
+
+ if not couriers:
+ couriers = ','.join(self._get_biteship_courier_codes())
+
+ # Persiapkan payload dengan menggabungkan origin, destination, dan items
+ payload = {
+ **origin_data,
+ **destination_data,
+ "couriers": couriers,
+ "items": items
+ }
+
+ api_mode = "koordinat" if "destination_latitude" in destination_data else "kode_pos"
+
+ try:
+ _logger.info(f"Calling Biteship API with mode: {api_mode}")
+ _logger.info(f"Payload: {payload}")
+
+ response = requests.post(url, headers=headers, json=payload, timeout=30)
+
+ _logger.info(f"Biteship API Status Code: {response.status_code}")
+ if response.status_code != 200:
+ _logger.error(f"Biteship API Error Response: {response.text}")
+
+ if response.status_code == 200:
+ result = response.json()
+ result['api_mode'] = api_mode # Tambahkan info mode API
+ return result
+ else:
+ error_msg = response.text
+ _logger.error(f"Error calling Biteship API: {response.status_code} - {error_msg}")
+ return False
+ except Exception as e:
+ _logger.error(f"Exception calling Biteship API: {str(e)}")
+ return False
+
def _call_rajaongkir_api(self, total_weight, destination_subsdistrict_id):
url = 'https://pro.rajaongkir.com/api/cost'
@@ -684,38 +1274,102 @@ class SaleOrder(models.Model):
rec.compute_fullfillment = True
- @api.depends('date_order', 'estimated_arrival_days', 'state', 'estimated_arrival_days_start')
+ @api.depends('expected_ready_to_ship', 'shipping_option_id.etd', 'state')
def _compute_eta_date(self):
- current_date = datetime.now().date()
for rec in self:
- if rec.date_order and rec.state not in [
- 'cancel'] and rec.estimated_arrival_days and rec.estimated_arrival_days_start:
- rec.eta_date = current_date + timedelta(days=rec.estimated_arrival_days)
- rec.eta_date_start = current_date + timedelta(days=rec.estimated_arrival_days_start)
+ if rec.expected_ready_to_ship and rec.shipping_option_id and rec.shipping_option_id.etd and rec.state not in ['cancel']:
+ etd_text = rec.shipping_option_id.etd.strip().lower()
+ match = re.match(r"(\d+)\s*-\s*(\d+)\s*(days?|hours?)", etd_text)
+ single_match = re.match(r"(\d+)\s*(days?|hours?)", etd_text)
+
+ if match:
+ start_val = int(match.group(1))
+ end_val = int(match.group(2))
+ unit = match.group(3)
+
+ if 'hour' in unit:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=start_val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=end_val)
+ else:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=start_val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(days=end_val)
+
+ elif single_match:
+ val = int(single_match.group(1))
+ unit = single_match.group(2)
+
+ if 'hour' in unit:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=val)
+ else:
+ rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=val)
+ rec.eta_date = rec.expected_ready_to_ship + timedelta(days=val)
+
+ else:
+ rec.eta_date_start = False
+ rec.eta_date = False
else:
- rec.eta_date = False
rec.eta_date_start = False
-
+ rec.eta_date = False
+
+
def get_days_until_next_business_day(self, start_date=None, *args, **kwargs):
- today = start_date or datetime.today().date()
- offset = 0 # Counter jumlah hari yang ditambahkan
- holiday = self.env['hr.public.holiday']
+ jakarta = pytz.timezone("Asia/Jakarta")
+ now = datetime.now(jakarta)
- while True:
- today += timedelta(days=1)
- offset += 1
+ if start_date is None:
+ start_date = now
- if today.weekday() >= 5:
- continue
+ if start_date.tzinfo is None:
+ start_date = jakarta.localize(start_date)
- is_holiday = holiday.search([("start_date", "=", today)])
- if is_holiday:
- continue
+ holiday = self.env['hr.public.holiday']
+ batas_waktu = datetime.strptime("15:00", "%H:%M").time()
+ current_day = start_date.date()
+ offset = 0
+ is3pm = False
+
+ # Step 1: Lewat jam 15 → Tambah 1 hari
+ if start_date.time() > batas_waktu:
+ is3pm = True
+ offset += 1
+
+ # Step 2: Hitung hari libur selama offset itu
+ i = 0
+ total_days = 0
+ while i < offset:
+ current_day += timedelta(days=1)
+ total_days += 1
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if not is_weekend and not is_holiday:
+ i += 1 # hanya hitung hari kerja
+
+ # Step 3: Tambah 1 hari masa persiapan gudang
+ i = 0
+ while i < 1:
+ current_day += timedelta(days=1)
+ total_days += 1
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if not is_weekend and not is_holiday:
+ i += 1
+
+ # Step 4: Kalau current_day ternyata weekend/libur, cari hari kerja berikutnya
+ while True:
+ is_weekend = current_day.weekday() >= 5
+ is_holiday = holiday.search([("start_date", "=", current_day)])
+ if is_weekend or is_holiday:
+ current_day += timedelta(days=1)
+ total_days += 1
+ else:
+ break
- break
+ offset = (current_day - start_date.date()).days
+ return offset, is3pm
- return offset
+
def calculate_sla_by_vendor(self, products):
product_ids = products.mapped('product_id.id') # Kumpulkan semua ID produk
include_instant = True # Default True, tetapi bisa menjadi False
@@ -724,7 +1378,7 @@ class SaleOrder(models.Model):
all_fast_products = all(
product.product_id.qty_free_bandengan >= product.product_uom_qty for product in products)
if all_fast_products:
- return {'slatime': 1, 'include_instant': include_instant}
+ return {'slatime': 0, 'include_instant': include_instant}
# Cari semua vendor pemenang untuk produk yang diberikan
vendors = self.env['purchase.pricelist'].search([
@@ -758,48 +1412,109 @@ class SaleOrder(models.Model):
if not rec.date_order:
rec.expected_ready_to_ship = False
return
-
- current_date = datetime.now().date()
-
+
+ jakarta = pytz.timezone("Asia/Jakarta")
+ current_date = datetime.now(jakarta)
+
max_slatime = 1 # Default SLA jika tidak ada
slatime = self.calculate_sla_by_vendor(rec.order_line)
max_slatime = max(max_slatime, slatime['slatime'])
-
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
+
+ offset , is3pm = self.get_days_until_next_business_day(current_date)
+ sum_days = max_slatime + offset
+ sum_days -= 1
if not rec.estimated_arrival_days:
rec.estimated_arrival_days = sum_days
eta_date = current_date + timedelta(days=sum_days)
+ if is3pm:
+ eta_date = datetime.combine(eta_date, time(10, 0)) # jam 10:00
+ eta_date = jakarta.localize(eta_date).astimezone(timezone.utc) # ubah ke UTC
+
+
+ eta_date = eta_date.astimezone(timezone.utc).replace(tzinfo=None)
rec.commitment_date = eta_date
rec.expected_ready_to_ship = eta_date
@api.depends("order_line.product_id", "date_order")
def _compute_etrts_date(self): # Function to calculate Estimated Ready To Ship Date
self._calculate_etrts_date()
+
+
+ # def _validate_expected_ready_ship_date(self):
+ # for rec in self:
+ # if not rec.order_line:
+ # _logger.info("⏩ Lewati validasi ERTS karena belum ada produk.")
+ # return # Lewati validasi jika belum ada produk
+
+ # now = fields.Datetime.now()
+ # expected_date = rec.expected_ready_to_ship and rec.expected_ready_to_ship.date() or None
+ # if not expected_date:
+ # return # Tidak validasi jika tidak ada input sama sekali
+
+ # sla = rec.calculate_sla_by_vendor()
+ # offset_day, lewat_jam_3 = rec.get_days_until_next_business_day()
+ # eta_minimum = now + timedelta(days=sla + offset_day)
+
+ # if expected_date < eta_minimum.date():
+ # rec.expected_ready_to_ship = eta_minimum
+ # raise ValidationError(
+ # "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
+ # .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
+ # )
def _validate_expected_ready_ship_date(self):
+ """
+ Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum.
+ Dipanggil setiap onchange / simpan SO.
+ """
for rec in self:
- if rec.expected_ready_to_ship and rec.commitment_date:
- current_date = datetime.now().date()
- # Hanya membandingkan tanggal saja, tanpa jam
- expected_date = rec.expected_ready_to_ship.date()
-
- max_slatime = 1 # Default SLA jika tidak ada
- slatime = self.calculate_sla_by_vendor(rec.order_line)
- max_slatime = max(max_slatime, slatime['slatime'])
- sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1
- eta_minimum = current_date + timedelta(days=sum_days)
-
- if expected_date < eta_minimum:
- rec.expected_ready_to_ship = eta_minimum
- raise ValidationError(
- "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
- .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
- )
- else:
- rec.commitment_date = rec.expected_ready_to_ship
+ # ─────────────────────────────────────────────────────
+ # 1. Hanya validasi kalau field sudah terisi
+ # (quotation baru / belum ada tanggal → abaikan)
+ # ─────────────────────────────────────────────────────
+ if not rec.expected_ready_to_ship:
+ continue
- @api.onchange('expected_ready_to_ship') # Hangle Onchange form Expected Ready to Ship
+ current_date = datetime.now()
+
+ # ─────────────────────────────────────────────────────
+ # 2. Hitung SLA berdasarkan product lines (jika ada)
+ # ─────────────────────────────────────────────────────
+ products = rec.order_line
+ if products:
+ sla_data = rec.calculate_sla_by_vendor(products)
+ max_sla_time = sla_data.get('slatime', 1)
+ else:
+ # belum ada item → gunakan default 1 hari
+ max_sla_time = 1
+
+ # offset hari libur / weekend
+ offset, is3pm = rec.get_days_until_next_business_day(current_date)
+ min_days = max_sla_time + offset - 1
+ eta_minimum = current_date + timedelta(days=min_days)
+
+ # ─────────────────────────────────────────────────────
+ # 3. Validasi - raise error bila terlalu cepat
+ # ─────────────────────────────────────────────────────
+ if rec.expected_ready_to_ship.date() < eta_minimum.date():
+ # set otomatis ke tanggal minimum supaya user tidak perlu
+ # menekan Save dua kali
+ rec.expected_ready_to_ship = eta_minimum
+
+ raise ValidationError(
+ _("Tanggal 'Expected Ready to Ship' tidak boleh "
+ "lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.")
+ % {'tgl': eta_minimum.strftime('%d-%m-%Y')}
+ )
+ else:
+ # sinkronkan ke field commitment_date
+ rec.commitment_date = rec.expected_ready_to_ship
+
+
+
+
+ @api.onchange('expected_ready_to_ship') #Hangle Onchange form Expected Ready to Ship
def _onchange_expected_ready_ship_date(self):
self._validate_expected_ready_ship_date()
@@ -2032,10 +2747,79 @@ class SaleOrder(models.Model):
order_line.discount = discount
order_line.order_id.use_button = True
+ def _auto_set_shipping_from_website(self):
+ if not self.env.context.get('from_website_checkout'):
+ return
+
+ for order in self:
+ # Validasi source website
+ if not order.source_id or order.source_id.id != 59:
+ continue
+
+ # Skip jika Self Pick Up
+ if int(order.carrier_id.id or 0) == 32:
+ _logger.info(f"[Checkout] Skip estimasi: Self Pickup untuk SO {order.name}")
+ order.select_shipping_option = 'custom'
+ continue
+
+ # Simpan pilihan user sebelum estimasi
+ user_carrier_id = order.carrier_id.id if order.carrier_id else None
+ user_service = order.delivery_service_type
+ user_amount = order.delivery_amt
+
+ # Jalankan estimasi untuk refresh data
+ order.select_shipping_option = 'biteship'
+ order.action_estimate_shipping()
+
+ # Restore pilihan user setelah estimasi
+ if user_carrier_id and user_service:
+ # Dapatkan provider
+ self.env.cr.execute("SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1", (user_carrier_id,))
+ result = self.env.cr.fetchone()
+ provider = result[0].lower() if result else order.env['delivery.carrier'].browse(user_carrier_id).name.lower().split()[0]
+
+ # Cari opsi yang cocok (prioritas: service code > nama > harga > fallback)
+ domain_options = [
+ [('courier_service_code', '=', user_service), ('provider', 'ilike', provider)], # exact service
+ [('name', 'ilike', user_service), ('provider', 'ilike', provider)], # nama service
+ [('price', '=', user_amount), ('provider', 'ilike', provider)] if user_amount > 0 else None, # harga sama
+ [('provider', 'ilike', provider)] # fallback
+ ]
+
+ matched_option = None
+ for domain in domain_options:
+ if domain:
+ matched_option = self.env['shipping.option'].search([('sale_order_id', '=', order.id)] + domain, limit=1)
+ if matched_option:
+ break
+
+ # Set opsi yang cocok atau buat manual
+ if matched_option:
+ order.shipping_option_id = matched_option.id
+ order.delivery_amt = matched_option.price
+ order.delivery_service_type = matched_option.courier_service_code
+
+ # Notif jika harga berubah
+ if user_amount > 0 and abs(matched_option.price - user_amount) > 1000:
+ order.message_post(body=f"Harga shipping berubah dari Rp {user_amount:,} ke Rp {matched_option.price:,}")
+
+ elif user_amount > 0:
+ # Buat opsi manual jika tidak ada yang cocok
+ manual_option = self.env['shipping.option'].create({
+ 'name': f"{provider.upper()} - {user_service}",
+ 'price': user_amount,
+ 'provider': provider,
+ 'courier_service_code': user_service,
+ 'sale_order_id': order.id,
+ })
+ order.shipping_option_id = manual_option.id
+
@api.model
def create(self, vals):
# Ensure partner details are updated when a sale order is created
order = super(SaleOrder, self).create(vals)
+ # _logger.info(f"[CREATE CONTEXT] {self.env.context}")
+ # order._auto_set_shipping_from_website()
order._compute_etrts_date()
order._validate_expected_ready_ship_date()
# order._validate_delivery_amt()
@@ -2153,6 +2937,8 @@ class SaleOrder(models.Model):
raise UserError(
"SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.")
+ order._update_delivery_service_type_from_shipping_option(vals)
+
if 'carrier_id' in vals:
for order in self:
for picking in order.picking_ids:
diff --git a/indoteknik_custom/models/sale_order_delay.py b/indoteknik_custom/models/sale_order_delay.py
new file mode 100644
index 00000000..dfd94650
--- /dev/null
+++ b/indoteknik_custom/models/sale_order_delay.py
@@ -0,0 +1,31 @@
+from odoo import api, fields, models
+
+
+class SaleOrderDelay(models.Model):
+ _name = 'sale.order.delay'
+ _description = 'Sale Order Delay'
+ _primary_key = 'so_number'
+
+ so_number = fields.Char(string="SO Number", required=True)
+ days_delayed = fields.Integer(string="Day Delayed or Erly")
+ status = fields.Selection([
+ ('delayed', 'Delayed'),
+ ('on track', 'On Track'),
+ ('early', 'Early')
+ ], string='Status', required=True)
+
+ _sql_constraints = [
+ ('unique_so_number', 'unique(so_number)', 'SO Number must be unique!')
+ ]
+
+ def update_delay(self):
+ query = "SELECT check_so_delay();"
+ self.env.cr.execute(query)
+
+ @api.model
+ def create(self, vals):
+ return super(SaleOrderDelay, self).create(vals)
+
+ def write(self, vals):
+ return super(SaleOrderDelay, self).write(vals)
+ \ No newline at end of file
diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py
index 622c537e..eabef37c 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -19,11 +19,9 @@ import re
_logger = logging.getLogger(__name__)
_biteship_url = "https://api.biteship.com/v1"
-_biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
-
-
-# _biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
-
+biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
+# biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
+
class StockPicking(models.Model):
_inherit = 'stock.picking'
@@ -170,6 +168,10 @@ class StockPicking(models.Model):
area_name = fields.Char(string="Area", compute="_compute_area_name")
+ # def _get_biteship_api_key(self):
+ # # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test')
+ # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live')
+
@api.depends('real_shipping_id.kecamatan_id', 'real_shipping_id.kota_id')
def _compute_area_name(self):
for record in self:
@@ -271,14 +273,14 @@ class StockPicking(models.Model):
biteship_id = fields.Char(string="Biteship Respon ID")
biteship_tracking_id = fields.Char(string="Biteship Trackcking ID")
biteship_waybill_id = fields.Char(string="Biteship Waybill ID")
- # estimated_ready_ship_date = fields.Datetime(string='ET Ready to Ship', copy=False, related='sale_id.estimated_ready_ship_date')
- # countdown_hours = fields.Float(string='Countdown in Hours', compute='_callculate_sequance', default=False, store=False, compute_sudo=False)
- # countdown_ready_to_ship = fields.Char(string='Countdown Ready to Ship', compute='_callculate_sequance', store=False, compute_sudo=False)
final_seq = fields.Float(string='Remaining Time')
- shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO',
- related='sale_id.carrier_id')
- state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')],
- string='Packing Status')
+ shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO', related='sale_id.carrier_id')
+ shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option SO', related='sale_id.shipping_option_id')
+ select_shipping_option_so = fields.Selection([
+ ('biteship', 'Biteship'),
+ ('custom', 'Custom'),
+ ], string='Shipping Type SO', related='sale_id.select_shipping_option')
+ state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')], string='Packing Status')
approval_invoice_date_id = fields.Many2one('approval.invoice.date', string='Approval Invoice Date')
last_update_date_doc_kirim = fields.Datetime(string='Last Update Tanggal Kirim', copy=False)
update_date_doc_kirim_add = fields.Boolean(string='Update Tanggal Kirim Lewat ADD')
@@ -682,46 +684,55 @@ class StockPicking(models.Model):
raise UserError(f"Kesalahan tidak terduga: {str(e)}")
def action_send_to_biteship(self):
-
if self.biteship_tracking_id:
raise UserError(f"Order ini sudah dikirim ke Biteship. Dengan Tracking Id: {self.biteship_tracking_id}")
- # Mencari data sale.order.line berdasarkan sale_id
- products = self.env['sale.order.line'].search([('order_id', '=', self.sale_id.id)])
-
- # Fungsi untuk membangun items_data dari order lines
- def build_items_data(lines):
- return [{
- "name": line.product_id.name,
- "description": line.name,
- "value": line.price_unit,
- "quantity": line.product_uom_qty,
- "weight": line.weight
- } for line in lines]
-
- # Items untuk pengiriman standard
- items_data_standard = build_items_data(products)
+ if self.sale_id.select_shipping_option == 'custom':
+ raise UserError("Shipping Option pada Sales Order ini adalah *Custom*. Tidak dapat dikirim melalui Biteship.")
+
+ def is_courier_need_coordinates(service_code):
+ return service_code in [
+ "instant", "same_day", "instant_car",
+ "instant_bike", "motorcycle", "mpv", "van", "truck",
+ "cdd_bak", "cdd_box", "engkel_box", "engkel_bak"
+ ]
+
+ # ✅ Ambil item dari move_line_ids_with_package (qty_done > 0)
+ items = []
+ for ml in self.move_line_ids_without_package:
+ if ml.qty_done <= 0:
+ continue
- # Items untuk pengiriman instant, mengambil product_id dari move_line_ids_without_package
- items_data_instant = []
- for move_line in self.move_line_ids_without_package:
- # Mencari baris di sale.order.line berdasarkan product_id dari move_line
- order_line = self.env['sale.order.line'].search([
+ product = ml.product_id
+ weight = product.weight or 0.1 # default minimal
+ line = ml.move_id.sale_line_id or self.env['sale.order.line'].search([
('order_id', '=', self.sale_id.id),
- ('product_id', '=', move_line.product_id.id)
+ ('product_id', '=', ml.product_id.id)
], limit=1)
- if order_line:
- items_data_instant.append({
- "name": order_line.product_id.name,
- "description": order_line.name,
- "value": order_line.price_unit,
- "quantity": move_line.qty_done,
- "weight": order_line.weight
- })
+ value = line.price_unit if line else 0
+ description = line.name if line else product.name
+
+ items.append({
+ "name": product.name,
+ "description": description,
+ "value": value,
+ "quantity": ml.qty_done,
+ "weight": int(weight * 1000),
+ })
+
+ if not items:
+ raise UserError("Pengiriman tidak dapat dilakukan karena tidak ada barang yang divalidasi (qty_done = 0).")
+
+ shipping_partner = self.real_shipping_id
+ courier_service_code = self.sale_id.delivery_service_type or "reg"
payload = {
- "reference_id ": self.sale_id.name,
+ "origin_coordinate": {
+ "latitude": -6.3031123,
+ "longitude": 106.7794934999
+ },
+ "reference_id": self.name,
"shipper_contact_name": self.carrier_id.pic_name or '',
"shipper_contact_phone": self.carrier_id.pic_phone or '',
"shipper_organization": self.carrier_id.name,
@@ -729,41 +740,39 @@ class StockPicking(models.Model):
"origin_contact_phone": "081717181922",
"origin_address": "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN",
"origin_postal_code": 14440,
- "destination_contact_name": self.real_shipping_id.name,
- "destination_contact_phone": self.real_shipping_id.phone or self.real_shipping_id.mobile,
- "destination_address": self.real_shipping_id.street,
- "destination_postal_code": self.real_shipping_id.zip,
+ "destination_contact_name": shipping_partner.name,
+ "destination_contact_phone": shipping_partner.phone or shipping_partner.mobile,
+ "destination_address": shipping_partner.street,
+ "destination_postal_code": shipping_partner.zip,
"origin_note": "BELAKANG INDOMARET",
- "courier_type": self.sale_id.delivery_service_type or "reg",
+ "destination_note": f"SO: {self.sale_id.name}",
+ "order_note": f"SO: {self.sale_id.name}",
+ "courier_type": courier_service_code,
"courier_company": self.carrier_id.name.lower(),
"delivery_type": "now",
- "destination_postal_code": self.real_shipping_id.zip,
- "items": items_data_standard
+ "items": items
}
- # Cek jika pengiriman instant atau same_day
- if self.sale_id.delivery_service_type and (
- "instant" in self.sale_id.delivery_service_type or "same_day" in self.sale_id.delivery_service_type):
- payload.update({
- "origin_coordinate": {
- "latitude": -6.3031123,
- "longitude": 106.7794934999
- },
- "destination_coordinate": {
- "latitude": self.real_shipping_id.latitude,
- "longitude": self.real_shipping_id.longtitude,
- },
- "items": items_data_instant
- })
+ if is_courier_need_coordinates(courier_service_code):
+ if not shipping_partner.latitude or not shipping_partner.longtitude:
+ raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).")
- api_key = _biteship_api_key
+ payload["destination_coordinate"] = {
+ "latitude": shipping_partner.latitude,
+ "longitude": shipping_partner.longtitude,
+ }
+
+ _logger.info(f"Payload untuk Biteship: {payload}")
+
+ # Kirim ke Biteship
+ api_key = biteship_api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
- # Kirim request ke Biteship
response = requests.post(_biteship_url + '/orders', headers=headers, json=payload)
+ _logger.info(f"Response dari Biteship: {response.text}")
if response.status_code == 200:
data = response.json()
@@ -771,17 +780,27 @@ class StockPicking(models.Model):
self.biteship_id = data.get("id", "")
self.biteship_tracking_id = data.get("courier", {}).get("tracking_id", "")
self.biteship_waybill_id = data.get("courier", {}).get("waybill_id", "")
- self.delivery_tracking_no = data.get("courier", {}).get("waybill_id", "")
-
- waybill_id = data.get("courier", {}).get("waybill_id", "")
+ self.delivery_tracking_no = self.biteship_waybill_id
+
+ waybill_id = self.biteship_waybill_id
+
+ self.message_post(
+ body=f"Biteship berhasil dilakukan.<br/>"
+ f"Kurir: {self.carrier_id.name}<br/>"
+ f"Tracking ID: {self.biteship_tracking_id or '-'}<br/>"
+ f"Resi: {waybill_id or '-'}<br/>"
+ f"Reference: {self.name}<br/>"
+ f"SO: {self.sale_id.name}",
+ message_type="comment"
+ )
message = f"✅ Berhasil Order ke Biteship! Resi: {waybill_id}" if waybill_id else "⚠️ Order berhasil, tetapi tidak ada nomor resi."
return {
'effect': {
- 'fadeout': 'slow', # Efek menghilang perlahan
- 'message': message, # Pesan sukses
- 'type': 'rainbow_man', # Efek animasi lucu Odoo
+ 'fadeout': 'slow',
+ 'message': message,
+ 'type': 'rainbow_man',
}
}
else:
@@ -1346,7 +1365,7 @@ class StockPicking(models.Model):
picking_date = fields.Date.to_date(picking.date_doc_kirim)
invoice_date = fields.Date.to_date(invoice.invoice_date)
- if picking_date != invoice_date and picking.update_date_doc_kirim_add:
+ if picking_date != invoice_date and picking.update_date_doc_kirim_add and not picking.so_lama:
raise UserError("Tanggal Kirim (%s) tidak sesuai dengan Tanggal Invoice (%s)!" % (
picking_date.strftime('%d-%m-%Y'),
invoice_date.strftime('%d-%m-%Y')
@@ -1623,28 +1642,51 @@ class StockPicking(models.Model):
self.ensure_one()
order = self.env['sale.order'].search([('name', '=', self.sale_id.name)], limit=1)
+
+ sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', order.name)], limit=1)
+
+ product_shipped = []
+ for move_line in self.move_line_ids_without_package:
+ if move_line.qty_done > 0:
+ product_shipped.append({
+ 'name': move_line.product_id.name,
+ 'qty': move_line.qty_done
+ })
response = {
'delivery_order': {
'name': self.name,
- 'carrier': self.carrier_id.name or '',
- 'service': order.delivery_service_type or '',
+ 'carrier': self.carrier_id.name or '-',
+ 'service' : order.delivery_service_type or '-',
'receiver_name': '',
'receiver_city': ''
},
+ 'delivered_date': self.driver_departure_date.strftime('%d %b %Y') if self.driver_departure_date != False else '-',
'delivered': False,
'status': self.shipping_status,
- 'waybill_number': self.delivery_tracking_no or '',
+ 'waybill_number': self.delivery_tracking_no or '-',
'delivery_status': None,
'eta': self.generate_eta_delivery(),
'is_biteship': True if self.biteship_id else False,
- 'manifests': self.get_manifests()
+ 'manifests': self.get_manifests(),
+ 'is_delay': True if sale_order_delay and sale_order_delay.status == 'delayed' else False,
+ 'products': product_shipped
}
if self.biteship_id:
histori = self.get_manifest_biteship()
- eta_start = order.date_order + timedelta(days=order.estimated_arrival_days_start)
- eta_end = order.date_order + timedelta(days=order.estimated_arrival_days)
+ day_start = order.estimated_arrival_days_start
+ day_end = order.estimated_arrival_days
+ if sale_order_delay:
+ if sale_order_delay.status == 'delayed':
+ day_start = day_start + sale_order_delay.days_delayed
+ day_end = day_end + sale_order_delay.days_delayed
+ elif sale_order_delay.status == 'early':
+ day_start = day_start - sale_order_delay.days_delayed
+ day_end = day_end - sale_order_delay.days_delayed
+
+ eta_start = order.date_order + timedelta(days=day_start)
+ eta_end = order.date_order + timedelta(days=day_end)
formatted_eta = f"{eta_start.strftime('%d %b')} - {eta_end.strftime('%d %b %Y')}"
response['eta'] = formatted_eta
response['manifests'] = histori.get("manifests", [])
@@ -1666,12 +1708,12 @@ class StockPicking(models.Model):
return response
def get_manifest_biteship(self):
- api_key = _biteship_api_key
+ api_key = biteship_api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
-
+
manifests = []
try:
@@ -1680,14 +1722,13 @@ class StockPicking(models.Model):
json=manifests)
result = response.json()
description = {
- 'confirmed': 'Indoteknik telah melakukan permintaan pick-up',
- 'allocated': 'Kurir akan melakukan pick-up pesanan',
- 'picking_up': 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
- 'picked': 'Pesanan sudah di pick-up kurir ' + result.get("courier", {}).get("name", ""),
- 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman',
- 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
- 'delivered': 'Pesanan telah sampai dan diterima oleh ' + result.get("destination", {}).get(
- "contact_name", "")
+ 'confirmed' : 'Indoteknik telah melakukan permintaan pick-up',
+ 'allocated' : 'Kurir akan melakukan pick-up pesanan',
+ 'picking_up' : 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
+ 'picked' : 'Pesanan sudah di pick-up kurir '+result.get("courier", {}).get("company", ""),
+ 'on_hold' : 'Pesanan ditahan sementara karena masalah pengiriman',
+ 'dropping_off' : 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
+ 'delivered' : f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{result.get("destination", {}).get("contact_name", "")}</span>'
}
if (result.get('success') == True):
history = result.get("history", [])
@@ -1705,19 +1746,99 @@ class StockPicking(models.Model):
"delivered": status
}
- return manifests
- except Exception as e:
+ return {
+ "manifests": [],
+ "delivered": False
+ }
+ except Exception as e :
_logger.error(f"Error fetching Biteship order for picking {self.id}: {str(e)}")
- return {'error': str(e)}
+ return { 'error': str(e) }
- def _convert_to_local_time(self, iso_date):
+ def action_sync_biteship_tracking(self):
+ for picking in self:
+ if not picking.biteship_id:
+ raise UserError("Tracking Biteship tidak tersedia.")
+
+ histori = picking.get_manifest_biteship()
+ updated_fields = {}
+ seen_logs = set()
+
+ manifests = sorted(histori.get("manifests", []), key=lambda m: m.get("datetime") or "")
+
+ for manifest in manifests:
+ status = manifest.get("status", "").lower()
+ dt_str = manifest.get("datetime")
+ desc = manifest.get("description")
+ dt = False
+
+ try:
+ dt = picking._convert_to_utc_datetime(dt_str)
+ _logger.info(f"[Biteship Sync] Berhasil parse datetime: {dt_str} -> {dt}")
+ except Exception as e:
+ _logger.warning(f"[Biteship Sync] Gagal parse datetime: {e}")
+ continue
+
+ # Update tanggal ke field (pastikan naive datetime UTC)
+ if status == "picked" and dt and not picking.driver_departure_date:
+ updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+
+ if status == "delivered" and dt and not picking.driver_arrival_date:
+ updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+
+ # Buat log unik dengan waktu lokal Asia/Jakarta
+ if dt and desc:
+ try:
+ dt_local = parser.parse(dt_str).replace(tzinfo=None)
+ except Exception as e:
+ _logger.warning(f"[Biteship Sync] Gagal parse dt_str untuk log: {e}")
+ dt_local = dt # fallback
+
+ desc_clean = ' '.join(desc.strip().split())
+ log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}: {desc_clean}"
+ if not picking._has_existing_log(log_line):
+ picking.message_post(body=log_line)
+ seen_logs.add(log_line)
+
+ if updated_fields:
+ picking.write(updated_fields)
+
+ def _has_existing_log(self, log_line):
+ self.ensure_one()
+ self.env.cr.execute("""
+ SELECT 1 FROM mail_message
+ WHERE model = %s AND res_id = %s
+ AND subtype_id IS NOT NULL
+ AND body ILIKE %s
+ LIMIT 1
+ """, (self._name, self.id, f"%{log_line}%"))
+ return self.env.cr.fetchone() is not None
+
+ # Untuk internal Odoo (mengembalikan naive UTC datetime untuk disimpan ke DB)
+ def _convert_to_utc_datetime(self, iso_date):
try:
- dt_with_tz = waktu.fromisoformat(iso_date)
- utc_dt = dt_with_tz.astimezone(pytz.utc)
+ if isinstance(iso_date, str):
+ waktu = parser.parse(iso_date)
+ else:
+ waktu = iso_date
+ if waktu.tzinfo is None:
+ waktu = waktu.replace(tzinfo=pytz.utc)
+ utc_dt = waktu.astimezone(pytz.utc).replace(tzinfo=None)
+ return utc_dt
+ except Exception as e:
+ _logger.warning(f"[Biteship] Gagal konversi waktu UTC: {e}")
+ return False
+ # Untuk tampilan di API atau kebutuhan web (mengembalikan string waktu lokal)
+ def _convert_to_local_time(self, iso_date):
+ try:
+ if isinstance(iso_date, str):
+ waktu = parser.parse(iso_date)
+ else:
+ waktu = iso_date
+ if waktu.tzinfo is None:
+ waktu = waktu.replace(tzinfo=pytz.utc)
local_tz = pytz.timezone("Asia/Jakarta")
- local_dt = utc_dt.astimezone(local_tz)
-
+ local_dt = waktu.astimezone(local_tz)
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
return str(e)
@@ -1738,20 +1859,26 @@ class StockPicking(models.Model):
def generate_eta_delivery(self):
current_date = datetime.datetime.now()
- prepare_days = 3
- start_date = self.driver_departure_date or self.create_date
-
- ead = self.sale_id.estimated_arrival_days or 0
- if not self.driver_departure_date:
- ead += prepare_days
-
- ead_datetime = datetime.timedelta(days=ead)
- fastest_eta = start_date + ead_datetime
- if not self.driver_departure_date and fastest_eta < current_date:
- fastest_eta = current_date + ead_datetime
-
- longest_days = 3
- longest_eta = fastest_eta + datetime.timedelta(days=longest_days)
+ days_start = self.sale_id.estimated_arrival_days_start or self.sale_id.estimated_arrival_days
+ days_end = self.sale_id.estimated_arrival_days or (self.sale_id.estimated_arrival_days + 3)
+ start_date = self.sale_id.create_date + datetime.timedelta(days=days_start)
+ end_date = self.sale_id.create_date + datetime.timedelta(days=days_end)
+
+
+ add_day_start = 0
+ add_day_end = 0
+ sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', self.sale_id.name)], limit=1)
+ if sale_order_delay:
+ if sale_order_delay.status == 'delayed':
+ add_day_start = sale_order_delay.days_delayed
+ add_day_end = sale_order_delay.days_delayed
+ elif sale_order_delay.status == 'early':
+ add_day_start = -abs(sale_order_delay.days_delayed)
+ add_day_end = -abs(sale_order_delay.days_delayed)
+
+ fastest_eta = start_date +datetime.timedelta(days=add_day_start + add_day_start)
+
+ longest_eta = end_date + datetime.timedelta(days=add_day_end)
format_time = '%d %b %Y'
format_time_fastest = '%d %b' if fastest_eta.year == longest_eta.year else format_time
diff --git a/indoteknik_custom/patch.py b/indoteknik_custom/patch.py
new file mode 100644
index 00000000..704ab056
--- /dev/null
+++ b/indoteknik_custom/patch.py
@@ -0,0 +1,16 @@
+import json, logging
+from odoo.http import JsonRequest
+
+_logger = logging.getLogger(__name__)
+
+def _safe_jsonloads(self, raw):
+ """Kembalikan dict kosong bila body kosong / JSON rusak"""
+ try:
+ return json.loads(raw) if raw else {}
+ except Exception as e:
+ _logger.warning("Bypassed invalid JSON body: %s", e)
+ return {}
+
+# Odoo 14 memakai _jsonloads
+JsonRequest._jsonloads = _safe_jsonloads
+_logger.info("Patch OK → JsonRequest._jsonloads dilindungi (empty JSON diterima)")
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index 45ce23dd..63e7b53a 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -178,6 +178,7 @@ access_stock_inventory,access.stock.inventory,model_stock_inventory,,1,1,1,1
access_cancel_reason_order,cancel.reason.order,model_cancel_reason_order,,1,1,1,0
access_reject_reason_commision,reject.reason.commision,model_reject_reason_commision,,1,1,1,0
access_shipping_option,shipping.option,model_shipping_option,,1,1,1,1
+access_sale_order_delay,sale.order.delay,model_sale_order_delay,,1,1,1,1
access_production_purchase_match,access.production.purchase.match,model_production_purchase_match,,1,1,1,1
access_image_carousel,access.image.carousel,model_image_carousel,,1,1,1,1
access_v_sale_notin_matchpo,access.v.sale.notin.matchpo,model_v_sale_notin_matchpo,,1,1,1,1
diff --git a/indoteknik_custom/views/res_partner.xml b/indoteknik_custom/views/res_partner.xml
index cb9fa3ac..2a4b03a7 100644
--- a/indoteknik_custom/views/res_partner.xml
+++ b/indoteknik_custom/views/res_partner.xml
@@ -65,6 +65,21 @@
<group name="purchase" position="inside">
<field name="leadtime"/>
</group>
+ <xpath expr="//notebook/page[@name='contact_addresses']" position="before">
+ <page string="Pin Point Location" name="map_location">
+ <!-- <group>
+ <button name="geocode_address" type="object" string="Get Pin Point Location" class="btn btn-primary"/>
+ </group> -->
+ <div style="margin: 16px 0;">
+ <field name="map_view" widget="googlemap" nolabel="1"/>
+ </div>
+ <group>
+ <field name="address_map" readonly="1" force_save="1"/>
+ <field name="latitude" readonly="1" force_save="1"/>
+ <field name="longtitude" readonly="1" force_save="1"/>
+ </group>
+ </page>
+ </xpath>
<field name="vat" position="after">
<field name="email_finance" widget="email"/>
<field name="email_sales" widget="email"/>
@@ -78,6 +93,15 @@
<field name="main_parent_id" invisible="1" />
<field name="site_id" attrs="{'readonly': [('parent_id', '=', False)]}" domain="[('partner_id', '=', main_parent_id)]" context="{'default_partner_id': active_id}" />
</xpath>
+ <xpath expr="//field[@name='child_ids']/form/sheet/group" position="after">
+ <div class="oe_left" style="margin-top: 16px;">
+ <button name="action_open_full_form"
+ type="object"
+ string="Detail Information"
+ class="btn btn-primary"
+ />
+ </div>
+ </xpath>
<xpath expr="//field[@name='property_payment_term_id']" position="attributes">
<attribute name="readonly">0</attribute>
</xpath>
diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml
index 4b74825e..fbca3705 100755
--- a/indoteknik_custom/views/sale_order.xml
+++ b/indoteknik_custom/views/sale_order.xml
@@ -134,10 +134,11 @@
<field name="real_invoice_id"/>
<field name="approval_status"/>
<field name="sales_tax_id"
- domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1"/>
- <field name="carrier_id" required="1"/>
- <field name="delivery_service_type" readonly="1"/>
- <field name="shipping_option_id"/>
+ domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1" />
+ <field name="select_shipping_option"/>
+ <field name="carrier_id" required="1" domain="[]" />
+ <field name="delivery_service_type" readonly="1" />
+ <field name="shipping_option_id" />
</field>
<field name="medium_id" position="after">
<field name="date_doc_kirim" readonly="1"/>
@@ -302,6 +303,12 @@
<field name="picking_iu_id"/>
<field name="note_ekspedisi"/>
</field>
+ <field name="select_shipping_option" position="attributes">
+ <attribute name="attrs">
+ {'readonly': [('approval_status', '=', 'approved'), ('state', 'not in',
+ ['cancel','draft'])]}
+ </attribute>
+ </field>
<field name="carrier_id" position="attributes">
<attribute name="attrs">
{'readonly': [('approval_status', '=', 'approved'), ('state', 'not in',
diff --git a/indoteknik_custom/views/sale_order_delay.xml b/indoteknik_custom/views/sale_order_delay.xml
new file mode 100644
index 00000000..b2aad8eb
--- /dev/null
+++ b/indoteknik_custom/views/sale_order_delay.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="view_sale_order_delay_tree" model="ir.ui.view">
+ <field name="name">sale.order.delay.tree</field>
+ <field name="model">sale.order.delay</field>
+ <field name="arch" type="xml">
+ <tree>
+ <field name="so_number" />
+ <field name="days_delayed" />
+ <field name="status" />
+ </tree>
+ </field>
+ </record>
+
+ <record id="sale_order_delay_action" model="ir.actions.act_window">
+ <field name="name">Sale Order Delay</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">sale.order.delay</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <record id="view_sale_order_delay_form" model="ir.ui.view">
+ <field name="name">sale.order.delay.form</field>
+ <field name="model">sale.order.delay</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <group>
+ <field name="so_number" />
+ <field name="days_delayed" />
+ <field name="status" />
+ </group>
+ </sheet>
+ </form>
+ </field>
+ </record>
+
+ <menuitem id="menu_sale_order_delay"
+ name="Sale Order Delay"
+ action="sale_order_delay_action"
+ parent="sale.product_menu_catalog"
+ sequence="8"
+ />
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/stock_picking.xml b/indoteknik_custom/views/stock_picking.xml
index ae77ab9a..97a9fbed 100644
--- a/indoteknik_custom/views/stock_picking.xml
+++ b/indoteknik_custom/views/stock_picking.xml
@@ -63,6 +63,12 @@
string="Biteship"
type="object"
/>
+ <button name="action_sync_biteship_tracking"
+ type="object"
+ string="Lacak dari Biteship"
+ class="btn-primary"
+ attrs="{'invisible': [('biteship_id', '=', False)]}"
+ />
<button name="track_envio_shipment"
string="Tracking Envio"
type="object"
@@ -91,7 +97,9 @@
/>
</button>
<field name="backorder_id" position="after">
+ <field name="select_shipping_option_so"/>
<field name="shipping_method_so_id"/>
+ <field name="shipping_option_so_id"/>
<field name="summary_qty_detail"/>
<field name="count_line_detail"/>
<field name="dokumen_tanda_terima"/>
@@ -184,6 +192,7 @@
<field name="note_info"/>
<field name="responsible" />
<field name="carrier_id"/>
+ <field name="biteship_id" invisible="1"/>
<field name="out_code" attrs="{'invisible': [['out_code', '=', False]]}"/>
<field name="picking_code" attrs="{'invisible': [['picking_code', '=', False]]}"/>
<field name="picking_code" string="Picking code (akan digenerate ketika sudah di-validate)" attrs="{'invisible': [['picking_code', '!=', False]]}"/>