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 from hashlib import sha256 from odoo.tools.float_utils import float_compare _logger = logging.getLogger(__name__) Request_URI = ['/openapi/order/v1/print', '/openapi/logistics/v1/get-shipping-parameter', '/openapi/order/v3/batchShipping'] ACCESS_KEY = '24bb6a1ec618ec6a' SECRET_KEY = '32e4a78ad05ee230' class StockPicking(models.Model): _inherit = 'stock.picking' check_product_lines = fields.One2many('check.product', 'picking_id', string='Check Product', auto_join=True, copy=False) order_reference = fields.Char('Order Reference') provider_name = fields.Char('Provider Name', tracking=True) tracking_number = fields.Char('Tracking Number', tracking=True) invoice_number = fields.Char('Invoice Number', tracking=True) pdf_label_url = fields.Char('PDF Label URL', tracking=True) invoice_mp = fields.Char(string='Invoice Marketplace', tracking=True) address = fields.Char('Address', tracking=True) note_by_buyer = fields.Char('Note By Buyer', tracking=True) carrier = fields.Char(string='Shipping Method', tracking=True) shipment_group_id = fields.Many2one('shipment.group', string='Shipment Group', copy=False) pdf_label_preview = fields.Binary( string="PDF Preview", compute="_compute_pdf_binary", store=False, tracking=True ) is_printed = fields.Boolean(string="Sudah Dicetak", default=False, tracking=True) is_return = fields.Boolean( string="Is Return", compute="_compute_is_return", store=True, tracking=True ) channel = fields.Char('Channel') ginee_delivery_type = fields.Char("Delivery Type", tracking=True) ginee_tracking_no = fields.Char("Tracking Number", tracking=True) ginee_invoice_no = fields.Char("Invoice Number", tracking=True) ginee_shipping_task_id = fields.Char("Shipping Task ID", tracking=True) ginee_provider_id = fields.Char("Provider ID", tracking=True) ginee_provider_name = fields.Char("Provider Name", tracking=True) ginee_address_id = fields.Char("Pickup Address ID", tracking=True) ginee_address = fields.Char("Pickup Address", tracking=True) ginee_pickup_time_id = fields.Char("Pickup Time ID", tracking=True) ginee_task_id = fields.Char("Ginee Task ID", tracking=True) soo_number = fields.Char(string='SOO Altama Number') number_soo = fields.Char(string='Number SOO Altama') type_sku = fields.Selection([('single', 'Single SKU'), ('multi', 'Multi SKU')], string='Type SKU') list_product = fields.Char(string='List Product') is_dispatched = fields.Boolean(string='Is Dispatched', default=False, compute='_compute_is_dispatched', readonly=True) date_canceled = fields.Datetime(string='Date Canceled', tracking=True) full_reserved = fields.Boolean(string='Full Reserved', default=False) so_num = fields.Char(string='SO Number', compute='_get_so_num') def _get_so_num(self): for record in self: if record.group_id: record.so_num = record.group_id.name else: record.so_num = False def check_qty_reserved(self): pickings = self.env['stock.picking'].search([ ('state', '=', 'assigned'), ('picking_type_code', '=', 'outgoing'), ('name', 'ilike', 'BU/OUT'), ('origin', 'ilike', 'SO/'), ]) for picking in pickings: moves = picking.move_ids_without_package picking.full_reserved = bool(moves) and all( float_compare( line.product_uom_qty, line.forecast_availability, precision_rounding=line.product_uom.rounding ) == 0 for line in moves ) def action_cancel(self): for picking in self: picking.date_canceled = fields.Datetime.now() return super(StockPicking, self).action_cancel() def check_qty_bundling_product(self): for line in self.move_ids_without_package: if line.sale_line_id and line.sale_line_id.name: if '(Bundle Component)' in line.sale_line_id.name: if line.forecast_availability < 1 or line.quantity_done < 1: raise UserError( 'Barang Bundling : %s Quantity Done tidak boleh 0' % line.product_id.display_name ) 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 sum(quant.mapped('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') @api.depends('shipment_group_id') def _compute_is_dispatched(self): for picking in self: picking.is_dispatched = bool(picking.shipment_group_id) def action_cancel_selected_pickings(self): for picking in self: if picking.state == 'done': raise UserError( _("Picking %s sudah DONE dan tidak bisa di-cancel.") % picking.name ) if picking.state == 'assigned': picking.do_unreserve() picking.action_cancel() return None def rts_ginee(self): self.get_shipping_parameter() self.ship_order() def create_invoices(self): so_id = self.sale_id.id if not so_id: raise UserError(_("Gaada So nya!")) sale_orders = self.env['sale.order'].browse(so_id) created_invoices = self.env['account.move'] for order in sale_orders: invoice = order.with_context(default_invoice_origin=order.name)._create_invoices(final=True) invoice.action_post() created_invoices += invoice order.invoice_ids += invoice if created_invoices: action = { 'name': _('Created Invoice'), 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'form', 'res_id': created_invoices.id, } else: action = {'type': 'ir.actions.act_window_close'} return action def set_po_bill_status(self): for picking in self: po = self.env['purchase.order'].search([ ('name', '=', picking.group_id.name) ], limit=1) if not po or po.state != 'purchase': continue all_pickings = self.env['stock.picking'].search([ ('group_id', '=', picking.group_id.id), ('picking_type_code', '=', 'incoming'), ('state', 'in', ['done', 'cancel', 'ready', 'assigned']), ]) states = all_pickings.mapped('state') if all(s == 'cancel' for s in states): po.bill_status = 'cancel' elif any(s in ('assigned', 'ready') for s in states): po.bill_status = 'waiting' else: po.bill_status = 'ready' def button_validate(self): if not self.picking_type_code == 'incoming' and not self.name.startswith('BU/IN'): self.check_qty_done_stock() self.check_qty_bundling_product() origin = self.origin or '' if any(prefix in origin for prefix in ['PO/', 'SO/']) and not self.check_product_lines and not self.name.startswith('BU/INT'): raise UserError(_("Belum ada check product, gabisa validate")) if self.name.startswith('BU/INT') and self.picking_type_code == 'internal' and self.env.user.id not in [10,15,2] and self.location_dest_id.id == 86: raise UserError(_("Hanya bang rafly hanggara yang bisa validate")) res = super(StockPicking, self).button_validate() if self.picking_type_code == 'incoming' and self.name.startswith('BU/IN'): self.set_po_bill_status() if ( self.name and self.origin and (self.name.startswith('BU/IN') or self.name.startswith('BU/SRT')) and self.origin.startswith('Return of BU/OUT') and self.state == 'done' ): self.automatic_reversed_invoice() # if self.name.startswith('BU/OUT') and self.origin.startswith('SO/'): # self.create_invoices() return res def automatic_reversed_invoice(self): origin = self.origin or '' clean_origin = origin.replace('Return of ', '') return_picking = self.env['stock.picking'].search([ ('name', '=', clean_origin), ('state', '=', 'done') ], limit=1) if not return_picking: return False account_move = self.env['account.move'].search([ ('picking_id', '=', return_picking.id), ('state', '=', 'posted') ], limit=1) if not account_move: return False reversal = self.env['account.move.reversal'].create({ 'move_ids': [(6, 0, account_move.ids)], 'date_mode': 'custom', 'date': fields.Date.context_today(self), 'refund_method': 'refund', 'reason': _('Auto reverse from return picking %s') % self.name, 'company_id': account_move.company_id.id, }) action = reversal.reverse_moves() # Force write invoice date using date done SP new_move = self.env['account.move'].browse(action.get('res_id')) if new_move: new_move.write({ 'invoice_date': self.date_done, }) return action @api.depends('move_lines.origin_returned_move_id') def _compute_is_return(self): for picking in self: # Picking dianggap return kalau ada minimal satu move yang direturn dari move lain picking.is_return = any(m.origin_returned_move_id for m in picking.move_lines) def action_cancel(self): for picking in self: if not self.env.user.id == 13: if picking.purchase_id.move_entry_id: raise UserError( 'Hanya Accounting yang bisa melakukan cancel karena di po nya sudah ada uang muka' ) if picking.picking_type_code == 'incoming' and picking.name.startswith('BU/IN'): picking.set_po_bill_status() res = super(StockPicking, self).action_cancel() return res def action_create_invoice_from_mr(self): """Create the invoice associated to the PO. """ if self.env.user.id not in (13, 15, 8): 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 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 def _compute_pdf_binary(self): for record in self: record.pdf_label_preview = False if record.pdf_label_url: try: response = requests.get(record.pdf_label_url, timeout=10) if response.status_code == 200 and response.headers.get('Content-Type') == 'application/pdf': record.pdf_label_preview = base64.b64encode(response.content) except Exception as e: _logger.warning(f"Gagal mengambil PDF dari URL: {e}") @api.constrains('sale_id') def _check_sale_order(self): for picking in self: if picking.sale_id: picking.order_reference = picking.sale_id.name self.order_reference = picking.sale_id.order_reference self.invoice_mp = picking.sale_id.invoice_mp self.channel = picking.sale_id.channel self.carrier = picking.sale_id.carrier self.address = picking.sale_id.address self.note_by_buyer = picking.sale_id.note_by_buyer self.date_deadline = picking.sale_id.deadline_date self.schema_multi_single_sku() def schema_multi_single_sku(self): for picking in self: if len(picking.move_ids_without_package) > 1: picking.type_sku = 'multi' else: picking.type_sku = 'single' names = picking.move_ids_without_package.mapped('product_id.display_name') picking.list_product = ", ".join(names) def open_form_shipment_group(self): return { 'name': _('Create Shipment Group'), 'type': 'ir.actions.act_window', 'res_model': 'stock.picking.shipment_group', 'view_mode': 'form', 'target': 'new', 'context': { 'picking_ids': self.ids, } } def open_form_print_picking_list(self): return { 'name': _('Create Print Picking List'), 'type': 'ir.actions.act_window', 'res_model': 'stock.picking.print_picking_list', 'view_mode': 'form', 'target': 'new', 'context': { 'picking_ids': self.ids, } } def label_ginee(self): try: now = time.strftime('%Y-%m-%d %H:%M:%S') order_id = self.order_reference if 'Blibli' in self.partner_id.name or 'BLIBLI' in self.partner_id.name: order_id = self.order_reference.split(",")[0].strip() # if not self.ginee_task_id and now > '2025-12-31 23:59:59': # raise UserError(_("Klik Ready To Ship terlebih dahulu")) authorization = self.sign_request(0) headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } payload = { "orderId": order_id, "documentType": "LABEL" } url = "https://api.ginee.com/openapi/order/v1/print" response = requests.post( url, headers=headers, data=json.dumps(payload) ) if response.status_code == 200: data = response.json() if data.get('code') == 'SUCCESS' and data.get('message') == 'OK': logistic_info_list = data.get('data', {}).get('logisticsInfos') # Check if logistic_info exists and has at least one item if not logistic_info_list: raise UserError(_("No logistic information found in response")) logistic_info = logistic_info_list[0] self.pdf_label_url = data.get('data', {}).get('pdfUrl') or '' self.tracking_number = logistic_info.get('logisticsTrackingNumber') or '' self.provider_name = logistic_info.get('logisticsProviderName') or '' self.invoice_number = logistic_info.get('invoiceNumber') or '' pdf_url = data.get('data', {}).get('pdfUrl') if not pdf_url: raise UserError(_("PDF label URL tidak ditemukan")) return { 'type': 'ir.actions.act_url', 'url': pdf_url, 'target': 'new', # buka tab baru (recommended buat PDF) } else: raise UserError(_("API Error: %s - %s") % (data.get('code', 'UNKNOWN'), data.get('message', 'No error message'))) else: raise UserError(_("API request failed with status code: %s") % response.status_code) except Exception as e: raise UserError(_("Error: %s") % str(e)) def get_shipping_parameter(self): try: if 'Blibli' in self.partner_id.name or 'BLIBLI' in self.partner_id.name: self.get_shipping_parameter_blibli() return order_id = self.order_reference authorization = self.sign_request(1) headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } payload = {"orderId": order_id} url = "https://api.ginee.com/openapi/logistics/v1/get-shipping-parameter" response = requests.post(url, headers=headers, data=json.dumps(payload)) res = response.json() if res.get("code") != "SUCCESS": raise UserError("Ginee Error: %s" % res.get("message")) data = res.get("data", {}) # ============================== # Basic fields # ============================== self.ginee_delivery_type = data.get("deliveryType") self.ginee_tracking_no = data.get("logisticsTrackingNumber") self.ginee_invoice_no = data.get("invoiceNumber") # ============================== # FIND CORRECT PROVIDER # ============================== provider_id = None provider_name = None logistics_type = None for lg in data.get("logistics", []): details = lg.get("logisticDetailList") or [] if details: # ambil pertama yang terisi d = details[0] provider_id = d.get("logisticsProviderId") provider_name = d.get("logisticsProviderName") logistics_type = lg.get("logisticsDeliveryType") break # STOP di yang pertama terisi # Simpan hasil ke field Odoo self.ginee_provider_id = provider_id self.ginee_provider_name = provider_name self.ginee_delivery_type = logistics_type or self.ginee_delivery_type # ============================== # PICKUP ADDRESS (kalau ada) # ============================== addr = None pickup_time = None addresses = data.get("addresses") or [] if addresses: # ambil address pertama paling relevan first = addresses[0] addr = first.get("address") self.ginee_address = addr self.ginee_address_id = first.get("addressId") times = first.get("pickupTimeList") or [] if times: pt = times[0] self.ginee_pickup_time_id = pt.get("pickupTimeId") except Exception as e: raise UserError(_("Error: %s") % str(e)) def get_shipping_parameter_blibli(self): try: if not self.order_reference: return # ============================== # SPLIT ORDER REFERENCE # ============================== order_refs = self.order_reference.split(",")[0].strip() # API ini WAJIB 1 order per call authorization = self.sign_request(1) headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } url = "https://api.ginee.com/openapi/logistics/v1/get-shipping-parameter" payload = { "orderId": order_refs } response = requests.post( url, headers=headers, data=json.dumps(payload) ) try: res = response.json() except Exception: raise UserError( "Invalid JSON response from Ginee for order %s" % order_refs ) if res.get("code") != "SUCCESS": raise UserError( "Ginee Error (%s): %s" % (order_refs, res.get("message")) ) data = res.get("data", {}) # ============================== # BASIC FIELDS # ============================== self.ginee_delivery_type = data.get("deliveryType") self.ginee_tracking_no = data.get("logisticsTrackingNumber") self.ginee_invoice_no = data.get("invoiceNumber") # ============================== # FIND PROVIDER # ============================== provider_id = None provider_name = None logistics_type = None for lg in data.get("logistics", []) or []: details = lg.get("logisticDetailList") or [] if details: d = details[0] provider_id = d.get("logisticsProviderId") provider_name = d.get("logisticsProviderName") logistics_type = lg.get("logisticsDeliveryType") break self.ginee_provider_id = provider_id self.ginee_provider_name = provider_name self.ginee_delivery_type = logistics_type or self.ginee_delivery_type # ============================== # PICKUP ADDRESS # ============================== addresses = data.get("addresses") or [] if addresses: first = addresses[0] self.ginee_address = first.get("address") self.ginee_address_id = first.get("addressId") times = first.get("pickupTimeList") or [] if times: self.ginee_pickup_time_id = times[0].get("pickupTimeId") # ============================== # OPTIONAL: DELAY (ANTI RATE LIMIT) # ============================== time.sleep(0.2) except Exception as e: raise UserError(_("Error: %s") % str(e)) def ship_order(self): try: if 'Blibli' in self.partner_id.name or 'BLIBLI' in self.partner_id.name: self.ship_order_blibli() return order_id = self.order_reference authorization = self.sign_request(2) # index 2 -> ship-order headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } # ========================================================== # Ambil field dari GET SHIPPING PARAMETER # ========================================================== delivery_type = self.ginee_delivery_type provider_name = self.ginee_provider_name provider_id = self.ginee_provider_id tracking_no = self.ginee_tracking_no invoice_no = self.ginee_invoice_no address_id = self.ginee_address_id address = self.ginee_address pickup_time_id = self.ginee_pickup_time_id # ========================================================== # Ambil platform dari channel (SHOPEE, LAZADA, dll) # ========================================================== platform = self._get_platform_from_channel() or "" # ========================================================== # Build order-level payload # ========================================================== order_data = { "orderId": order_id, "deliveryType": delivery_type } # ========================================================== # PLATFORM RULES # ========================================================== # ----------------------------- SHOPEE ----------------------------- if platform == "SHOPEE": if delivery_type == "PICK_UP": order_data.update({ "shippingProvider": provider_name, "pickupTimeId": pickup_time_id or "", "addressId": address_id, "address": address, }) elif delivery_type == "DROP_OFF": order_data.update({"shippingProvider": provider_name}) elif delivery_type == "MANUAL_SHIP": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no, }) # ----------------------------- TOKOPEDIA ----------------------------- elif platform == "TOKOPEDIA": if delivery_type == "PICK_UP": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no, }) elif delivery_type == "DROP_OFF": order_data.update({"shippingProvider": provider_name}) elif delivery_type == "MANUAL_SHIP": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no }) # ----------------------------- LAZADA ----------------------------- elif platform == "LAZADA": if delivery_type == "PICK_UP": order_data.update({ "shippingProvider": provider_name, "address": address, "addressId": address_id, "pickupTimeId": pickup_time_id or "" }) elif delivery_type == "DROP_OFF": order_data.update({ "shippingProvider": provider_name, "invoiceNumber": invoice_no }) # ----------------------------- TIKTOK ----------------------------- elif platform == "TIKTOK": if delivery_type == "PICK_UP": order_data.update({ "shippingProvider": provider_name, "pickupStartTime": pickup_time_id or "", "pickupEndTime": pickup_time_id or "" }) elif delivery_type == "DROP_OFF": order_data.update({"shippingProvider": provider_name}) # ----------------------------- ZALORA ----------------------------- elif platform == "ZALORA": if delivery_type == "DROP_OFF": order_data.update({ "trackingNo": tracking_no, "invoiceNumber": invoice_no }) # ----------------------------- AKULAKU ----------------------------- elif platform == "AKULAKU": if delivery_type in ["PICK_UP", "DROP_OFF"]: order_data.update({ "shippingProvider": provider_name, "shippingProviderId": provider_id, "addressId": address_id, "address": address }) elif delivery_type == "MANUAL_SHIP": order_data.update({ "shippingProvider": provider_name, "shippingProviderId": provider_id, "trackingNo": tracking_no }) # ----------------------------- BUKALAPAK ----------------------------- elif platform == "BUKALAPAK": if delivery_type in ["PICK_UP", "DROP_OFF"]: order_data.update({"shippingProvider": provider_name}) elif delivery_type == "MANUAL_SHIP": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no }) # ----------------------------- BLIBLI ----------------------------- elif platform == "BLIBLI": if delivery_type == "PICK_UP": order_data.update({"shippingProvider": provider_name}) elif delivery_type == "DROP_OFF": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no }) # ----------------------------- DEFAULT FALLBACK ----------------------------- else: if delivery_type == "PICK_UP": order_data.update({ "shippingProvider": provider_name, "pickupTimeId": pickup_time_id or "", "addressId": address_id, "address": address, }) elif delivery_type == "DROP_OFF": order_data.update({"shippingProvider": provider_name}) elif delivery_type == "MANUAL_SHIP": order_data.update({ "shippingProvider": provider_name, "trackingNo": tracking_no, }) # ========================================================== # FINAL PAYLOAD (dibungkus orderShips) # ========================================================== payload = { "orderShips": [order_data] } # ========================================================== # CALL API # ========================================================== url = "https://api.ginee.com/openapi/order/v3/batchShipping" response = requests.post(url, headers=headers, data=json.dumps(payload)) res = response.json() if res.get("code") != "SUCCESS": raise UserError("Ship Order Error: %s" % res.get("message")) self.ginee_task_id = res.get("data") or False return True except Exception as e: raise UserError(_("Error: %s") % str(e)) def ship_order_blibli(self): try: if not self.order_reference: return False # ============================== # SPLIT ORDER IDS # ============================== order_refs = self.order_reference.split(",")[0].strip() if not order_refs: return False # ============================== # AUTH & HEADER (ONCE) # ============================== authorization = self.sign_request(2) headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } platform = self._get_platform_from_channel() or "" # ============================== # COMMON SHIPPING DATA (SOURCE OF TRUTH) # ============================== base_shipping = { "deliveryType": self.ginee_delivery_type, "shippingProvider": self.ginee_provider_name, "shippingProviderId": self.ginee_provider_id, "trackingNo": self.ginee_tracking_no, "invoiceNumber": self.ginee_invoice_no, "addressId": self.ginee_address_id, "address": self.ginee_address, "pickupTimeId": self.ginee_pickup_time_id, } order_ships = [] order_data = { "orderId": order_refs, "deliveryType": base_shipping["deliveryType"], } dt = base_shipping["deliveryType"] # ============================== # PLATFORM RULES # ============================== def add(*keys): for k in keys: if base_shipping.get(k): order_data[k] = base_shipping[k] if platform == "SHOPEE": if dt == "PICK_UP": add("shippingProvider", "pickupTimeId", "addressId", "address") elif dt == "DROP_OFF": add("shippingProvider") elif dt == "MANUAL_SHIP": add("shippingProvider", "trackingNo") elif platform == "TOKOPEDIA": if dt in ["PICK_UP", "MANUAL_SHIP"]: add("shippingProvider", "trackingNo") elif dt == "DROP_OFF": add("shippingProvider") elif platform == "LAZADA": if dt == "PICK_UP": add("shippingProvider", "address", "addressId", "pickupTimeId") elif dt == "DROP_OFF": add("shippingProvider", "invoiceNumber") elif platform == "TIKTOK": if dt == "PICK_UP": order_data.update({ "shippingProvider": base_shipping["shippingProvider"], "pickupStartTime": base_shipping["pickupTimeId"] or "", "pickupEndTime": base_shipping["pickupTimeId"] or "", }) elif dt == "DROP_OFF": add("shippingProvider") elif platform == "ZALORA": if dt == "DROP_OFF": add("trackingNo", "invoiceNumber") elif platform == "AKULAKU": if dt in ["PICK_UP", "DROP_OFF"]: add("shippingProvider", "shippingProviderId", "addressId", "address") elif dt == "MANUAL_SHIP": add("shippingProvider", "shippingProviderId", "trackingNo") elif platform == "BUKALAPAK": if dt in ["PICK_UP", "DROP_OFF"]: add("shippingProvider") elif dt == "MANUAL_SHIP": add("shippingProvider", "trackingNo") elif platform == "BLIBLI": if dt == "PICK_UP": add("shippingProvider") elif dt == "DROP_OFF": add("shippingProvider", "trackingNo") else: if dt == "PICK_UP": add("shippingProvider", "pickupTimeId", "addressId", "address") elif dt == "DROP_OFF": add("shippingProvider") elif dt == "MANUAL_SHIP": add("shippingProvider", "trackingNo") order_ships.append(order_data) # ============================== # FINAL PAYLOAD (1 CALL ONLY) # ============================== payload = { "orderShips": order_ships } url = "https://api.ginee.com/openapi/order/v3/batchShipping" response = requests.post( url, headers=headers, data=json.dumps(payload) ) res = response.json() if res.get("code") != "SUCCESS": raise UserError("Ship Order Error: %s" % res.get("message")) self.ginee_task_id = res.get("data") or False return True except Exception as e: raise UserError(_("Error: %s") % str(e)) def sign_request(self, array_num): signData = '$'.join(['POST', Request_URI[array_num]]) + '$' authorization = ACCESS_KEY + ':' + base64.b64encode( hmac.new(SECRET_KEY.encode('utf-8'), signData.encode('utf-8'), digestmod=sha256).digest() ).decode('ascii') return authorization def _get_platform_from_channel(self): if not self.channel: return None ch = (self.channel or "").upper().strip() # remove suffix _ID / _CHOICE / etc ch = ch.replace("_ID", "").replace("_CHOICE", "") return ch # def sync_qty_reserved_qty_done(self): # for picking in self: # for line in picking.move_line_ids_without_package: # line.qty_done = line.product_uom_qty # picking.button_validate() class CheckProduct(models.Model): _name = 'check.product' _description = 'Check Product' _order = 'picking_id, id' _inherit = ['mail.thread', 'mail.activity.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): picking_map = {} for line in self: picking_map.setdefault(line.picking_id, []).append({ 'product': line.product_id.display_name, 'qty': line.quantity, }) pickings = self.mapped('picking_id') deleted_product_ids = self.mapped('product_id') result = super(CheckProduct, self).unlink() for picking in pickings: if picking in picking_map: product_list = picking_map[picking] picking.message_post( body=( "Product Dihapus dari Check Product
" "%s" ) % "
".join( "- %s" % product for product in product_list ), subtype_xmlid='mail.mt_note', ) remaining_product_ids = picking.check_product_lines.mapped('product_id') removed_product_ids = deleted_product_ids - remaining_product_ids moves_to_reset = picking.move_ids_without_package.filtered( lambda move: move.product_id in removed_product_ids ) for move in moves_to_reset: move.quantity_done = 0.0 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' @api.model def create(self, vals): record = super(CheckProduct, self).create(vals) if record.product_id and record.picking_id: record.picking_id.message_post( body=( "Check Product Berhasil
" "Product: %s
" ) % ( record.product_id.display_name, ), message_type='comment', subtype_xmlid='mail.mt_note', ) 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 quantity_done di move_ids_without_package dengan total quantity dari check.product berdasarkan product_id. """ for product_id in picking.check_product_lines.mapped('product_id'): # Totalkan quantity dari semua baris check.product untuk product_id ini total_quantity = sum( line.quantity for line in picking.check_product_lines.filtered(lambda line: line.product_id == product_id) ) # Update quantity_done di move yang relevan moves = picking.move_ids_without_package.filtered(lambda move: move.product_id == product_id) for move in moves: move.quantity_done = total_quantity 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 PickingReportCustom(models.AbstractModel): _name = 'report.fixco_custom.report_picking_list_custom' _description = 'Custom Picking List Report' def _get_report_values(self, docids, data=None): pickings = self.env['stock.picking'].browse(docids) was_printed_map = {p.id: p.is_printed for p in pickings} pickings.write({'is_printed': True}) return { 'doc_ids': docids, 'doc_model': 'stock.picking', 'docs': pickings, 'was_printed_map': was_printed_map, } class PickingReportCustomNew(models.AbstractModel): _name = 'report.fixco_custom.report_picking_list_custom_new' _description = 'asjdkla' def _get_report_values(self, docids, data=None): pickings = self.env['stock.picking'].browse(docids) was_printed_map = {p.id: p.is_printed for p in pickings} pickings.write({'is_printed': True}) return { 'doc_ids': docids, 'doc_model': 'stock.picking', 'docs': pickings, 'was_printed_map': was_printed_map, }