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