from odoo import fields, models, api, _ from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools.float_utils import float_is_zero from collections import defaultdict from datetime import timedelta, datetime from datetime import timedelta, datetime as waktu from itertools import groupby import pytz, requests, json, requests from dateutil import parser import datetime import hmac import hashlib import base64 import requests import time import logging import re _logger = logging.getLogger(__name__) _biteship_url = "https://api.biteship.com/v1" biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo" # biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA" class StockPicking(models.Model): _inherit = 'stock.picking' _order = 'final_seq ASC' tukar_guling_id = fields.Many2one( 'tukar.guling', string='Tukar Guling Reference' ) konfirm_koli_lines = fields.One2many('konfirm.koli', 'picking_id', string='Konfirm Koli', auto_join=True, copy=False) scan_koli_lines = fields.One2many('scan.koli', 'picking_id', string='Scan Koli', auto_join=True, copy=False) check_koli_lines = fields.One2many('check.koli', 'picking_id', string='Check Koli', auto_join=True, copy=False) check_product_lines = fields.One2many('check.product', 'picking_id', string='Check Product', auto_join=True, copy=False) barcode_product_lines = fields.One2many('barcode.product', 'picking_id', string='Barcode Product', auto_join=True) is_internal_use = fields.Boolean('Internal Use', help='flag which is internal use or not') account_id = fields.Many2one('account.account', string='Account') efaktur_id = fields.Many2one('vit.efaktur', string='Faktur Pajak') is_efaktur_exported = fields.Boolean(string='Is eFaktur Exported') date_efaktur_exported = fields.Datetime(string='eFaktur Exported Date') delivery_status = fields.Char(string='Delivery Status', compute='compute_delivery_status', readonly=True) summary_qty_detail = fields.Float('Total Qty Detail', compute='_compute_summary_qty') summary_qty_operation = fields.Float('Total Qty Operation', compute='_compute_summary_qty') count_line_detail = fields.Float('Total Item Detail', compute='_compute_summary_qty') count_line_operation = fields.Float('Total Item Operation', compute='_compute_summary_qty') real_shipping_id = fields.Many2one( 'res.partner', string='Real Delivery Address', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Dipakai untuk alamat tempel") # Delivery Order driver_departure_date = fields.Datetime( string='Delivery Departure Date', copy=False ) arrival_time = fields.Datetime( string='Jam Kedatangan', copy=False ) driver_arrival_date = fields.Datetime( string='Delivery Arrival Date', copy=False ) delivery_tracking_no = fields.Char( string='Delivery Tracking Number', readonly=True, copy=False ) driver_id = fields.Many2one( comodel_name='res.users', string='Driver', readonly=True, copy=False ) picking_code = fields.Char( string="Picking Code", readonly=True, copy=False ) out_code = fields.Integer( string="Out Code", readonly=True, related="id", ) sj_documentation = fields.Binary(string="Dokumentasi Surat Jalan") paket_documentation = fields.Binary(string="Dokumentasi Paket") dispatch_documentation = fields.Binary(string="Dokumentasi Dispatch") sj_return_date = fields.Datetime(string="SJ Return Date", copy=False) responsible = fields.Many2one('res.users', string='Responsible', tracking=True) approval_status = fields.Selection([ ('pengajuan1', 'Approval Accounting'), ('pengajuan2', 'Approval Logistic'), ('approved', 'Approved'), ], string='Approval Status', readonly=True, copy=False, index=True, tracking=3, help="Approval Status untuk Internal Use") approval_receipt_status = fields.Selection([ ('pengajuan1', 'Approval Logistic'), ('approved', 'Approved'), ], string='Approval Receipt Status', readonly=True, copy=False, index=True, tracking=3, help="Approval Status untuk Receipt") approval_return_status = fields.Selection([ ('pengajuan1', 'Approval Finance'), ('approved', 'Approved'), ], string='Approval Return Status', readonly=True, copy=False, index=True, tracking=3, help="Approval Status untuk Return") date_doc_kirim = fields.Datetime(string='Tanggal Kirim di SJ', help="Tanggal Kirim di cetakan SJ, tidak berpengaruh ke Accounting", tracking=True, copy=False) note_logistic = fields.Selection([ ('wait_so_together', 'Tunggu SO Barengan'), ('not_paid', 'Customer belum bayar'), ('reserve_stock', 'Reserve Stock'), ('waiting_schedule', 'Menunggu Jadwal Kirim'), ('self_pickup', 'Barang belum di pickup Customer'), ('expedition_closed', 'Eskpedisi belum buka') ], string='Note Logistic', help='jika field ini diisi maka tidak akan dihitung ke lead time') waybill_id = fields.One2many(comodel_name='airway.bill', inverse_name='do_id', string='Airway Bill') purchase_representative_id = fields.Many2one('res.users', related='move_lines.purchase_line_id.order_id.user_id', string="Purchase Representative") carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method', tracking=3) shipping_status = fields.Char(string='Shipping Status', compute="_compute_shipping_status") date_reserved = fields.Datetime(string="Date Reserved", help='Tanggal ter-reserved semua barang nya', copy=False) status_printed = fields.Selection([ ('not_printed', 'Belum Print'), ('printed', 'Printed') ], string='Printed?', copy=False, tracking=True) date_printed_sj = fields.Datetime(string='Status Printed Surat Jalan', copy=False, tracking=True) date_printed_list = fields.Datetime(string='Status Printed Picking List', copy=False, tracking=True) date_unreserve = fields.Datetime(string="Date Unreserved", copy=False, tracking=True) date_availability = fields.Datetime(string="Date Availability", copy=False, tracking=True) sale_order = fields.Char(string='Matches SO', copy=False) printed_sj = fields.Boolean('Printed Surat Jalan', help='flag which is internal use or not') printed_sj_retur = fields.Boolean('Printed Surat Jalan Retur', help='flag which is internal use or not') date_printed_sj_retur = fields.Datetime(string='Status Printed Surat Jalan Retur', copy=False, tracking=True) invoice_status = fields.Selection([ ('upselling', 'Upselling Opportunity'), ('invoiced', 'Fully Invoiced'), ('to invoice', 'To Invoice'), ('no', 'Nothing to Invoice') ], string='Invoice Status', related="sale_id.invoice_status") note_return = fields.Text(string="Note Return", help="Catatan untuk kirim barang kembali") state_reserve = fields.Selection([ ('waiting', 'Waiting For Fullfilment'), ('ready', 'Ready to Ship'), ('partial', 'Ready to Ship Partial'), ('done', 'Done'), ('cancel', 'Cancelled'), ], string='Status Reserve', tracking=True, copy=False, help="The current state of the stock picking.") notee = fields.Text(string="Note SJ", help="Catatan untuk kirim barang") note_info = fields.Text(string="Note Logistix (Text)", help="Catatan untuk pengiriman") state_approve_md = fields.Selection([ ('waiting', 'Waiting For Approve by MD'), ('pending', 'Pending (perlu koordinasi dengan MD)'), ('done', 'Approve by MD'), ], string='Approval MD Gudang Selisih', tracking=True, copy=False, help="The current state of the MD Approval transfer barang from gudang selisih.") # show_state_approve_md = fields.Boolean(compute="_compute_show_state_approve_md") # def _compute_show_state_approve_md(self): # for record in self: # record.show_state_approve_md = record.location_id.id == 47 or record.location_id.complete_name == "Virtual Locations/Gudang Selisih" quantity_koli = fields.Float(string="Quantity Koli", copy=False) total_mapping_koli = fields.Float(string="Total Mapping Koli", compute='_compute_total_mapping_koli') so_lama = fields.Boolean('SO LAMA', copy=False) linked_manual_bu_out = fields.Many2one('stock.picking', string='BU Out', copy=False) area_name = fields.Char(string="Area", compute="_compute_area_name") is_bu_iu = fields.Boolean('Is BU/IU', compute='_compute_is_bu_iu', default=False, copy=False, readonl=True) @api.depends('name') def _compute_is_bu_iu(self): for record in self: if 'BU/IU' in record.name: record.is_bu_iu = True else: record.is_bu_iu = False def action_bu_iu_to_pengajuan2(self): for rec in self: if not rec.is_bu_iu or not rec.is_internal_use: raise UserError(_("Tombol ini hanya untuk dokumen BU/IU - Internal Use.")) if rec.approval_status == False: raise UserError("Harus Ask Approval terlebih dahulu") if rec.approval_status in ['pengajuan1'] and self.env.user.is_accounting: rec.approval_status = 'pengajuan2' rec.message_post(body=_("Status naik ke Approval Logistik oleh %s") % self.env.user.display_name) if rec.approval_status in ['pengajuan1', 'pengajuan2', ''] and not self.env.user.is_accounting: raise UserError("Tombol hanya untuk accounting") return True # 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: district = record.real_shipping_id.kecamatan_id.name or '' city = record.real_shipping_id.kota_id.name or '' record.area_name = f"{district}, {city}".strip(', ') # def write(self, vals): # if 'linked_manual_bu_out' in vals: # for record in self: # if (record.picking_type_code == 'internal' # and 'BU/PICK/' in record.name): # # Jika menghapus referensi (nilai di-set False/None) # if record.linked_manual_bu_out and not vals['linked_manual_bu_out']: # record.linked_manual_bu_out.state_packing = 'not_packing' # # Jika menambahkan referensi baru # elif vals['linked_manual_bu_out']: # new_picking = self.env['stock.picking'].browse(vals['linked_manual_bu_out']) # new_picking.state_packing = 'packing_done' # return super().write(vals) # @api.model # def create(self, vals): # record = super().create(vals) # if (record.picking_type_code == 'internal' # and 'BU/PICK/' in record.name # and vals.get('linked_manual_bu_out')): # picking = self.env['stock.picking'].browse(vals['linked_manual_bu_out']) # picking.state_packing = 'packing_done' # return record @api.depends('konfirm_koli_lines', 'konfirm_koli_lines.pick_id', 'konfirm_koli_lines.pick_id.quantity_koli') def _compute_total_mapping_koli(self): for record in self: total = 0.0 for line in record.konfirm_koli_lines: if line.pick_id and line.pick_id.quantity_koli: total += line.pick_id.quantity_koli record.total_mapping_koli = total @api.model def _compute_dokumen_tanda_terima(self): for picking in self: picking.dokumen_tanda_terima = picking.partner_id.dokumen_pengiriman @api.model def _compute_dokumen_pengiriman(self): for picking in self: picking.dokumen_pengiriman = picking.partner_id.dokumen_pengiriman_input dokumen_tanda_terima = fields.Char(string='Dokumen Tanda Terima yang Diberikan Pada Saat Pengiriman Barang', readonly=True, compute=_compute_dokumen_tanda_terima) dokumen_pengiriman = fields.Char(string='Dokumen yang Dibawa Saat Pengiriman Barang', readonly=True, compute=_compute_dokumen_pengiriman) # Envio Tracking Section envio_id = fields.Char(string="Envio ID", readonly=True) envio_code = fields.Char(string="Envio Code", readonly=True) envio_ref_code = fields.Char(string="Envio Reference Code", readonly=True) envio_eta_at = fields.Datetime(string="Estimated Time of Arrival (ETA)", readonly=True) envio_ata_at = fields.Datetime(string="Actual Time of Arrival (ATA)", readonly=True) envio_etd_at = fields.Datetime(string="Estimated Time of Departure (ETD)", readonly=True) envio_atd_at = fields.Datetime(string="Actual Time of Departure (ATD)", readonly=True) envio_received_by = fields.Char(string="Received By", readonly=True) envio_status = fields.Char(string="Status", readonly=True) envio_cod_value = fields.Float(string="COD Value", readonly=True) envio_cod_status = fields.Char(string="COD Status", readonly=True) envio_logs = fields.Text(string="Logs", readonly=True) envio_latest_message = fields.Text(string="Latest Log Message", readonly=True) envio_latest_recorded_at = fields.Datetime(string="Log Recorded At", readonly=True) envio_latest_latitude = fields.Float(string="Log Latitude", readonly=True) envio_latest_longitude = fields.Float(string="Log Longitude", readonly=True) tracking_by = fields.Many2one('res.users', string='Tracking By', readonly=True, tracking=True) # Lalamove Section lalamove_order_id = fields.Char(string="Lalamove Order ID", copy=False) lalamove_address = fields.Char(string="Lalamove Address") lalamove_name = fields.Char(string="Lalamove Name") lalamove_phone = fields.Char(string="Lalamove Phone") lalamove_status = fields.Char(string="Lalamove Status") lalamove_delivered_at = fields.Datetime(string="Lalamove Delivered At") lalamove_data = fields.Text(string="Lalamove Data", readonly=True) lalamove_image_url = fields.Char(string="Lalamove Image URL") lalamove_image_html = fields.Html(string="Lalamove Image", compute="_compute_lalamove_image_html") # KGX Section kgx_pod_photo_url = fields.Char('KGX Photo URL') kgx_pod_photo = fields.Html('KGX Photo', compute='_compute_kgx_image_html') kgx_pod_signature = fields.Char('KGX Signature URL') kgx_pod_receive_time = fields.Datetime('KGX Ata Date') kgx_pod_receiver = fields.Char('KGX Receiver') total_koli = fields.Integer(compute='_compute_total_koli', string="Total Koli") total_koli_display = fields.Char(compute='_compute_total_koli_display', string="Total Koli Display") linked_out_picking_id = fields.Many2one('stock.picking', string="Linked BU/OUT", copy=False) total_so_koli = fields.Integer(compute='_compute_total_so_koli', string="Total SO Koli") # Biteship Section biteship_id = fields.Char(string="Biteship Respon ID") biteship_tracking_id = fields.Char(string="Biteship Tracking ID") biteship_waybill_id = fields.Char(string="Biteship Waybill ID") biteship_driver_name = fields.Char('Biteship Driver Name') biteship_driver_phone = fields.Char('Biteship Driver Phone') biteship_driver_plate_number = fields.Char('Biteship Driver Plate Number') biteship_courier_link = fields.Char('Biteship Courier Link') biteship_shipping_status = fields.Char('Biteship Shipping Status', help="Status pengiriman dari Biteship") biteship_shipping_price = fields.Monetary('Biteship Shipping Price', currency_field='currency_id', help="Harga pengiriman dari Biteship") currency_id = fields.Many2one('res.currency', related='sale_id.currency_id', string='Currency', readonly=True) final_seq = fields.Float(string='Remaining Time') shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method', related='sale_id.carrier_id', help="Shipping Method yang digunakan di SO", tracking=3) shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option', related='sale_id.shipping_option_id', help="Shipping Option yang digunakan di SO", tracking=3) select_shipping_option_so = fields.Selection([ ('biteship', 'Biteship'), ('custom', 'Custom'), ], string='Shipping Type', related='sale_id.select_shipping_option', help="Shipping Type yang digunakan di SO", tracking=3) state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')], string='Packing Status') approval_invoice_date_id = fields.Many2one('approval.invoice.date', string='Approval Invoice Date') 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') reason_change_date_planned = fields.Selection([ ('delay', 'Delay By Vendor'), ('urgent', 'Urgent Delivery'), ], string='Reason Change Date Planned', tracking=True) delivery_date = fields.Datetime(string='Delivery Date', copy=False) def _get_kgx_awb_number(self): """Menggabungkan name dan origin untuk membuat AWB Number""" self.ensure_one() if not self.name or not self.origin: return False return f"{self.name}" def _download_pod_photo(self, url): """Mengunduh foto POD dari URL""" try: response = requests.get(url, timeout=10) response.raise_for_status() return base64.b64encode(response.content) except Exception as e: raise UserError(f"Gagal mengunduh foto POD: {str(e)}") def _parse_datetime(self, dt_str): """Parse datetime string dari format KGX""" try: from datetime import datetime if not dt_str: return False if '+' in dt_str: dt_str = dt_str.split('+')[0] return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S') except ValueError: return False def action_get_kgx_pod(self, shipment=False): self.ensure_one() awb_number = shipment or self._get_kgx_awb_number() if not awb_number: raise UserError("Nomor AWB tidak dapat dibuat, pastikan picking memiliki name dan origin") url = "https://kgx.co.id/get_detail_awb" headers = {'Content-Type': 'application/json'} payload = {"params": {'awb_number': awb_number}} try: response = requests.post(url, headers=headers, data=json.dumps(payload)) response.raise_for_status() data = response.json() if data.get('result', {}).get('data', []): pod_data = data['result']['data'][0].get('connote_pod', {}) photo_url = pod_data.get('photo') self.kgx_pod_photo_url = photo_url self.kgx_pod_signature = pod_data.get('signature') self.kgx_pod_receiver = pod_data.get('receiver') self.kgx_pod_receive_time = self._parse_datetime(pod_data.get('timeReceive')) self.driver_arrival_date = self._parse_datetime(pod_data.get('timeReceive')) return data else: raise UserError(f"Tidak ditemukan data untuk AWB: {awb_number}") except requests.exceptions.RequestException as e: raise UserError(f"Gagal mengambil data POD: {str(e)}") @api.constrains('sj_return_date') def _check_sj_return_date(self): for record in self: if not record.driver_arrival_date: if record.sj_return_date: raise ValidationError( _("Anda tidak dapat mengubah Tanggal Pengembalian setelah Tanggal Pengiriman!") ) def _check_date_doc_kirim_modification(self): for record in self: if record.last_update_date_doc_kirim and not self.env.context.get('from_button_approve'): kirim_date = fields.Datetime.from_string(record.last_update_date_doc_kirim) now = fields.Datetime.now() deadline = kirim_date + timedelta(days=1) deadline = deadline.replace(hour=10, minute=0, second=0) if now > deadline and not self.so_lama: raise ValidationError( _("Anda tidak dapat mengubah Tanggal Kirim setelah jam 10:00 pada hari berikutnya!") ) @api.constrains('date_doc_kirim') def _constrains_date_doc_kirim(self): for rec in self: rec.calculate_line_no() if rec.picking_type_code == 'outgoing' and 'BU/OUT/' in rec.name and rec.partner_id.id != 96868: invoice = self.env['account.move'].search( [('sale_id', '=', rec.sale_id.id), ('move_type', '=', 'out_invoice'), ('state', '=', 'posted')], limit=1, order='create_date desc') if invoice and not self.env.context.get('active_model') == 'stock.picking': rec._check_date_doc_kirim_modification() if rec.date_doc_kirim != invoice.invoice_date and not self.env.context.get('from_button_approve'): get_approval_invoice_date = self.env['approval.invoice.date'].search( [('picking_id', '=', rec.id), ('state', '=', 'draft')], limit=1) if get_approval_invoice_date and get_approval_invoice_date.state == 'draft': get_approval_invoice_date.date_doc_do = rec.date_doc_kirim else: approval_invoice_date = self.env['approval.invoice.date'].create({ 'picking_id': rec.id, 'date_invoice': invoice.invoice_date, 'date_doc_do': rec.date_doc_kirim, 'sale_id': rec.sale_id.id, 'move_id': invoice.id, 'partner_id': rec.partner_id.id }) rec.approval_invoice_date_id = approval_invoice_date.id if approval_invoice_date: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'title': 'Notification', 'message': 'Invoice Date Tidak Sesuai, Document Approval Invoice Date Terbuat', 'next': {'type': 'ir.actions.act_window_close'}}, } rec.last_update_date_doc_kirim = datetime.datetime.utcnow() @api.constrains('scan_koli_lines') def _constrains_scan_koli_lines(self): now = datetime.datetime.utcnow() for picking in self: if len(picking.scan_koli_lines) > 0: if len(picking.scan_koli_lines) != picking.total_mapping_koli: raise UserError("Scan Koli Tidak Sesuai Dengan Total Mapping Koli") picking.driver_departure_date = now @api.depends('total_so_koli') def _compute_total_so_koli(self): for picking in self: if picking.state == 'done': picking.total_so_koli = self.env['sales.order.koli'].search_count( [('picking_id.linked_out_picking_id', '=', picking.id), ('state', '=', 'delivered')]) else: picking.total_so_koli = self.env['sales.order.koli'].search_count( [('picking_id.linked_out_picking_id', '=', picking.id), ('state', '!=', 'delivered')]) @api.depends('total_koli') def _compute_total_koli(self): for picking in self: picking.total_koli = self.env['scan.koli'].search_count([('picking_id', '=', picking.id)]) @api.depends('total_koli', 'total_so_koli') def _compute_total_koli_display(self): for picking in self: picking.total_koli_display = f"{picking.total_koli} / {picking.total_so_koli}" @api.constrains('quantity_koli') def _constrains_quantity_koli(self): for picking in self: if not picking.linked_out_picking_id: so_koli = self.env['sales.order.koli'].search([('picking_id', '=', picking.id)]) if so_koli: so_koli.unlink() for rec in picking.check_koli_lines: self.env['sales.order.koli'].create({ 'sale_order_id': picking.sale_id.id, 'picking_id': picking.id, 'koli_id': rec.id, }) else: raise UserError( 'Tidak Bisa Mengubah Quantity Koli Karena Koli Dari Picking Ini Sudah Dipakai Di BU/OUT!') @api.onchange('quantity_koli') def _onchange_quantity_koli(self): self.check_koli_lines = [(5, 0, 0)] self.check_koli_lines = [(0, 0, { 'koli': f"{self.name}/{str(i + 1).zfill(3)}", 'picking_id': self.id, }) for i in range(int(self.quantity_koli))] def schduled_update_sequance(self): query = "SELECT update_sequance_stock_picking();" self.env.cr.execute(query) # @api.depends('estimated_ready_ship_date', 'state') # def _callculate_sequance(self): # for record in self: # try : # if record.estimated_ready_ship_date and record.state not in ('cancel', 'done'): # rts = record.estimated_ready_ship_date - waktu.now() # rts_days = rts.days # rts_hours = divmod(rts.seconds, 3600) # estimated_by_erts = rts.total_seconds() / 3600 # record.countdown_ready_to_ship = f"{rts_days} days, {rts_hours} hours" # record.countdown_hours = estimated_by_erts # else: # record.countdown_hours = 999999999999 # record.countdown_ready_to_ship = False # except Exception as e : # _logger.error(f"Error calculating sequance {record.id}: {str(e)}") # print(str(e)) # return { 'error': str(e) } # @api.depends('estimated_ready_ship_date', 'state') # def _compute_countdown_hours(self): # for record in self: # if record.state in ('cancel', 'done') or not record.estimated_ready_ship_date: # # Gunakan nilai yang sangat besar sebagai placeholder # record.countdown_hours = 999999 # else: # delta = record.estimated_ready_ship_date - waktu.now() # record.countdown_hours = delta.total_seconds() / 3600 # @api.depends('estimated_ready_ship_date', 'state') # def _compute_countdown_ready_to_ship(self): # for record in self: # if record.state in ('cancel', 'done'): # record.countdown_ready_to_ship = False # else: # if record.estimated_ready_ship_date: # delta = record.estimated_ready_ship_date - waktu.now() # days = delta.days # hours, remainder = divmod(delta.seconds, 3600) # record.countdown_ready_to_ship = f"{days} days, {hours} hours" # record.countdown_hours = delta.total_seconds() / 3600 # else: # record.countdown_ready_to_ship = False def _compute_lalamove_image_html(self): for record in self: if record.lalamove_image_url: record.lalamove_image_html = f'' else: record.lalamove_image_html = "No image available." def _compute_kgx_image_html(self): for record in self: if record.kgx_pod_photo_url: record.kgx_pod_photo = f'' else: record.kgx_pod_photo = "No image available." def action_fetch_lalamove_order(self): for picking in self: try: order_id = picking.lalamove_order_id apikey = self.env['ir.config_parameter'].sudo().get_param('lalamove.apikey') secret = self.env['ir.config_parameter'].sudo().get_param('lalamove.secret') market = self.env['ir.config_parameter'].sudo().get_param('lalamove.market', default='ID') order_data = picking.get_lalamove_order(order_id, apikey, secret, market) picking.lalamove_data = order_data except Exception as e: _logger.error(f"Error fetching Lalamove order for picking {picking.id}: {str(e)}") continue def get_lalamove_order(self, order_id, apikey, secret, market): timestamp = str(int(time.time() * 1000)) message = f"{timestamp}\r\nGET\r\n/v3/orders/{order_id}\r\n\r\n" signature = hmac.new(secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest() headers = { "Content-Type": "application/json", "Authorization": f"hmac {apikey}:{timestamp}:{signature}", "Market": market } url = f"https://rest.lalamove.com/v3/orders/{order_id}" response = requests.get(url, headers=headers) if response.status_code == 200: data = response.json() stops = data.get("data", {}).get("stops", []) for stop in stops: pod = stop.get("POD", {}) if pod.get("status") == "DELIVERED": image_url = pod.get("image") # Sesuaikan jika key berbeda self.lalamove_image_url = image_url address = stop.get("address") name = stop.get("name") phone = stop.get("phone") delivered_at = pod.get("deliveredAt") delivered_at_dt = self._convert_to_datetime(delivered_at) self.lalamove_address = address self.lalamove_name = name self.lalamove_phone = phone self.lalamove_status = pod.get("status") self.lalamove_delivered_at = delivered_at_dt self.driver_arrival_date = delivered_at_dt return data raise UserError("No delivered data found in Lalamove response.") else: raise UserError(f"Error {response.status_code}: {response.text}") def _convert_to_wib(self, date_str): """ Mengonversi string waktu ISO 8601 ke format waktu Indonesia (WIB) """ if not date_str: return False try: utc_time = datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%SZ') wib_time = utc_time + timedelta(hours=7) return wib_time.strftime('%d-%m-%Y %H:%M:%S') except ValueError: raise UserError(f"Format waktu tidak sesuai: {date_str}") def _convert_to_datetime(self, date_str): """Mengonversi string waktu dari API ke datetime.""" if not date_str: return False try: # Format waktu dengan milidetik date = datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S.%fZ') return date except ValueError: try: # Format waktu tanpa milidetik date = datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%SZ') return date except ValueError: raise UserError(f"Format waktu tidak sesuai: {date_str}") def track_envio_shipment(self, shipment=False): pickings = self.env['stock.picking'].search([ ('picking_type_code', '=', 'outgoing'), ('state', '=', 'done'), ('carrier_id', '=', 151) ]) for picking in self: name = shipment or picking.name if not picking.name: raise UserError("Name pada stock.picking tidak ditemukan.") # API URL dan headers url = f"https://api.envio.co.id/v1/tracking/distribution?code={name}" headers = { 'Authorization': 'Bearer JZ0Seh6qpYJAC3CJHdhF7sPqv8B/uSSfZe1VX5BL?vPYdo', 'Content-Type': 'application/json', } try: # Request ke API response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() # Raise error jika status code bukan 200 response_data = response.json() # Validasi jika respons tidak sesuai format yang diharapkan if not response_data or "data" not in response_data: raise UserError("Respons API tidak sesuai format yang diharapkan.") data = response_data.get("data") if not data: continue # Menyimpan data ke field masing-masing picking.envio_id = data.get("id") picking.envio_code = data.get("code") picking.envio_ref_code = data.get("ref_code") picking.envio_eta_at = self._convert_to_datetime(data.get("eta_at")) picking.envio_ata_at = self._convert_to_datetime(data.get("ata_at")) picking.envio_etd_at = self._convert_to_datetime(data.get("etd_at")) picking.envio_atd_at = self._convert_to_datetime(data.get("atd_at")) picking.envio_received_by = data.get("received_by") picking.envio_status = data.get("status") picking.envio_cod_value = data.get("cod_value", 0.0) picking.envio_cod_status = data.get("cod_status") # Menyimpan log terbaru logs = data.get("logs", []) if logs and isinstance(logs, list) and logs[0]: latest_log = logs[0] picking.envio_latest_message = latest_log.get("message", "Log kosong.") picking.envio_latest_recorded_at = self._convert_to_datetime(latest_log.get("recorded_at")) picking.envio_latest_latitude = latest_log.get("latitude", 0.0) picking.envio_latest_longitude = latest_log.get("longitude", 0.0) picking.tracking_by = self.env.user.id ata_at_str = data.get("ata_at") envio_ata = self._convert_to_datetime(data.get("ata_at")) picking.driver_arrival_date = envio_ata if data.get("status") != 'delivered': picking.driver_arrival_date = False picking.envio_ata_at = False except requests.exceptions.RequestException as e: raise UserError(f"Terjadi kesalahan saat menghubungi API Envio: {str(e)}") except Exception as e: 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}") 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 product = ml.product_id weight = product.weight or 0.1 # default minimal line = ml.move_id.sale_line_id or self.env['sale.order.line'].search([ ('order_id', '=', self.sale_id.id), ('product_id', '=', ml.product_id.id) ], limit=1) 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 = { "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, "origin_contact_name": "PT. Indoteknik Dotcom Gemilang", "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": 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", "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", "items": items } if is_courier_need_coordinates(courier_service_code): if not shipping_partner.latitude or not shipping_partner.longtitude: raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).") payload["destination_coordinate"] = { "latitude": shipping_partner.latitude, "longitude": shipping_partner.longtitude, } _logger.info(f"Payload untuk Biteship: {payload}") # Kirim ke Biteship api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } 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() 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 = self.biteship_waybill_id self.biteship_shipping_price = data.get("price", 0.0) waybill_id = self.biteship_waybill_id self.message_post( body=f"Biteship berhasil dilakukan.
" f"Kurir: {self.carrier_id.name}
" f"Tracking ID: {self.biteship_tracking_id or '-'}
" f"Resi: {waybill_id or '-'}
" f"Reference: {self.name}
" 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', 'message': message, 'type': 'rainbow_man', } } else: error_data = response.json() error_message = error_data.get("error", "Unknown error") error_code = error_data.get("code", "No code provided") raise UserError(f"Error saat mengirim ke Biteship: {error_message} (Code: {error_code})") @api.constrains('driver_departure_date') def constrains_driver_departure_date(self): if not self.date_doc_kirim: self.date_doc_kirim = self.driver_departure_date @api.constrains('arrival_time') def constrains_arrival_time(self): for record in self: if record.arrival_time and record.arrival_time > datetime.datetime.utcnow(): raise UserError('Jam kedatangan harus kurang dari Effective Date') def reset_status_printed(self): for rec in self: rec.status_printed = 'not_printed' rec.printed_sj = False rec.date_printed_list = False rec.date_printed_sj = False @api.onchange('carrier_id') def constrains_carrier_id(self): if self.carrier_id: if not self.env.user.is_logistic_approver: raise UserError('Hanya Logistic yang bisa mengubah shipping method') def do_unreserve(self): group_id = self.env.ref('indoteknik_custom.group_role_it').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) if not self._context.get('darimana') == 'sale.order' and self.env.user.id not in users_in_group.mapped('id'): self.sale_id.unreserve_id = self.id return self._create_approval_notification('Logistic') res = super(StockPicking, self).do_unreserve() current_time = datetime.datetime.utcnow() self.date_unreserve = current_time return res def check_state_reserve(self): pickings = self.search([ ('state', 'not in', ['cancel', 'draft', 'done']), ('picking_type_code', '=', 'internal'), ('name', 'ilike', 'BU/PICK/'), ]) for picking in pickings: fullfillments = self.env['sales.order.fulfillment.v2'].search([ ('sale_order_id', '=', picking.sale_id.id) ]) picking.state_reserve = 'ready' picking.date_reserved = picking.date_reserved or datetime.datetime.utcnow() if any(rec.so_qty != rec.reserved_stock_qty for rec in fullfillments): picking.state_reserve = 'waiting' picking.date_reserved = '' self.check_state_reserve_backorder() def check_state_reserve_backorder(self): pickings = self.search([ ('backorder_id', '!=', False), ('name', 'ilike', 'BU/PICK/'), ('picking_type_code', '=', 'internal'), ('state', 'not in', ['cancel', 'draft', 'done']) ]) for picking in pickings: fullfillments = self.env['sales.order.fulfillment.v2'].search([ ('sale_order_id', '=', picking.sale_id.id) ]) picking.state_reserve = 'ready' picking.date_reserved = picking.date_reserved or datetime.datetime.utcnow() if any(rec.so_qty != rec.reserved_stock_qty for rec in fullfillments): picking.state_reserve = 'waiting' picking.date_reserved = '' def _create_approval_notification(self, approval_role): title = 'Warning' message = f'Butuh approval sales untuk unreserved' return self._create_notification_action(title, message) def _create_notification_action(self, title, message): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': {'title': title, 'message': message, 'next': {'type': 'ir.actions.act_window_close'}}, } def _compute_shipping_status(self): for rec in self: status = 'pending' if rec.driver_departure_date and not (rec.sj_return_date or rec.driver_arrival_date): status = 'shipment' elif rec.driver_departure_date and (rec.sj_return_date or rec.driver_arrival_date): status = 'completed' rec.shipping_status = status def action_create_invoice_from_mr(self): """Create the invoice associated to the PO. """ if not self.env.user.is_accounting: raise UserError('Hanya Accounting yang bisa membuat Bill') precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') # custom here po = self.env['purchase.order'].search([ ('name', '=', self.group_id.name) ]) # 1) Prepare invoice vals and clean-up the section lines invoice_vals_list = [] for order in po: if order.invoice_status != 'to invoice': continue order = order.with_company(order.company_id) pending_section = None # Invoice values. invoice_vals = order._prepare_invoice() invoice_date = self.date_done invoice_vals['date'] = invoice_date invoice_vals['invoice_date'] = invoice_date # Invoice line values (keep only necessary sections). for line in self.move_ids_without_package: po_line = self.env['purchase.order.line'].search( [('order_id', '=', po.id), ('product_id', '=', line.product_id.id)], limit=1) qty = line.product_uom_qty if po_line.display_type == 'line_section': pending_section = line continue if not float_is_zero(po_line.qty_to_invoice, precision_digits=precision): if pending_section: invoice_vals['invoice_line_ids'].append( (0, 0, pending_section._prepare_account_move_line_from_mr(po_line, qty))) pending_section = None invoice_vals['invoice_line_ids'].append( (0, 0, line._prepare_account_move_line_from_mr(po_line, qty))) invoice_vals_list.append(invoice_vals) if not invoice_vals_list: raise UserError( _('There is no invoiceable line. If a product has a control policy based on received quantity, please make sure that a quantity has been received.')) # 2) group by (company_id, partner_id, currency_id) for batch creation new_invoice_vals_list = [] for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: ( x.get('company_id'), x.get('partner_id'), x.get('currency_id'))): origins = set() payment_refs = set() refs = set() ref_invoice_vals = None for invoice_vals in invoices: if not ref_invoice_vals: ref_invoice_vals = invoice_vals else: ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids'] origins.add(invoice_vals['invoice_origin']) payment_refs.add(invoice_vals['payment_reference']) refs.add(invoice_vals['ref']) ref_invoice_vals.update({ 'ref': ', '.join(refs)[:2000], 'invoice_origin': ', '.join(origins), 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False, }) new_invoice_vals_list.append(ref_invoice_vals) invoice_vals_list = new_invoice_vals_list # 3) Create invoices. moves = self.env['account.move'] AccountMove = self.env['account.move'].with_context(default_move_type='in_invoice') for vals in invoice_vals_list: moves |= AccountMove.with_company(vals['company_id']).create(vals) # 4) Some moves might actually be refunds: convert them if the total amount is negative # We do this after the moves have been created since we need taxes, etc. to know if the total # is actually negative or not moves.filtered( lambda m: m.currency_id.round(m.amount_total) < 0).action_switch_invoice_into_refund_credit_note() return self.action_view_invoice_from_mr(moves) def action_view_invoice_from_mr(self, invoices=False): """This function returns an action that display existing vendor bills of given purchase order ids. When only one found, show the vendor bill immediately. """ if not invoices: # Invoice_ids may be filtered depending on the user. To ensure we get all # invoices related to the purchase order, we read them in sudo to fill the # cache. self.sudo()._read(['invoice_ids']) invoices = self.invoice_ids result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_in_invoice_type') # choose the view_mode accordingly if len(invoices) > 1: result['domain'] = [('id', 'in', invoices.ids)] elif len(invoices) == 1: res = self.env.ref('account.view_move_form', False) form_view = [(res and res.id or False, 'form')] if 'views' in result: result['views'] = form_view + [(state, view) for state, view in result['views'] if view != 'form'] else: result['views'] = form_view result['res_id'] = invoices.id else: result = {'type': 'ir.actions.act_window_close'} return result @api.onchange('date_doc_kirim') def update_date_doc_kirim_so(self): if not self.sale_id: return self.sale_id.date_doc_kirim = self.date_doc_kirim def action_assign(self): if self.env.context.get('default_picking_type_id') and ('BU/INPUT' not in self.name or 'BU/PUT' not in self.name): pickings_to_assign = self.filtered( lambda p: not (p.sale_id and p.sale_id.hold_outgoing) ) else: pickings_to_assign = self res = super(StockPicking, pickings_to_assign).action_assign() current_time = datetime.datetime.utcnow() for picking in pickings_to_assign: picking.real_shipping_id = picking.sale_id.real_shipping_id picking.date_availability = current_time return res def ask_approval(self): # if self.env.user.is_accounting: # if self.env.user.is_accounting and self.location_id.id == 57 or self.location_id == 57 and self.approval_status in ['pengajuan1', ''] and 'BU/IU' in self.name and self.approval_status == 'pengajuan1': # raise UserError("Bisa langsung set ke approval logistik") if self.env.user.is_accounting and self.approval_status == "pengajuan2" and 'BU/IU' in self.name: raise UserError("Tidak perlu ask approval sudah approval logistik") if self.env.user.is_logistic_approver and self.location_id.id == 57 or self.location_id== 57 and self.approval_status == 'pengajuan2' and 'BU/IU' in self.name: raise UserError("Bisa langsung Validate") # for calendar distribute only # if self.is_internal_use: # stock_move_lines = self.env['stock.move.line'].search([ # ('picking_id', '!=', False), # ('product_id', '=', 236805), # ('picking_id.partner_id', '=', self.partner_id.id), # ('qty_done', '>', 0), # ]) # list_state = ['confirmed', 'done'] # for stock_move_line in stock_move_lines: # if stock_move_line.picking_id.state not in list_state: # continue # raise UserError('Sudah pernah dikirim kalender') for pick in self: if not pick.is_internal_use: raise UserError("Selain Internal Use bisa langsung Validate") for line in pick.move_line_ids_without_package: if line.qty_done <= 0: raise UserError("Qty tidak boleh 0") pick.approval_status = 'pengajuan1' def ask_receipt_approval(self): if self.env.user.is_logistic_approver: raise UserError('Bisa langsung validate tanpa Ask Receipt') else: self.approval_receipt_status = 'pengajuan1' def ask_return_approval(self): pass raise UserError("Bisa langsung Validate") # for pick in self: # if self.env.user.is_accounting: # pick.approval_return_status = 'approved' # continue # else: # pick.approval_return_status = 'pengajuan1' # # action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_stock_return_note_wizard') # # if self.picking_type_code == 'outgoing': # if self.env.user.id in [3988, 3401, 20] or ( # self.env.user.has_group( # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin # ): # action['context'] = {'picking_ids': [x.id for x in self]} # return action # elif not self.env.user.has_group( # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin: # raise UserError('Harus Purchasing yang Ask Return') # else: # raise UserError('Harus Sales Admin yang Ask Return') # # elif self.picking_type_code == 'incoming': # if self.env.user.has_group('indoteknik_custom.group_role_purchasing') or ( # self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin # ): # action['context'] = {'picking_ids': [x.id for x in self]} # return action # elif not self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin: # raise UserError('Harus Sales Admin yang Ask Return') # else: # raise UserError('Harus Purchasing yang Ask Return') def calculate_line_no(self): for picking in self: name = picking.group_id.name for move in picking.move_ids_without_package: if picking.group_id.sale_id: order = self.env['sale.order'].search([('name', '=', name)], limit=1) else: order = self.env['purchase.order'].search([('name', '=', name)], limit=1) order_lines = order.order_line set_line = 0 for order_line in order_lines: if move.product_id == order_line.product_id: set_line = order_line.line_no break else: continue move.line_no = set_line for line in move.move_line_ids: line.line_no = set_line def _compute_summary_qty(self): for picking in self: sum_qty_detail = sum_qty_operation = count_line_detail = count_line_operation = 0 for detail in picking.move_line_ids_without_package: # detailed operations sum_qty_detail += detail.qty_done count_line_detail += 1 for operation in picking.move_ids_without_package: # operations sum_qty_operation += operation.product_uom_qty count_line_operation += 1 picking.summary_qty_detail = sum_qty_detail picking.count_line_detail = count_line_detail picking.summary_qty_operation = sum_qty_operation picking.count_line_operation = count_line_operation @api.onchange('picking_type_id') def _onchange_operation_type(self): self.is_internal_use = self.picking_type_id.is_internal_use return def validation_minus_onhand_quantity(self): bu_location_id = 49 for line in self.move_line_ids_without_package: quant = self.env['stock.quant'].search([ ('product_id', '=', line.product_id.id), ('location_id', '=', bu_location_id), ]) if ( self.picking_type_id.id == 29 and quant and line.location_id.id == bu_location_id and quant.inventory_quantity < line.product_uom_qty ): raise UserError('Quantity reserved lebih besar dari quantity onhand di product') def check_qty_done_stock(self): for line in self.move_line_ids_without_package: def check_qty_per_inventory(self, product, location): quant = self.env['stock.quant'].search([ ('product_id', '=', product.id), ('location_id', '=', location.id), ]) if quant: return quant.quantity return 0 qty_onhand = check_qty_per_inventory(self, line.product_id, line.location_id) if line.qty_done > qty_onhand: raise UserError('Quantity Done melebihi Quantity Onhand') def button_state_approve_md(self): group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) active_model = self.env.context.get('active_model') if self.env.user.id in users_in_group.mapped('id'): self.state_approve_md = 'done' else: raise UserError('Hanya MD yang bisa Approve') def button_state_pending_md(self): group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) active_model = self.env.context.get('active_model') if self.env.user.id in users_in_group.mapped('id'): self.state_approve_md = 'pending' else: raise UserError('Hanya MD yang bisa Approve') def button_validate(self): self.check_invoice_date() _logger.info("Kode Picking: %s", self.picking_type_id.code) _logger.info("Group ID: %s", self.group_id) _logger.info("Group ID ID: %s", self.group_id.id if self.group_id else None) _logger.info("Is Internal Use: %s", self.is_internal_use) threshold_datetime = waktu(2025, 4, 11, 6, 26) group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) active_model = self.env.context.get('active_model') if self.location_id.id == 47 and self.env.user.id not in users_in_group.mapped( 'id') and self.state_approve_md != 'done': self.state_approve_md = 'waiting' if self.state_approve_md != 'pending' else 'pending' self.env.cr.commit() raise UserError("Transfer dari gudang selisih harus di approve MD, Hubungi MD agar bisa di Validate") else: if self.location_id.id == 47 and self.env.user.id in users_in_group.mapped('id'): self.state_approve_md = 'done' if (len(self.konfirm_koli_lines) == 0 and 'BU/OUT/' in self.name and self.picking_type_code == 'outgoing' and self.create_date > threshold_datetime and not self.so_lama): raise UserError(_("Tidak ada Mapping koli! Harap periksa kembali.")) if (len(self.scan_koli_lines) == 0 and 'BU/OUT/' in self.name and self.picking_type_code == 'outgoing' and self.create_date > threshold_datetime and not self.so_lama): raise UserError(_("Tidak ada scan koli! Harap periksa kembali.")) # if self.driver_departure_date == False and 'BU/OUT/' in self.name and self.picking_type_code == 'outgoing': # raise UserError(_("Isi Driver Departure Date dulu sebelum validate")) if len(self.check_koli_lines) == 0 and 'BU/PICK/' in self.name: raise UserError(_("Tidak ada koli! Harap periksa kembali.")) if not self.linked_manual_bu_out and 'BU/PICK/' in self.name: raise UserError(_("Isi BU Out terlebih dahulu!")) if len(self.check_product_lines) == 0 and 'BU/PICK/' in self.name: raise UserError(_("Tidak ada Check Product! Harap periksa kembali.")) if self.total_koli > self.total_so_koli: raise UserError(_("Total Koli (%s) dan Total SO Koli (%s) tidak sama! Harap periksa kembali.") % (self.total_koli, self.t1otal_so_koli)) if not self.env.user.is_logistic_approver and self.env.context.get('active_model') == 'stock.picking': if self.origin and 'Return of' in self.origin: raise UserError("Button ini hanya untuk Logistik") if self.picking_type_code == 'internal': self.check_qty_done_stock() if self._name != 'stock.picking': return super(StockPicking, self).button_validate() if not self.picking_code: self.picking_code = self.env['ir.sequence'].next_by_code('stock.picking.code') or '0' if not self.arrival_time and 'BU/IN/' in self.name: raise UserError('Jam Kedatangan harus diisi') if self.picking_type_id.code == 'incoming' and self.group_id.id == False and self.is_internal_use == False: raise UserError(_('Tidak bisa Validate jika tidak dari Document SO / PO')) # if self.is_internal_use and not self.env.user.is_logistic_approver and self.location_id.id == 57 and self.approval_status == 'pengajuan2': # raise UserError("Harus di Approve oleh Logistik") if self.is_internal_use and self.approval_status in ['pengajuan1', '', False] and 'BU/IU' in self.name and self.is_bu_iu == True: raise UserError("Tidak Bisa Validate, set approval status ke approval logistik terlebih dahhulu") if self.is_internal_use and not self.env.user.is_logistic_approver and self.approval_status in ['pengajuan2'] and self.is_bu_iu == True and 'BU/IU' in self.name: raise UserError("Harus di Approve oleh Logistik") if self.is_internal_use and not self.env.user.is_accounting and self.approval_status in ['pengajuan1', '', False] and self.is_bu_iu == False: raise UserError("Harus di Approve oleh Accounting") if self.picking_type_id.id == 28 and not self.env.user.is_logistic_approver: raise UserError("Harus di Approve oleh Logistik") if self.location_dest_id.id == 47 and not self.env.user.is_purchasing_manager: raise UserError("Transfer ke gudang selisih harus di approve Rafly Hanggara") if self.is_internal_use and self.approval_status == 'pengajuan2': self.approval_status = 'approved' elif self.picking_type_id.code == 'incoming': self.approval_receipt_status = 'approved' for product in self.move_line_ids_without_package.product_id: if product: product.product_tmpl_id._create_solr_queue('_sync_product_stock_to_solr') for move_line in self.move_line_ids_without_package: if move_line.product_id: move_line.product_id.product_tmpl_id._create_solr_queue('_sync_product_stock_to_solr') if not self.date_reserved: current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.date_reserved = current_time # Validate Qty Demand Can't higher than Qty Product if self.location_dest_id.id == 58 and 'BU/INPUT/' in self.name: for move in self.move_ids_without_package: purchase_line = move.purchase_line_id if purchase_line: if purchase_line.product_qty < move.quantity_done: raise UserError( _("Quantity demand (%s) tidak bisa lebih besar dari qty product (%s) untuk produk %s") % ( move.quantity_done, purchase_line.product_qty, move.product_id.display_name ) ) self.validation_minus_onhand_quantity() self.responsible = self.env.user.id # self.send_koli_to_so() if self.picking_type_code == 'outgoing' and 'BU/OUT/' in self.name: self.check_koli() res = super(StockPicking, self).button_validate() # Penambahan link PO di Stock Journal untuk Picking BD for picking in self: if picking.name and 'BD/' in picking.name and picking.purchase_id: stock_journal = self.env['account.move'].search([ ('ref', 'ilike', picking.name + '%'), ('journal_id', '=', 3) # Stock Journal ID ], limit=1) if stock_journal: stock_journal.write({ 'purchase_order_id': picking.purchase_id.id }) self.date_done = datetime.datetime.utcnow() self.state_reserve = 'done' self.final_seq = 0 self.set_picking_code_out() self.send_koli_to_so() if (self.state_reserve == 'done' and self.picking_type_code == 'internal' and 'BU/PICK/' in self.name and self.linked_manual_bu_out): if not self.linked_manual_bu_out.date_reserved: current_datetime = datetime.datetime.utcnow() self.linked_manual_bu_out.date_reserved = current_datetime self.linked_manual_bu_out.message_post( body=f"Date Reserved diisi secara otomatis dari validasi BU/PICK {self.name}" ) if not self.env.context.get('skip_koli_check'): for picking in self: if picking.sale_id: all_koli_ids = picking.sale_id.koli_lines.filtered(lambda k: k.state != 'delivered').ids scanned_koli_ids = picking.scan_koli_lines.mapped('koli_id.id') missing_koli_ids = set(all_koli_ids) - set(scanned_koli_ids) if len(missing_koli_ids) > 0 and picking.picking_type_code == 'outgoing' and 'BU/OUT/' in picking.name: missing_koli_names = picking.sale_id.koli_lines.filtered( lambda k: k.id in missing_koli_ids and k.state != 'delivered').mapped('display_name') missing_koli_list = "\n".join(f"- {name}" for name in missing_koli_names) # Buat wizard modal warning wizard = self.env['warning.modal.wizard'].create({ 'message': f"Berikut Koli yang belum discan:\n{missing_koli_list}", 'picking_id': picking.id, }) return { 'type': 'ir.actions.act_window', 'res_model': 'warning.modal.wizard', 'view_mode': 'form', 'res_id': wizard.id, 'target': 'new', } self.send_mail_bills() if 'BU/PUT' in self.name: self.automatic_reserve_product() if self.tukar_guling_id: self.tukar_guling_id.update_doc_state() elif self.tukar_guling_po_id: self.tukar_guling_po_id.update_doc_state() return res def automatic_reserve_product(self): if self.state == 'done': po = self.env['purchase.order'].search([ ('name', '=', self.group_id.name) ]) for line in po.order_sales_match_line: if not line.bu_pick: continue line.bu_pick.action_assign() def check_invoice_date(self): for picking in self: if picking.picking_type_code != 'outgoing' or 'BU/OUT/' not in picking.name or picking.partner_id.id == 96868: continue invoice = self.env['account.move'].search( [('sale_id', '=', picking.sale_id.id), ('state', 'not in', ['draft', 'cancel']), ('move_type', '=', 'out_invoice')], limit=1) if not invoice: continue if not picking.so_lama and invoice and (not picking.date_doc_kirim or not invoice.invoice_date): raise UserError("Tanggal Kirim atau Tanggal Invoice belum diisi!") picking_date = fields.Date.to_date(picking.date_doc_kirim) invoice_date = fields.Date.to_date(invoice.invoice_date) if picking_date != invoice_date and picking.update_date_doc_kirim_add 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') )) def set_picking_code_out(self): for picking in self: # Check if picking meets criteria is_bu_pick = picking.picking_type_code == 'internal' and 'BU/PICK/' in picking.name if not is_bu_pick: continue # Find matching outgoing transfers bu_out_transfers = self.search([ ('name', 'like', 'BU/OUT/%'), ('sale_id', '=', picking.sale_id.id), ('picking_type_code', '=', 'outgoing'), ('picking_code', '=', False), ('state', 'not in', ['done', 'cancel']) ]) # Assign sequence code to each matching transfer for transfer in bu_out_transfers: transfer.picking_code = self.env['ir.sequence'].next_by_code('stock.picking.code') def check_koli(self): for picking in self: sale_id = picking.sale_id for koli_lines in picking.scan_koli_lines: if koli_lines.koli_id.sale_order_id != sale_id: raise UserError('Koli tidak sesuai') def send_koli_to_so(self): for picking in self: if picking.picking_type_code == 'internal' and 'BU/PICK/' in picking.name: for koli_line in picking.check_koli_lines: existing_koli = self.env['sales.order.koli'].search([ ('sale_order_id', '=', picking.sale_id.id), ('picking_id', '=', picking.id), ('koli_id', '=', koli_line.id) ], limit=1) if not existing_koli: self.env['sales.order.koli'].create({ 'sale_order_id': picking.sale_id.id, 'picking_id': picking.id, 'koli_id': koli_line.id }) if picking.picking_type_code == 'outgoing' and 'BU/OUT/' in picking.name: if picking.state == 'done': for koli_line in picking.scan_koli_lines: existing_koli = self.env['sales.order.koli'].search([ ('sale_order_id', '=', picking.sale_id.id), ('koli_id', '=', koli_line.koli_id.koli_id.id) ], limit=1) existing_koli.state = 'delivered' def check_qty_done_stock(self): for line in self.move_line_ids_without_package: def check_qty_per_inventory(self, product, location): quant = self.env['stock.quant'].search([ ('product_id', '=', product.id), ('location_id', '=', location.id), ]) if quant: return quant.quantity return 0 qty_onhand = check_qty_per_inventory(self, line.product_id, line.location_id) if line.qty_done > qty_onhand: raise UserError('Quantity Done melebihi Quantity Onhand') def send_mail_bills(self): if self.picking_type_code == 'incoming' and self.purchase_id: template = self.env.ref('indoteknik_custom.mail_template_invoice_po_document') if template and self.purchase_id: # Render email body email_values = template.sudo().generate_email( res_ids=[self.purchase_id.id], fields=['body_html'] ) rendered_body = email_values.get(self.purchase_id.id, {}).get('body_html', '') # Render report dengan XML ID report = self.env.ref('purchase.action_report_purchase_order') # Gunakan XML ID laporan if not report: raise UserError("Laporan dengan XML ID 'purchase.action_report_purchase_order' tidak ditemukan.") # Render laporan ke PDF pdf_content, _ = report._render_qweb_pdf([self.purchase_id.id]) report_content = base64.b64encode(pdf_content).decode('utf-8') # Kirim email menggunakan template email_sent = template.sudo().send_mail(self.purchase_id.id, force_send=True) if email_sent: # Buat attachment untuk laporan attachment = self.env['ir.attachment'].create({ 'name': self.purchase_id.name or "Laporan Invoice.pdf", 'type': 'binary', 'datas': report_content, 'res_model': 'purchase.order', 'res_id': self.purchase_id.id, 'mimetype': 'application/pdf', }) # Siapkan data untuk mail.compose.message compose_values = { 'subject': "Pengiriman Email Invoice", 'body': rendered_body, 'attachment_ids': [(4, attachment.id)], 'res_id': self.purchase_id.id, 'model': 'purchase.order', } # Buat mail.compose.message compose_message = self.env['mail.compose.message'].create(compose_values) # Kirim pesan melalui wizard compose_message.action_send_mail() return True def action_cancel(self): if not self.env.user.is_logistic_approver and ( self.env.context.get('active_model') == 'stock.picking' or self.env.context.get( 'active_model') == 'stock.picking.type'): if self.origin and 'Return of' in self.origin: raise UserError("Button ini hanya untuk Logistik") if not self.env.user.has_group('indoteknik_custom.group_role_it') and not self.env.user.has_group( 'indoteknik_custom.group_role_logistic') and self.picking_type_code == 'outgoing': raise UserError("Button ini hanya untuk Logistik") res = super(StockPicking, self).action_cancel() return res @api.model def create(self, vals): self._use_faktur(vals) records = super(StockPicking, self).create(vals) # Panggil sync_sale_line setelah record dibuat # records.sync_sale_line(vals) return records def sync_sale_line(self, vals): # Pastikan kita bekerja dengan record yang sudah ada for picking in self: if picking.picking_type_code == 'internal' and 'BU/PICK/' in picking.name: for line in picking.move_ids_without_package: if line.product_id and picking.sale_id: sale_line = self.env['sale.order.line'].search([ ('product_id', '=', line.product_id.id), ('order_id', '=', picking.sale_id.id) ], limit=1) # Tambahkan limit=1 untuk efisiensi if sale_line: line.sale_line_id = sale_line.id def write(self, vals): if 'linked_manual_bu_out' in vals: for record in self: if (record.picking_type_code == 'internal' and 'BU/PICK/' in record.name): # Jika menghapus referensi (nilai di-set False/None) if record.linked_manual_bu_out and not vals['linked_manual_bu_out']: record.linked_manual_bu_out.state_packing = 'not_packing' # Jika menambahkan referensi baru elif vals['linked_manual_bu_out']: new_picking = self.env['stock.picking'].browse(vals['linked_manual_bu_out']) new_picking.state_packing = 'packing_done' self._use_faktur(vals) self.sync_sale_line(vals) # for picking in self: # # Periksa apakah kondisi terpenuhi saat data diubah # if (vals.get('picking_type_code', picking.picking_type_code) == 'incoming' and # vals.get('location_dest_id', picking.location_dest_id.id) == 58): # if 'name' in vals or picking.name.startswith('BU/IN/'): # name_to_modify = vals.get('name', picking.name) # if name_to_modify.startswith('BU/IN/'): # vals['name'] = name_to_modify.replace('BU/IN/', 'BU/INPUT/', 1) # if (vals.get('picking_type_code', picking.picking_type_code) == 'internal' and # vals.get('location_id', picking.location_id.id) == 58): # name_to_modify = vals.get('name', picking.name) # if name_to_modify.startswith('BU/INT'): # new_name = name_to_modify.replace('BU/INT', 'BU/IN', 1) # # Periksa apakah nama sudah ada # if self.env['stock.picking'].search_count( # [('name', '=', new_name), ('company_id', '=', picking.company_id.id)]) > 0: # new_name = f"{new_name}-DUP" # vals['name'] = new_name return super(StockPicking, self).write(vals) def _use_faktur(self, vals): if vals.get('efaktur_id', False): self.env['vit.efaktur'].search( [ ('id', '=', vals['efaktur_id']) ], limit=1 ).is_used = True if self.efaktur_id.id != vals['efaktur_id']: self.efaktur_id.is_used = False return True def compute_delivery_status(self): for picking in self: if not picking.driver_departure_date and picking.picking_code: picking.delivery_status = "Sedang Dikemas" elif picking.driver_departure_date and not picking.driver_arrival_date: picking.delivery_status = "Dalam Perjalanan" elif picking.driver_departure_date and picking.driver_arrival_date and picking.carrier_id == 1: picking.delivery_status = "Diterima Konsumen" elif picking.driver_departure_date and picking.driver_arrival_date and picking.carrier_id != 1: picking.delivery_status = "Diterima Ekspedisi" else: picking.delivery_status = "Diterima Konsumen" def create_manifest_data(self, description, object): datetime_str = '' if isinstance(object, datetime.datetime): jakarta_timezone = pytz.timezone('Asia/Jakarta') datetime_str = object.replace(tzinfo=pytz.utc).astimezone(jakarta_timezone).strftime('%Y-%m-%d %H:%M:%S') return { 'description': description, 'datetime': datetime_str } def get_manifests(self): if self.waybill_id and len(self.waybill_id.manifest_ids) > 0: return [self.create_manifest_data(x.description, x.datetime) for x in self.waybill_id.manifest_ids] status_mapping = { 'pickup': { 'arrival': 'Sudah diambil', 'departure': 'Siap diambil', 'prepare': 'Sedang disiapkan' }, 'delivery': { 'arrival': 'Sudah sampai', 'departure': 'Sedang dikirim', 'prepare': 'Menunggu pickup', } } status_key = 'delivery' if self.carrier_id.id == 32: status_key = 'pickup' manifest_datas = [] departure_date = self.driver_departure_date arrival_date = self.sj_return_date if self.sj_return_date else self.driver_arrival_date status = status_mapping.get(status_key) if not status: return manifest_datas if arrival_date or self.sj_return_date: manifest_datas.append(self.create_manifest_data(status['arrival'], arrival_date)) if departure_date: manifest_datas.append(self.create_manifest_data(status['departure'], departure_date)) manifest_datas.append(self.create_manifest_data(status['prepare'], self.create_date)) return manifest_datas def get_tracking_detail(self): 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, 'code': move_line.product_id.default_code, 'qty': move_line.qty_done, 'image': self.env['ir.attachment'].api_image('product.template', 'image_128', move_line.product_id.product_tmpl_id.id), }) response = { 'delivery_order': { 'name': self.name, '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 '-', 'delivery_status': None, 'eta': self.generate_eta_delivery(), 'is_biteship': True if self.biteship_id else False, '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: try: histori = self.get_manifest_biteship() 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 += sale_order_delay.days_delayed day_end += sale_order_delay.days_delayed elif sale_order_delay.status == 'early': day_start -= sale_order_delay.days_delayed 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", []) response['delivered'] = ( histori.get("delivered", False) or self.sj_return_date != False or self.driver_arrival_date != False ) response['status'] = self._map_status_biteship(histori.get("delivered")) return response except Exception as e: # Kalau ada error di biteship, log dan fallback ke Odoo _logger.warning("Biteship error pada DO %s: %s", self.name, str(e)) # biarkan lanjut ke kondisi di bawah (pakai Odoo waybill_id) if not self.waybill_id or len(self.waybill_id.manifest_ids) == 0: response['delivered'] = self.sj_return_date != False or self.driver_arrival_date != False return response response['delivery_order']['receiver_name'] = self.waybill_id.receiver_name response['delivery_order']['receiver_city'] = self.waybill_id.receiver_city response['delivery_status'] = self.waybill_id._get_history('delivery_status') response['delivered'] = self.waybill_id.delivered return response def get_manifest_biteship(self): api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } manifests = [] try: # Kirim request ke Biteship response = requests.get(_biteship_url + '/trackings/' + self.biteship_tracking_id, headers=headers, json=manifests) result = response.json() # description = { # 'confirmed' : 'Indoteknik telah melakukan permintaan pick-up', # 'allocated' : 'Kurir akan melakukan pick-up pesanan', # 'picking_up' : 'Kurir sedang dalam perjalanan menuju lokasi pick-up', # 'picked' : 'Pesanan sudah di pick-up kurir '+result.get("courier", {}).get("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 {result.get("destination", {}).get("contact_name", "")}' # } if (result.get('success') == True): history = result.get("history", []) status = result.get("status", "") for entry in reversed(history): manifests.append({ "status": re.sub(r'[^a-zA-Z0-9\s]', ' ', entry["status"]).lower().capitalize(), "datetime": self._convert_to_local_time(entry["updated_at"]), "description": self._get_biteship_status_description(entry["status"], result), }) return { "manifests": manifests, "delivered": status } 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)} # ACTION GET TRACKING MANUAL BITESHIP # def action_sync_biteship_tracking(self): # for picking in self: # if not picking.biteship_id: # raise UserError("Tracking Biteship tidak tersedia.") # histori = picking.get_manifest_biteship() # updated_fields = {} # seen_logs = set() # manifests = sorted(histori.get("manifests", []), key=lambda m: m.get("datetime") or "") # for manifest in manifests: # status = manifest.get("status", "").lower() # dt_str = manifest.get("datetime") # desc = manifest.get("description") # dt = False # try: # dt = picking._convert_to_utc_datetime(dt_str) # _logger.info(f"[Biteship Sync] Berhasil parse datetime: {dt_str} -> {dt}") # except Exception as e: # _logger.warning(f"[Biteship Sync] Gagal parse datetime: {e}") # continue # # Update tanggal ke field (pastikan naive datetime UTC) # if status == "picked" and dt and not picking.driver_departure_date: # updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt) # if status == "delivered" and dt and not picking.driver_arrival_date: # updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt) # # Buat log unik dengan waktu lokal Asia/Jakarta # if dt and desc: # try: # dt_local = parser.parse(dt_str).replace(tzinfo=None) # except Exception as e: # _logger.warning(f"[Biteship Sync] Gagal parse dt_str untuk log: {e}") # dt_local = dt # fallback # desc_clean = ' '.join(desc.strip().split()) # log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}: {desc_clean}" # if not picking._has_existing_log(log_line): # picking.message_post(body=log_line) # seen_logs.add(log_line) # if updated_fields: # picking.write(updated_fields) def action_open_biteship_tracking(self): self.ensure_one() if not self.biteship_courier_link: raise UserError("Biteship tracking link tidak tersedia.") return { 'type': 'ir.actions.act_url', 'url': self.biteship_courier_link, 'target': 'new', } def _get_biteship_status_description(self, status, data=None): data = data or {} courier = data.get("courier", {}).get("company", "") contact_name = data.get("destination", {}).get("contact_name", "") description_map = { 'confirmed': 'Indoteknik telah melakukan permintaan pick-up', 'allocated': 'Kurir akan melakukan pick-up pesanan', 'picking_up': 'Kurir sedang dalam perjalanan menuju lokasi pick-up', 'picked': f'Pesanan sudah di pick-up kurir {courier}', 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli', 'delivered': f'Pesanan telah sampai dan diterima oleh {contact_name}', 'return_in_transit': 'Pesanan dalam perjalanan kembali ke pengirim', 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman', 'rejected': 'Pesanan ditolak, silakan hubungi Biteship', 'courier_not_found': 'Pesanan dibatalkan karena tidak ada kurir tersedia', 'returned': 'Pesanan berhasil dikembalikan', 'disposed': 'Pesanan sudah dimusnahkan', 'cancelled': 'Pesanan dibatalkan oleh sistem atau pengguna', } return description_map.get(status, f"Status '{status}' diterima dari Biteship") def log_biteship_event_from_webhook(self, status, timestamp, description, extra_data=None): self.ensure_one() updated_fields = {} try: dt = self._convert_to_utc_datetime(timestamp) except Exception as e: _logger.warning(f"[Webhook] Gagal konversi waktu: {e}") dt = datetime.utcnow() # Penanganan status pengiriman if status == "picked" and not self.driver_departure_date: updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt) if status == "delivered" and not self.driver_arrival_date: updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt) shipping_status = self._map_status_biteship(status) if shipping_status and self.shipping_status != shipping_status: updated_fields["shipping_status"] = shipping_status # Penanganan extra data dari webhook if extra_data: # Informasi kurir if extra_data.get("courier_driver_name"): updated_fields["biteship_driver_name"] = extra_data["courier_driver_name"] if extra_data.get("courier_driver_phone"): updated_fields["biteship_driver_phone"] = extra_data["courier_driver_phone"] if extra_data.get("courier_driver_plate_number"): updated_fields["biteship_driver_plate_number"] = extra_data["courier_driver_plate_number"] if extra_data.get("courier_link"): updated_fields["biteship_courier_link"] = extra_data["courier_link"] # Informasi harga if extra_data.get("order_price"): updated_fields["biteship_shipping_price"] = extra_data["order_price"] # Status mentah dari Biteship if extra_data.get("status"): updated_fields["biteship_shipping_status"] = extra_data["status"] # Tambahan untuk handle order.waybill_id if extra_data.get("tracking_id"): updated_fields["biteship_tracking_id"] = extra_data["tracking_id"] updated_fields["delivery_tracking_no"] = extra_data["tracking_id"] if extra_data.get("waybill_id"): updated_fields["biteship_waybill_id"] = extra_data["waybill_id"] # Konversi waktu lokal untuk log try: dt_parsed = parser.parse(timestamp) if dt_parsed.tzinfo is None: dt_parsed = dt_parsed.replace(tzinfo=pytz.utc) dt_local = dt_parsed.astimezone(pytz.timezone("Asia/Jakarta")) except Exception: dt_local = dt.astimezone(pytz.timezone("Asia/Jakarta")) # Format pesan log desc_clean = ' '.join(description.strip().split()) log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}:
{desc_clean}" # Hindari log duplikat if not self._has_existing_log(log_line): biteship_user = self.env['res.users'].sudo().browse(15710) # ID live # biteship_user = self.env['res.users'].sudo().browse(15710) # ID user (cek di db) self.sudo().message_post( body=log_line, author_id=biteship_user.partner_id.id ) # Update field-field terkait if updated_fields: self.write(updated_fields) _logger.info(f"[Webhook] Updated fields on picking {self.name}: {updated_fields}") def _has_existing_log(self, log_line): self.ensure_one() self.env.cr.execute(""" SELECT 1 FROM mail_message WHERE model = %s AND res_id = %s AND subtype_id IS NOT NULL AND body ILIKE %s LIMIT 1 """, (self._name, self.id, f"%{log_line}%")) return self.env.cr.fetchone() is not None # Untuk internal Odoo (mengembalikan naive UTC datetime untuk disimpan ke DB) def _convert_to_utc_datetime(self, iso_date): try: if isinstance(iso_date, str): waktu = parser.parse(iso_date) else: waktu = iso_date if waktu.tzinfo is None: waktu = waktu.replace(tzinfo=pytz.utc) utc_dt = waktu.astimezone(pytz.utc).replace(tzinfo=None) return utc_dt except Exception as e: _logger.warning(f"[Biteship] Gagal konversi waktu UTC: {e}") return False # Untuk tampilan di API atau kebutuhan web (mengembalikan string waktu lokal) def _convert_to_local_time(self, iso_date): try: if isinstance(iso_date, str): waktu = parser.parse(iso_date) else: waktu = iso_date if waktu.tzinfo is None: waktu = waktu.replace(tzinfo=pytz.utc) local_tz = pytz.timezone("Asia/Jakarta") local_dt = waktu.astimezone(local_tz) return local_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception as e: return str(e) def _map_status_biteship(self, status): status_mapping = { "confirmed": "pending", "scheduled": "pending", "allocated": "pending", "picking_up": "pending", "picked": "shipment", "dropping_off": "shipment", "delivered": "completed", "return_in_transit": "returning", "on_hold": "on_hold", "rejected": "cancelled", "courier_not_found": "cancelled", "returned": "returned", "disposed": "disposed", "cancelled": "cancelled" } return status_mapping.get(status, "Hubungi Admin") def generate_eta_delivery(self): current_date = datetime.datetime.now() 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 formatted_fastest_eta = fastest_eta.strftime(format_time_fastest) formatted_longest_eta = longest_eta.strftime(format_time) return f'{formatted_fastest_eta} - {formatted_longest_eta}' class CheckProduct(models.Model): _name = 'check.product' _description = 'Check Product' _order = 'picking_id, id' _inherit = ['barcodes.barcode_events_mixin'] picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) product_id = fields.Many2one('product.product', string='Product') quantity = fields.Float(string='Quantity') status = fields.Char(string='Status', compute='_compute_status') code_product = fields.Char(string='Code Product') @api.onchange('code_product') def _onchange_code_product(self): if not self.code_product: return # Cari product berdasarkan default_code, barcode, atau barcode_box product = self.env['product.product'].search([ '|', ('default_code', '=', self.code_product), '|', ('barcode', '=', self.code_product), ('barcode_box', '=', self.code_product) ], limit=1) if not product: raise UserError("Product tidak ditemukan") # Jika scan barcode_box, set quantity sesuai qty_pcs_box if product.barcode_box == self.code_product: self.product_id = product.id self.quantity = product.qty_pcs_box self.code_product = product.default_code or product.barcode # return { # 'warning': { # 'title': 'Info',8994175025871 # 'message': f'Product box terdeteksi. Quantity di-set ke {product.qty_pcs_box}' # } # } else: # Jika scan biasa self.product_id = product.id self.code_product = product.default_code or product.barcode self.quantity = 1 def unlink(self): """ Override unlink untuk: 1. Simpan picking dan product_id yang terdampak sebelum delete. 2. Hapus record check.product. 3. Reset quantity_done untuk product yang udah gak ada di check_product_lines. 4. Sync ulang product yang masih ada. """ # Step 1: ambil data sebelum hapus pickings = self.mapped('picking_id') deleted_product_ids = self.mapped('product_id') # Step 2: hapus record result = super(CheckProduct, self).unlink() # Step 3: update masing-masing picking for picking in pickings: # pastikan picking masih valid (kadang record udah kehapus bareng cascade) if not picking.exists(): continue remaining_product_ids = picking.check_product_lines.mapped('product_id') removed_product_ids = deleted_product_ids - remaining_product_ids # Reset quantity_done untuk produk yang udah gak ada if removed_product_ids: moves_to_reset = picking.move_line_ids_without_package.filtered( lambda m: m.product_id in removed_product_ids ) for move_line in moves_to_reset: move_line.qty_done = 0.0 # Step 4: sync ulang product yang masih ada if remaining_product_ids: self._sync_check_product_to_moves(picking) return result @api.depends('quantity') def _compute_status(self): for record in self: moves = record.picking_id.move_ids_without_package.filtered( lambda move: move.product_id.id == record.product_id.id ) total_qty_in_moves = sum(moves.mapped('product_uom_qty')) if record.quantity < total_qty_in_moves: record.status = 'Pending' else: record.status = 'Done' def create(self, vals): # Create the record record = super(CheckProduct, self).create(vals) # Ensure uniqueness after creation if not self.env.context.get('skip_consolidate'): record.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return record def write(self, vals): # Write changes to the record result = super(CheckProduct, self).write(vals) # Ensure uniqueness after writing if not self.env.context.get('skip_consolidate'): self.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return result def _sync_check_product_to_moves(self, picking): """ Sinkronisasi qty_done di move_line_ids_without_package berdasarkan total quantity dari check_product_lines per product_id, dan distribusikan ke move_line berdasarkan urutan rack_level ascending. """ for product_id in picking.check_product_lines.mapped('product_id'): # Hitung total quantity dari check_product_lines untuk produk ini remaining_qty = sum( line.quantity for line in picking.check_product_lines.filtered(lambda l: l.product_id == product_id) ) # Ambil move_line untuk product_id ini dan urutkan berdasarkan rack_level ASC move_lines = picking.move_line_ids_without_package.filtered( lambda m: m.product_id == product_id ) move_lines = sorted( move_lines, key=lambda m: m.location_id.rack_level or 0 # kalau rack_level kosong, dianggap 0 ) for move_line in move_lines: if remaining_qty <= 0: move_line.qty_done = 0.0 continue needed = move_line.product_uom_qty assigned_qty = min(needed, remaining_qty) move_line.qty_done = assigned_qty remaining_qty -= assigned_qty # (Opsional) Log biar bisa dilacak # _logger.info(f"[SYNC] {picking.name} - {product_id.display_name}: Sisa {remaining_qty}") def _consolidate_duplicate_lines(self): """ Consolidate duplicate lines with the same product_id under the same picking_id and sync the total quantity to related moves. """ for picking in self.mapped('picking_id'): lines_to_remove = self.env['check.product'] # Recordset untuk menyimpan baris yang akan dihapus product_lines = picking.check_product_lines.filtered(lambda line: line.product_id) # Group lines by product_id product_groups = {} for line in product_lines: product_groups.setdefault(line.product_id.id, []).append(line) for product_id, lines in product_groups.items(): if len(lines) > 1: # Consolidate duplicate lines first_line = lines[0] total_quantity = sum(line.quantity for line in lines) # Update the first line's quantity first_line.with_context(skip_consolidate=True).write({'quantity': total_quantity}) # Add the remaining lines to the lines_to_remove recordset lines_to_remove |= self.env['check.product'].browse([line.id for line in lines[1:]]) # Perform unlink after consolidation if lines_to_remove: lines_to_remove.unlink() # Sync total quantities to moves self._sync_check_product_to_moves(picking) @api.onchange('product_id', 'quantity') def check_product_validity(self): for record in self: if not record.picking_id or not record.product_id: continue # Filter moves related to the selected product moves = record.picking_id.move_ids_without_package.filtered( lambda move: move.product_id.id == record.product_id.id ) if not moves: raise UserError(( "The product '%s' tidak ada di operations. " ) % record.product_id.display_name) total_qty_in_moves = sum(moves.mapped('product_uom_qty')) # Find existing lines for the same product, excluding the current line existing_lines = record.picking_id.check_product_lines.filtered( lambda line: line.product_id == record.product_id ) if existing_lines: # Get the first existing line first_line = existing_lines[0] # Calculate the total quantity after addition total_quantity = sum(existing_lines.mapped('quantity')) if total_quantity > total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity demand." ) % (record.product_id.display_name)) else: # Check if the quantity exceeds the allowed total if record.quantity == total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity demand." ) % (record.product_id.display_name)) # Set the quantity to the entered value record.quantity = record.quantity class BarcodeProduct(models.Model): _name = 'barcode.product' _description = 'Barcode Product' _order = 'picking_id, id' picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) product_id = fields.Many2one('product.product', string='Product', required=True) barcode = fields.Char(string='Barcode') def check_duplicate_barcode(self): barcode_product = self.env['product.product'].search([('barcode', '=', self.barcode)]) if barcode_product: raise UserError('Barcode sudah digunakan {}'.format(barcode_product.display_name)) barcode_box = self.env['product.product'].search([('barcode_box', '=', self.barcode)]) if barcode_box: raise UserError('Barcode box sudah digunakan {}'.format(barcode_box.display_name)) @api.constrains('barcode') def send_barcode_to_product(self): for record in self: record.check_duplicate_barcode() if record.barcode and not record.product_id.barcode: record.product_id.barcode = record.barcode else: raise UserError('Barcode sudah terisi') class CheckKoli(models.Model): _name = 'check.koli' _description = 'Check Koli' _order = 'picking_id, id' _rec_name = 'koli' picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) koli = fields.Char(string='Koli') reserved_id = fields.Many2one('stock.picking', string='Reserved Picking') check_koli_progress = fields.Char( string="Progress Check Koli" ) @api.constrains('koli') def _check_koli_progress(self): for check in self: if check.picking_id: all_checks = self.env['check.koli'].search([('picking_id', '=', check.picking_id.id)], order='id') if all_checks: check_index = list(all_checks).index(check) + 1 # Nomor urut check total_so_koli = len(all_checks) check.check_koli_progress = f"{check_index}/{total_so_koli}" if total_so_koli else "0/0" class ScanKoli(models.Model): _name = 'scan.koli' _description = 'Scan Koli' _order = 'picking_id, id' _rec_name = 'koli_id' picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) koli_id = fields.Many2one('sales.order.koli', string='Koli') scan_koli_progress = fields.Char( string="Progress Scan Koli", compute="_compute_scan_koli_progress" ) code_koli = fields.Char(string='Code Koli') @api.onchange('code_koli') def _onchange_code_koli(self): if self.code_koli: koli = self.env['sales.order.koli'].search([('koli_id.koli', '=', self.code_koli)], limit=1) if koli: self.write({'koli_id': koli.id}) else: raise UserError('Koli tidak ditemukan') # def _compute_scan_koli_progress(self): # for scan in self: # if scan.picking_id: # all_scans = self.env['scan.koli'].search([('picking_id', '=', scan.picking_id.id)], order='id') # if all_scans: # scan_index = list(all_scans).index(scan) + 1 # Nomor urut scan # total_so_koli = scan.picking_id.total_so_koli # scan.scan_koli_progress = f"{scan_index}/{total_so_koli}" if total_so_koli else "0/0" @api.onchange('koli_id') def _onchange_koli_compare_with_konfirm_koli(self): if not self.koli_id: return if not self.picking_id.konfirm_koli_lines: raise UserError(_('Mapping Koli Harus Diisi!')) koli_picking = self.koli_id.picking_id._origin konfirm_pick_ids = [ line.pick_id._origin for line in self.picking_id.konfirm_koli_lines if line.pick_id ] if koli_picking not in konfirm_pick_ids: raise UserError(_('Koli tidak sesuai dengan mapping koli, pastikan picking terkait benar!')) @api.constrains('picking_id', 'koli_id') def _check_duplicate_koli(self): for record in self: if record.koli_id: existing_koli = self.search([ ('picking_id', '=', record.picking_id.id), ('koli_id', '=', record.koli_id.id), ('id', '!=', record.id) ]) if existing_koli: raise ValidationError(f"⚠️ Koli '{record.koli_id.display_name}' sudah discan untuk picking ini!") def unlink(self): picking_ids = set(self.mapped('koli_id.picking_id.id')) for scan in self: koli = scan.koli_id.koli_id if koli: koli.reserved_id = False for picking_id in picking_ids: remaining_scans = self.env['sales.order.koli'].search_count([ ('koli_id.picking_id', '=', picking_id) ]) delete_koli = len(self.filtered(lambda rec: rec.koli_id.picking_id.id == picking_id)) if remaining_scans == delete_koli: picking = self.env['stock.picking'].browse(picking_id) picking.linked_out_picking_id = False else: raise UserError( _("Tidak dapat menghapus scan koli, karena masih ada scan koli lain yang tersisa untuk picking ini.")) for picking_id in picking_ids: self._reset_qty_done_if_no_scan(picking_id) # self.check_koli_not_balance() return super(ScanKoli, self).unlink() @api.onchange('koli_id', 'scan_koli_progress') def onchange_koli_id(self): if not self.koli_id: return for scan in self: if scan.koli_id.koli_id.picking_id.group_id.id != scan.picking_id.group_id.id: scan.koli_id.koli_id.reserved_id = scan.picking_id.id.origin scan.koli_id.koli_id.picking_id.linked_out_picking_id = scan.picking_id.id.origin def _compute_scan_koli_progress(self): for scan in self: if not scan.picking_id: scan.scan_koli_progress = "0/0" continue try: all_scans = self.env['scan.koli'].search([('picking_id', '=', scan.picking_id.id)], order='id') if all_scans: scan_index = list(all_scans).index(scan) + 1 total_so_koli = scan.picking_id.total_so_koli or 0 scan.scan_koli_progress = f"{scan_index}/{total_so_koli}" else: scan.scan_koli_progress = "0/0" except Exception: # Fallback in case of any error scan.scan_koli_progress = "0/0" @api.constrains('picking_id', 'picking_id.total_so_koli') def _check_koli_validation(self): for scan in self.picking_id.scan_koli_lines: scan.koli_id.koli_id.reserved_id = scan.picking_id.id scan.koli_id.koli_id.picking_id.linked_out_picking_id = scan.picking_id.id total_scans = len(self.picking_id.scan_koli_lines) if total_scans != self.picking_id.total_so_koli: raise UserError(_("Jumlah scan koli tidak sama dengan total SO koli!")) # def check_koli_not_balance(self): # for scan in self: # total_scancs = self.env['scan.koli'].search_count([('picking_id', '=', scan.picking_id.id), ('id', '!=', scan.id)]) # if total_scancs != scan.picking_id.total_so_koli: # raise UserError(_("Jumlah scan koli tidak sama dengan total SO koli!")) @api.onchange('koli_id') def _onchange_koli_id(self): if not self.koli_id: return source_koli_so = self.picking_id.group_id.id source_koli = self.koli_id.picking_id.group_id.id if source_koli_so != source_koli: raise UserError(_('Koli tidak sesuai, pastikan picking terkait benar!')) @api.constrains('koli_id') def _send_product_from_koli_id(self): if not self.koli_id: return koli_count_by_picking = defaultdict(int) for scan in self: koli_count_by_picking[scan.koli_id.picking_id.id] += 1 for picking_id, total_koli in koli_count_by_picking.items(): picking = self.env['stock.picking'].browse(picking_id) if total_koli == picking.quantity_koli: pick_moves = self.env['stock.move.line'].search([('picking_id', '=', picking_id)]) out_moves = self.env['stock.move.line'].search([('picking_id', '=', picking.linked_out_picking_id.id)]) for pick_move in pick_moves: corresponding_out_moves = out_moves.filtered(lambda m: m.product_id == pick_move.product_id) if len(corresponding_out_moves) == 1: corresponding_out_moves.qty_done += pick_move.qty_done elif len(corresponding_out_moves) > 1: qty_koli = pick_move.qty_done for out_move in corresponding_out_moves: if qty_koli <= 0: break # ambil sesuai kebutuhan atau sisa qty qty_to_assign = min(qty_koli, out_move.product_uom_qty) out_move.qty_done += qty_to_assign qty_koli -= qty_to_assign def _reset_qty_done_if_no_scan(self, picking_id): product_bu_pick = self.env['stock.move.line'].search([('picking_id', '=', picking_id)]) for move in product_bu_pick: product_bu_out = self.env['stock.move.line'].search( [('picking_id', '=', self.picking_id.id), ('product_id', '=', move.product_id.id)]) for bu_out in product_bu_out: bu_out.qty_done -= move.qty_done # if remaining_scans == 0: # picking = self.env['stock.picking'].browse(picking_id) # picking.move_line_ids_without_package.write({'qty_done': 0}) # picking.message_post(body=f"⚠ qty_done direset ke 0 untuk Picking {picking.name} karena tidak ada scan.koli yang tersisa.") # return remaining_scans class KonfirmKoli(models.Model): _name = 'konfirm.koli' _description = 'Konfirm Koli' _order = 'picking_id, id' _rec_name = 'pick_id' picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) pick_id = fields.Many2one('stock.picking', string='Pick') product_id = fields.Many2one('product.product', string='Product') qty_done = fields.Float(string='Qty Done') @api.constrains('pick_id') def _check_duplicate_pick_id(self): for rec in self: exist = self.search([ ('pick_id', '=', rec.pick_id.id), ('picking_id', '=', rec.picking_id.id), ('id', '!=', rec.id), ]) if exist: raise UserError(f"⚠️ '{rec.pick_id.display_name}' sudah discan untuk picking ini!") class WarningModalWizard(models.TransientModel): _name = 'warning.modal.wizard' _description = 'Peringatan Koli Belum Diperiksa' name = fields.Char(default="⚠️ Perhatian!") message = fields.Text() picking_id = fields.Many2one('stock.picking') def action_continue(self): if self.picking_id: return self.picking_id.with_context(skip_koli_check=True).button_validate() return {'type': 'ir.actions.act_window_close'}