diff options
| author | it-fixcomart <it@fixcomart.co.id> | 2025-07-28 15:09:55 +0700 |
|---|---|---|
| committer | it-fixcomart <it@fixcomart.co.id> | 2025-07-28 15:09:55 +0700 |
| commit | d15ce4e186e2b77f01e8dfd03886298cc733d4c1 (patch) | |
| tree | 1b32a4c29c4fcea85070fcecb5b77a7d55d30029 /indoteknik_custom/models/stock_picking.py | |
| parent | deba962d7368a5c4e30441b5a640102608e3dde6 (diff) | |
| parent | 36a53535dbdc5777266fd9276b4c557259dab6be (diff) | |
<hafid> merging odoo-backup
Diffstat (limited to 'indoteknik_custom/models/stock_picking.py')
| -rw-r--r-- | indoteknik_custom/models/stock_picking.py | 678 |
1 files changed, 487 insertions, 191 deletions
diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py index 0fcb7ca1..3e152f10 100644 --- a/indoteknik_custom/models/stock_picking.py +++ b/indoteknik_custom/models/stock_picking.py @@ -19,15 +19,19 @@ import re _logger = logging.getLogger(__name__) _biteship_url = "https://api.biteship.com/v1" -_biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA" +biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo" -# _biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo" +# biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA" class StockPicking(models.Model): _inherit = 'stock.picking' _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) @@ -121,7 +125,7 @@ class StockPicking(models.Model): 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') + 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([ @@ -170,6 +174,10 @@ class StockPicking(models.Model): area_name = fields.Char(string="Area", compute="_compute_area_name") + # def _get_biteship_api_key(self): + # # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test') + # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live') + @api.depends('real_shipping_id.kecamatan_id', 'real_shipping_id.kota_id') def _compute_area_name(self): for record in self: @@ -269,14 +277,27 @@ class StockPicking(models.Model): # Biteship Section biteship_id = fields.Char(string="Biteship Respon ID") - biteship_tracking_id = fields.Char(string="Biteship Trackcking ID") + biteship_tracking_id = fields.Char(string="Biteship Tracking ID") biteship_waybill_id = fields.Char(string="Biteship Waybill ID") - # estimated_ready_ship_date = fields.Datetime(string='ET Ready to Ship', copy=False, related='sale_id.estimated_ready_ship_date') - # countdown_hours = fields.Float(string='Countdown in Hours', compute='_callculate_sequance', default=False, store=False, compute_sudo=False) - # countdown_ready_to_ship = fields.Char(string='Countdown Ready to Ship', compute='_callculate_sequance', store=False, compute_sudo=False) + 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 SO', - related='sale_id.carrier_id') + 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') @@ -288,8 +309,8 @@ class StockPicking(models.Model): self.ensure_one() if not self.name or not self.origin: return False - return f"{self.name} {self.origin}" - + return f"{self.name}" + def _download_pod_photo(self, url): """Mengunduh foto POD dari URL""" try: @@ -298,48 +319,53 @@ class StockPicking(models.Model): 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 - # Hilangkan timezone jika ada masalah parsing + + 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): + + + def action_get_kgx_pod(self, shipment=False): self.ensure_one() - - awb_number = self._get_kgx_awb_number() + + 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}} - + 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)}") @@ -529,13 +555,7 @@ class StockPicking(models.Model): record.kgx_pod_photo = "No image available." def action_fetch_lalamove_order(self): - pickings = self.env['stock.picking'].search([ - ('picking_type_code', '=', 'outgoing'), - ('state', '=', 'done'), - ('carrier_id', '=', 9), - ('lalamove_order_id', '!=', False) - ]) - for picking in pickings: + for picking in self: try: order_id = picking.lalamove_order_id apikey = self.env['ir.config_parameter'].sudo().get_param('lalamove.apikey') @@ -584,6 +604,7 @@ class StockPicking(models.Model): 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.") @@ -619,18 +640,19 @@ class StockPicking(models.Model): except ValueError: raise UserError(f"Format waktu tidak sesuai: {date_str}") - def track_envio_shipment(self): + 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 pickings: + 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={picking.name}" + url = f"https://api.envio.co.id/v1/tracking/distribution?code={name}" headers = { 'Authorization': 'Bearer JZ0Seh6qpYJAC3CJHdhF7sPqv8B/uSSfZe1VX5BL?vPYdo', 'Content-Type': 'application/json', @@ -686,46 +708,56 @@ class StockPicking(models.Model): raise UserError(f"Kesalahan tidak terduga: {str(e)}") def action_send_to_biteship(self): - if self.biteship_tracking_id: raise UserError(f"Order ini sudah dikirim ke Biteship. Dengan Tracking Id: {self.biteship_tracking_id}") - # Mencari data sale.order.line berdasarkan sale_id - products = self.env['sale.order.line'].search([('order_id', '=', self.sale_id.id)]) - - # Fungsi untuk membangun items_data dari order lines - def build_items_data(lines): - return [{ - "name": line.product_id.name, - "description": line.name, - "value": line.price_unit, - "quantity": line.product_uom_qty, - "weight": line.weight - } for line in lines] - - # Items untuk pengiriman standard - items_data_standard = build_items_data(products) + if self.sale_id.select_shipping_option == 'custom': + raise UserError( + "Shipping Option pada Sales Order ini adalah *Custom*. Tidak dapat dikirim melalui Biteship.") + + def is_courier_need_coordinates(service_code): + return service_code in [ + "instant", "same_day", "instant_car", + "instant_bike", "motorcycle", "mpv", "van", "truck", + "cdd_bak", "cdd_box", "engkel_box", "engkel_bak" + ] + + # ✅ Ambil item dari move_line_ids_with_package (qty_done > 0) + items = [] + for ml in self.move_line_ids_without_package: + if ml.qty_done <= 0: + continue - # Items untuk pengiriman instant, mengambil product_id dari move_line_ids_without_package - items_data_instant = [] - for move_line in self.move_line_ids_without_package: - # Mencari baris di sale.order.line berdasarkan product_id dari move_line - order_line = self.env['sale.order.line'].search([ + product = ml.product_id + weight = product.weight or 0.1 # default minimal + line = ml.move_id.sale_line_id or self.env['sale.order.line'].search([ ('order_id', '=', self.sale_id.id), - ('product_id', '=', move_line.product_id.id) + ('product_id', '=', ml.product_id.id) ], limit=1) - if order_line: - items_data_instant.append({ - "name": order_line.product_id.name, - "description": order_line.name, - "value": order_line.price_unit, - "quantity": move_line.qty_done, - "weight": order_line.weight - }) + value = line.price_unit if line else 0 + description = line.name if line else product.name + + items.append({ + "name": product.name, + "description": description, + "value": value, + "quantity": ml.qty_done, + "weight": int(weight * 1000), + }) + + if not items: + raise UserError("Pengiriman tidak dapat dilakukan karena tidak ada barang yang divalidasi (qty_done = 0).") + + shipping_partner = self.real_shipping_id + courier_service_code = self.sale_id.delivery_service_type or "reg" payload = { - "reference_id ": self.sale_id.name, + "origin_coordinate": { + "latitude": -6.3031123, + "longitude": 106.7794934999 + }, + "reference_id": self.name, "shipper_contact_name": self.carrier_id.pic_name or '', "shipper_contact_phone": self.carrier_id.pic_phone or '', "shipper_organization": self.carrier_id.name, @@ -733,41 +765,39 @@ class StockPicking(models.Model): "origin_contact_phone": "081717181922", "origin_address": "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN", "origin_postal_code": 14440, - "destination_contact_name": self.real_shipping_id.name, - "destination_contact_phone": self.real_shipping_id.phone or self.real_shipping_id.mobile, - "destination_address": self.real_shipping_id.street, - "destination_postal_code": self.real_shipping_id.zip, + "destination_contact_name": shipping_partner.name, + "destination_contact_phone": shipping_partner.phone or shipping_partner.mobile, + "destination_address": shipping_partner.street, + "destination_postal_code": shipping_partner.zip, "origin_note": "BELAKANG INDOMARET", - "courier_type": self.sale_id.delivery_service_type or "reg", + "destination_note": f"SO: {self.sale_id.name}", + "order_note": f"SO: {self.sale_id.name}", + "courier_type": courier_service_code, "courier_company": self.carrier_id.name.lower(), "delivery_type": "now", - "destination_postal_code": self.real_shipping_id.zip, - "items": items_data_standard + "items": items } - # Cek jika pengiriman instant atau same_day - if self.sale_id.delivery_service_type and ( - "instant" in self.sale_id.delivery_service_type or "same_day" in self.sale_id.delivery_service_type): - payload.update({ - "origin_coordinate": { - "latitude": -6.3031123, - "longitude": 106.7794934999 - }, - "destination_coordinate": { - "latitude": self.real_shipping_id.latitude, - "longitude": self.real_shipping_id.longtitude, - }, - "items": items_data_instant - }) + if is_courier_need_coordinates(courier_service_code): + if not shipping_partner.latitude or not shipping_partner.longtitude: + raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).") + + payload["destination_coordinate"] = { + "latitude": shipping_partner.latitude, + "longitude": shipping_partner.longtitude, + } - api_key = _biteship_api_key + _logger.info(f"Payload untuk Biteship: {payload}") + + # Kirim ke Biteship + api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } - # Kirim request ke Biteship response = requests.post(_biteship_url + '/orders', headers=headers, json=payload) + _logger.info(f"Response dari Biteship: {response.text}") if response.status_code == 200: data = response.json() @@ -775,17 +805,27 @@ class StockPicking(models.Model): self.biteship_id = data.get("id", "") self.biteship_tracking_id = data.get("courier", {}).get("tracking_id", "") self.biteship_waybill_id = data.get("courier", {}).get("waybill_id", "") - self.delivery_tracking_no = data.get("courier", {}).get("waybill_id", "") - - waybill_id = data.get("courier", {}).get("waybill_id", "") + self.delivery_tracking_no = self.biteship_waybill_id + + waybill_id = self.biteship_waybill_id + + self.message_post( + body=f"Biteship berhasil dilakukan.<br/>" + f"Kurir: {self.carrier_id.name}<br/>" + f"Tracking ID: {self.biteship_tracking_id or '-'}<br/>" + f"Resi: {waybill_id or '-'}<br/>" + f"Reference: {self.name}<br/>" + f"SO: {self.sale_id.name}", + message_type="comment" + ) message = f"✅ Berhasil Order ke Biteship! Resi: {waybill_id}" if waybill_id else "⚠️ Order berhasil, tetapi tidak ada nomor resi." return { 'effect': { - 'fadeout': 'slow', # Efek menghilang perlahan - 'message': message, # Pesan sukses - 'type': 'rainbow_man', # Efek animasi lucu Odoo + 'fadeout': 'slow', + 'message': message, + 'type': 'rainbow_man', } } else: @@ -917,6 +957,9 @@ class StockPicking(models.Model): 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( @@ -1055,38 +1098,40 @@ class StockPicking(models.Model): self.approval_receipt_status = 'pengajuan1' def ask_return_approval(self): - 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') + 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): @@ -1181,6 +1226,10 @@ class StockPicking(models.Model): 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])]) @@ -1269,6 +1318,19 @@ class StockPicking(models.Model): 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 + for move_line in self.move_line_ids_without_package: + purchase_line = move_line.move_id.purchase_line_id + if purchase_line: + if purchase_line.product_uom_qty < move_line.product_uom_qty: + raise UserError( + _("Quantity demand (%s) tidak bisa lebih besar dari qty product (%s) untuk produk %s") % ( + move_line.product_uom_qty, + purchase_line.product_uom_qty, + move_line.product_id.display_name + ) + ) + self.validation_minus_onhand_quantity() self.responsible = self.env.user.id # self.send_koli_to_so() @@ -1280,7 +1342,6 @@ class StockPicking(models.Model): 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: @@ -1317,26 +1378,40 @@ class StockPicking(models.Model): 'target': 'new', } self.send_mail_bills() + if 'BU/PUT' in self.name: + self.automatic_reserve_product() 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) + [('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: + 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') @@ -1515,25 +1590,25 @@ class StockPicking(models.Model): 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 + # 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): @@ -1614,27 +1689,51 @@ class StockPicking(models.Model): order = self.env['sale.order'].search([('name', '=', self.sale_id.name)], limit=1) + sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', order.name)], limit=1) + + product_shipped = [] + for move_line in self.move_line_ids_without_package: + if move_line.qty_done > 0: + product_shipped.append({ + 'name': move_line.product_id.name, + 'qty': move_line.qty_done + }) + response = { 'delivery_order': { 'name': self.name, - 'carrier': self.carrier_id.name or '', - 'service': order.delivery_service_type or '', + 'carrier': self.carrier_id.name or '-', + 'service': order.delivery_service_type or '-', 'receiver_name': '', 'receiver_city': '' }, + 'delivered_date': self.driver_departure_date.strftime( + '%d %b %Y') if self.driver_departure_date != False else '-', 'delivered': False, 'status': self.shipping_status, - 'waybill_number': self.delivery_tracking_no or '', + 'waybill_number': self.delivery_tracking_no or '-', 'delivery_status': None, 'eta': self.generate_eta_delivery(), 'is_biteship': True if self.biteship_id else False, - 'manifests': self.get_manifests() + 'manifests': self.get_manifests(), + 'is_delay': True if sale_order_delay and sale_order_delay.status == 'delayed' else False, + 'products': product_shipped } if self.biteship_id: histori = self.get_manifest_biteship() - eta_start = order.date_order + timedelta(days=order.estimated_arrival_days_start) - eta_end = order.date_order + timedelta(days=order.estimated_arrival_days) + day_start = order.estimated_arrival_days_start + day_end = order.estimated_arrival_days + if sale_order_delay: + if sale_order_delay.status == 'delayed': + day_start = day_start + sale_order_delay.days_delayed + day_end = day_end + sale_order_delay.days_delayed + elif sale_order_delay.status == 'early': + day_start = day_start - sale_order_delay.days_delayed + day_end = day_end - sale_order_delay.days_delayed + + eta_start = order.date_order + timedelta(days=day_start) + eta_end = order.date_order + timedelta(days=day_end) formatted_eta = f"{eta_start.strftime('%d %b')} - {eta_end.strftime('%d %b %Y')}" response['eta'] = formatted_eta response['manifests'] = histori.get("manifests", []) @@ -1656,7 +1755,7 @@ class StockPicking(models.Model): return response def get_manifest_biteship(self): - api_key = _biteship_api_key + api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" @@ -1669,16 +1768,15 @@ class StockPicking(models.Model): 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("name", ""), - 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman', - 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli', - 'delivered': 'Pesanan telah sampai dan diterima oleh ' + result.get("destination", {}).get( - "contact_name", "") - } + # 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 <span style="color:#DC2626;">{result.get("destination", {}).get("contact_name", "")}</span>' + # } if (result.get('success') == True): history = result.get("history", []) status = result.get("status", "") @@ -1687,7 +1785,7 @@ class StockPicking(models.Model): 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": description[entry["status"]], + "description": self._get_biteship_status_description(entry["status"], result), }) return { @@ -1695,19 +1793,205 @@ class StockPicking(models.Model): "delivered": status } - return manifests + 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)} - def _convert_to_local_time(self, iso_date): + # 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 <span style="color:#DC2626;">{contact_name}</span>', + '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_with_tz = waktu.fromisoformat(iso_date) - utc_dt = dt_with_tz.astimezone(pytz.utc) + 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')}:<br/>{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 + ) - local_tz = pytz.timezone("Asia/Jakarta") - local_dt = utc_dt.astimezone(local_tz) + # 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) @@ -1719,29 +2003,39 @@ class StockPicking(models.Model): "allocated": "pending", "picking_up": "pending", "picked": "shipment", - "cancelled": "cancelled", - "on_hold": "on_hold", "dropping_off": "shipment", - "delivered": "completed" + "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() - prepare_days = 3 - start_date = self.driver_departure_date or self.create_date - - ead = self.sale_id.estimated_arrival_days or 0 - if not self.driver_departure_date: - ead += prepare_days - - ead_datetime = datetime.timedelta(days=ead) - fastest_eta = start_date + ead_datetime - if not self.driver_departure_date and fastest_eta < current_date: - fastest_eta = current_date + ead_datetime - - longest_days = 3 - longest_eta = fastest_eta + datetime.timedelta(days=longest_days) + days_start = self.sale_id.estimated_arrival_days_start or self.sale_id.estimated_arrival_days + days_end = self.sale_id.estimated_arrival_days or (self.sale_id.estimated_arrival_days + 3) + start_date = self.sale_id.create_date + datetime.timedelta(days=days_start) + end_date = self.sale_id.create_date + datetime.timedelta(days=days_end) + + add_day_start = 0 + add_day_end = 0 + sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', self.sale_id.name)], limit=1) + if sale_order_delay: + if sale_order_delay.status == 'delayed': + add_day_start = sale_order_delay.days_delayed + add_day_end = sale_order_delay.days_delayed + elif sale_order_delay.status == 'early': + add_day_start = -abs(sale_order_delay.days_delayed) + add_day_end = -abs(sale_order_delay.days_delayed) + + fastest_eta = start_date + datetime.timedelta(days=add_day_start + add_day_start) + + longest_eta = end_date + datetime.timedelta(days=add_day_end) format_time = '%d %b %Y' format_time_fastest = '%d %b' if fastest_eta.year == longest_eta.year else format_time @@ -2229,6 +2523,8 @@ class KonfirmKoli(models.Model): 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): @@ -2254,4 +2550,4 @@ class WarningModalWizard(models.TransientModel): 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'} + return {'type': 'ir.actions.act_window_close'}
\ No newline at end of file |
