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)],
'picking_id': self.id,
'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()
new_move = self.env['account.move'].browse(action.get('res_id'))
if new_move:
if new_move.state == 'posted':
new_move.button_draft()
# skip move validity
new_move = new_move.with_context(check_move_validity=False)
#Ambil data retur
retur_data = {}
for line in self.move_ids_without_package:
retur_data[line.product_id.id] = retur_data.get(line.product_id.id, 0) + line.quantity_done
#Update Qty atau Unlink
for inv_line in new_move.invoice_line_ids:
p_id = inv_line.product_id.id
if p_id in retur_data and retur_data[p_id] > 0:
inv_line.write({'quantity': retur_data[p_id]})
retur_data[p_id] = 0
else:
inv_line.unlink()
new_move._recompute_tax_lines()
# Recompute baris piutang/hutang
# bakal nyesuain angka debit/kredit biar balance sama total invoice
new_move._recompute_payment_terms_lines()
new_move._recompute_dynamic_lines(recompute_all_taxes=True)
# invoice date ikutin date done stock picking
new_move.write({
'invoice_date': self.date_done.date(),
'date': self.date_done.date()
})
# check validity diakhir
new_move.with_context(check_move_validity=True).action_post()
lines_to_reconcile = (
account_move.line_ids + new_move.line_ids
).filtered(lambda l: l.account_id.internal_type == 'receivable' and not l.reconciled)
if lines_to_reconcile:
lines_to_reconcile.reconcile()
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, 24):
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,
}