from re import search
from odoo import fields, models, api, _
from odoo.exceptions import UserError, ValidationError
from datetime import datetime, timedelta, timezone, time
import logging, random, string, requests, math, json, re, qrcode, base64
import pytz
from io import BytesIO
from collections import defaultdict
import pytz
from lxml import etree
_logger = logging.getLogger(__name__)
class CancelReasonOrder(models.TransientModel):
_name = 'cancel.reason.order'
_description = 'Wizard for Cancel Reason order'
request_id = fields.Many2one('sale.order', string='Request')
reason_cancel = fields.Selection([
('harga_terlalu_mahal', 'Harga barang terlalu mahal'),
('harga_web_tidak_valid', 'Harga web tidak valid'),
('stok_kosong', 'Stock kosong'),
('tidak_mau_indent', 'Customer tidak mau indent'),
('batal_rencana_pembelian', 'Customer membatalkan rencana pembelian'),
('vendor_tidak_support_demo', 'Vendor tidak support demo/trial product'),
('product_knowledge_kurang', 'Product knowledge kurang baik'),
('barang_tidak_sesuai', 'Barang tidak sesuai/tepat'),
('tidak_sepakat_pembayaran', 'Tidak menemukan kesepakatan untuk pembayaran'),
('dokumen_tidak_support', 'Indoteknik tidak bisa support document yang dibutuhkan (Ex: TKDN, COO, SNI)'),
('ganti_quotation', 'Ganti Quotation'),
('testing_internal', 'Testing Internal'),
('revisi_data', 'Revisi Data'),
], string='Reason for Cancel', required=True, copy=False, index=True, tracking=3)
attachment_bukti = fields.Many2many(
'ir.attachment',
string="Attachment Bukti", readonly=False,
tracking=3, required=True
)
nomor_so_pengganti = fields.Char(string='Nomor SO Pengganti', copy=False, tracking=3)
def confirm_reject(self):
order = self.request_id
if order:
order.write({'reason_cancel': self.reason_cancel})
if not self.attachment_bukti:
raise UserError('Attachment bukti wajib disertakan')
order.write({'attachment_bukti': self.attachment_bukti})
order.message_post(body='Attachment Bukti Cancel',
attachment_ids=[self.attachment_bukti.id])
if self.reason_cancel == 'ganti_quotation':
if self.nomor_so_pengganti:
order.write({'nomor_so_pengganti': self.nomor_so_pengganti})
else:
raise UserError('Nomor SO pengganti wajib disertakan')
order.confirm_cancel_order()
return {'type': 'ir.actions.act_window_close'}
class ShippingOption(models.Model):
_name = "shipping.option"
_description = "Shipping Option"
name = fields.Char(string="Option Name", required=True)
price = fields.Float(string="Price", required=True)
provider = fields.Char(string="Provider")
etd = fields.Char(string="Estimated Delivery Time")
courier_service_code = fields.Char(string="Courier Service Code")
sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete="cascade")
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def unlink(self):
lines_to_reject = []
for line in self:
if line.order_id:
now = fields.Datetime.now()
initial_reason = "Product Rejected"
# Buat lognote untuk product yang di delete
log_note = (f"
Product '{line.product_id.name}' rejected. "
f"Quantity: {line.product_uom_qty}, "
f"Date: {now.strftime('%d-%m-%Y')}, "
f"Time: {now.strftime('%H:%M:%S')} "
f"Reason reject: {initial_reason} ")
lines_to_reject.append({
'sale_order_id': line.order_id.id,
'product_id': line.product_id.id,
'qty_reject': line.product_uom_qty,
'reason_reject': initial_reason, # pesan reason reject
'message_body': log_note,
'order_id': line.order_id,
})
# Call the original unlink method
result = super(SaleOrderLine, self).unlink()
# After deletion, create reject lines and post messages
SalesOrderReject = self.env['sales.order.reject']
for reject_data in lines_to_reject:
# Buat line baru di reject line
SalesOrderReject.create({
'sale_order_id': reject_data['sale_order_id'],
'product_id': reject_data['product_id'],
'qty_reject': reject_data['qty_reject'],
'reason_reject': reject_data['reason_reject'],
})
# Post to chatter with a more prominent message
reject_data['order_id'].message_post(
body=reject_data['message_body'],
author_id=self.env.user.partner_id.id, # menampilkan pesan di lognote sebagai current user
)
return result
class SaleOrder(models.Model):
_inherit = "sale.order"
sale_forecast_lines = fields.One2many('sale.forecast.coverage', 'sale_id', string='Sale Forecast Lines')
ccm_id = fields.Many2one('tukar.guling', string='Doc. CCM', readonly=True, compute='_has_ccm', copy=False)
ongkir_ke_xpdc = fields.Float(string='Ongkir ke Ekspedisi', help='Biaya ongkir ekspedisi', copy=False, index=True,
tracking=3)
metode_kirim_ke_xpdc = fields.Selection([
('indoteknik_deliv', 'Indoteknik Delivery'),
('lalamove', 'Lalamove'),
('grab', 'Grab'),
('gojek', 'Gojek'),
('deliveree', 'Deliveree'),
('other', 'Other'),
], string='Metode Kirim Ke Ekspedisi', copy=False, index=True, tracking=3)
notes = fields.Text(string="Notes", tracking=3)
koli_lines = fields.One2many('sales.order.koli', 'sale_order_id', string='Sales Order Koli', auto_join=True)
fulfillment_line_v2 = fields.One2many('sales.order.fulfillment.v2', 'sale_order_id', string='Fullfillment2')
fullfillment_line = fields.One2many('sales.order.fullfillment', 'sales_order_id', string='Fullfillment')
reject_line = fields.One2many('sales.order.reject', 'sale_order_id', string='Reject Lines')
order_sales_match_line = fields.One2many('sales.order.purchase.match', 'sales_order_id',
string='Purchase Match Lines',
states={'cancel': [('readonly', True)], 'done': [('readonly', True)]},
copy=True)
total_margin = fields.Float('Total Margin', compute='_compute_total_margin',
help="Total Margin in Sales Order Header")
total_before_margin = fields.Float('Total Before Margin', compute='_compute_total_before_margin',
help="Total Margin in Sales Order Header")
total_percent_margin = fields.Float('Total Percent Margin', compute='_compute_total_percent_margin',
help="Total % Margin in Sales Order Header")
total_margin_excl_third_party = fields.Float('Before Margin', help="Before Margin in Sales Order Header")
approval_status = fields.Selection([
('pengajuan0', 'Approval Leader Team Sales'),
('pengajuan1', 'Approval Manager'),
('pengajuan2', 'Approval Pimpinan'),
('approved', 'Approved'),
], string='Approval Status', readonly=True, copy=False, index=True, tracking=3)
carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method', tracking=3)
have_visit_service = fields.Boolean(string='Have Visit Service', compute='_have_visit_service',
help='To compute is customer get visit service')
delivery_amt = fields.Float(string='Delivery Amt', copy=False, tracking=True)
shipping_cost_covered = fields.Selection([
('indoteknik', 'Indoteknik'),
('customer', 'Customer')
], string='Shipping Covered by', help='Siapa yang menanggung biaya ekspedisi?', copy=False, tracking=3)
shipping_paid_by = fields.Selection([
('indoteknik', 'Indoteknik'),
('customer', 'Customer')
], string='Shipping Paid by', help='Siapa yang talangin dulu Biaya ekspedisi-nya?', copy=False, tracking=3)
sales_tax_id = fields.Many2one('account.tax', string='Tax',
domain=['|', ('active', '=', False), ('active', '=', True)])
have_outstanding_invoice = fields.Boolean('Have Outstanding Invoice', compute='_have_outstanding_invoice')
have_outstanding_picking = fields.Boolean('Have Outstanding Picking', compute='_have_outstanding_picking')
have_outstanding_po = fields.Boolean('Have Outstanding PO', compute='_have_outstanding_po')
purchase_ids = fields.Many2many('purchase.order', string='Purchases', compute='_get_purchases')
real_shipping_id = fields.Many2one(
'res.partner', string='Real Delivery Address', readonly=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Dipakai untuk alamat tempel", tracking=True)
real_invoice_id = fields.Many2one(
'res.partner', string='Delivery Invoice Address', required=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Dipakai untuk alamat tempel", tracking=True)
fee_third_party = fields.Float('Fee Pihak Ketiga')
biaya_lain_lain = fields.Float('Biaya Lain Lain')
so_status = fields.Selection([
('terproses', 'Terproses'),
('sebagian', 'Sebagian Diproses'),
('menunggu', 'Menunggu Diproses'),
], copy=False)
partner_purchase_order_name = fields.Char(string='Nama PO Customer', copy=False,
help="Nama purchase order customer, diisi oleh customer melalui website.",
tracking=3)
partner_purchase_order_description = fields.Text(string='Keterangan PO Customer', copy=False,
help="Keterangan purchase order customer, diisi oleh customer melalui website.",
tracking=3)
partner_purchase_order_file = fields.Binary(string='File PO Customer', copy=False,
help="File purchase order customer, diisi oleh customer melalui website.")
payment_status = fields.Selection([
('pending', 'Pending'),
('capture', 'Capture'),
('settlement', 'Settlement'),
('deny', 'Deny'),
('cancel', 'Cancel'),
('expire', 'Expire'),
('failure', 'Failure'),
('refund', 'Refund'),
('chargeback', 'Chargeback'),
('partial_refund', 'Partial Refund'),
('partial_chargeback', 'Partial Chargeback'),
('authorize', 'Authorize'),
], tracking=True, string='Payment Status',
help='Payment Gateway Status / Midtrans / Web, https://docs.midtrans.com/en/after-payment/status-cycle')
date_doc_kirim = fields.Datetime(string='Tanggal Kirim di SJ',
help="Tanggal Kirim di cetakan SJ yang terakhir, tidak berpengaruh ke Accounting")
payment_type = fields.Char(string='Payment Type', help='Jenis pembayaran dengan Midtrans')
gross_amount = fields.Float(string='Gross Amount', help='Jumlah pembayaran yang dilakukan dengan Midtrans')
notification = fields.Char(string='Notification', help='Dapat membantu error dari approval')
delivery_service_type = fields.Char(string='Delivery Service Type', help='data dari rajaongkir')
grand_total = fields.Monetary(string='Grand Total', help='Amount total + amount delivery',
compute='_compute_grand_total')
payment_link_midtrans = fields.Char(string='Payment Link',
help='Url payment yg digenerate oleh midtrans, harap diserahkan ke customer agar dapat dilakukan pembayaran secara mandiri')
payment_qr_code = fields.Binary("Payment QR Code")
due_id = fields.Many2one('due.extension', string="Due Extension", readonly=True, tracking=True)
vendor_approval_id = fields.Many2many('vendor.approval', string="Vendor Approval", readonly=True, tracking=True,
copy=False)
customer_type = fields.Selection([
('pkp', 'PKP'),
('nonpkp', 'Non PKP')
], related="partner_id.customer_type", string="Customer Type", readonly=True)
sppkp = fields.Char(string="SPPKP", related="partner_id.sppkp")
npwp = fields.Char(string="NPWP", related="partner_id.npwp")
purchase_total = fields.Monetary(string='Purchase Total', compute='_compute_purchase_total')
voucher_id = fields.Many2one(comodel_name='voucher', string='Voucher', copy=False)
applied_voucher_id = fields.Many2one(comodel_name='voucher', string='Applied Voucher', copy=False)
amount_voucher_disc = fields.Float(string='Voucher Discount')
applied_voucher_shipping_id = fields.Many2one(comodel_name='voucher', string='Applied Voucher', copy=False)
amount_voucher_shipping_disc = fields.Float(string='Voucher Discount')
source_id = fields.Many2one('utm.source', 'Source', domain="[('id', 'in', [32, 59, 60, 61])]", required=True)
estimated_arrival_days = fields.Integer('Estimated Arrival To', default=0)
estimated_arrival_days_start = fields.Integer('Estimated Arrival From', default=0)
email = fields.Char(string='Email')
picking_iu_id = fields.Many2one('stock.picking', 'Picking IU')
helper_by_id = fields.Many2one('res.users', 'Helper By')
eta_date_start = fields.Datetime(string='ETA Date start', copy=False, compute='_compute_eta_date')
eta_date = fields.Datetime(string='ETA Date end', copy=False, compute='_compute_eta_date')
flash_sale = fields.Boolean(string='Flash Sale', help='Data dari web')
is_continue_transaction = fields.Boolean(string='Button Transaction', help='Data dari web')
web_approval = fields.Selection([
('company', 'Company'),
('cust_manager', 'Customer Manager'),
('cust_director', 'Customer Director'),
('cust_procurement', 'Customer Procurement')
], string='Web Approval', copy=False)
compute_fullfillment = fields.Boolean(string='Compute Fullfillment', compute="_compute_fullfillment")
vendor_approval = fields.Boolean(string='Vendor Approval')
note_ekspedisi = fields.Char(string="Note Ekspedisi")
date_kirim_ril = fields.Datetime(string='Tanggal Kirim SJ', compute='_compute_date_kirim', copy=False)
date_status_done = fields.Datetime(string='Date Done DO', compute='_compute_date_kirim', copy=False)
date_driver_arrival = fields.Datetime(string='Arrival Date', compute='_compute_date_kirim', copy=False)
date_driver_departure = fields.Datetime(string='Departure Date', compute='_compute_date_kirim', copy=False)
note_website = fields.Char(string="Note Website")
use_button = fields.Boolean(string='Using Calculate Selling Price', copy=False)
unreserve_id = fields.Many2one('stock.picking', 'Unreserve Picking')
voucher_shipping_id = fields.Many2one(comodel_name='voucher', string='Voucher Shipping', copy=False)
margin_after_delivery_purchase = fields.Float(string='Margin After Delivery Purchase',
compute='_compute_margin_after_delivery_purchase')
percent_margin_after_delivery_purchase = fields.Float(string='% Margin After Delivery Purchase',
compute='_compute_margin_after_delivery_purchase')
purchase_delivery_amt = fields.Float(string='Purchase Delivery Amount', compute='_compute_purchase_delivery_amount')
type_promotion = fields.Char(string='Type Promotion', compute='_compute_type_promotion')
partner_invoice_id = fields.Many2one(
'res.partner', string='Invoice Address',
readonly=True, required=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
tracking=True, # Menambahkan tracking=True
)
partner_shipping_id = fields.Many2one(
'res.partner', string='Delivery Address', readonly=True, required=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=True)
payment_term_id = fields.Many2one(
'account.payment.term', string='Payment Terms', check_company=True, # Unrequired company
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=True)
total_weight = fields.Float(string='Total Weight', compute='_compute_total_weight')
pareto_status = fields.Selection([
('PR', 'Pareto Repeating'),
('PPR', 'Potensi Pareto Repeating'),
('PNR', 'Pareto Non Repeating'),
('NP', 'Non Pareto')
])
# estimated_ready_ship_date = fields.Datetime(
# string='ET Ready to Ship compute',
# compute='_compute_etrts_date'
# )
expected_ready_to_ship = fields.Datetime(
string='ET Ready to Ship',
copy=False
)
shipping_method_picking = fields.Char(string='Shipping Method Picking', compute='_compute_shipping_method_picking')
reason_cancel = fields.Selection([
('harga_terlalu_mahal', 'Harga barang terlalu mahal'),
('harga_web_tidak_valid', 'Harga web tidak valid'),
('stok_kosong', 'Stock kosong'),
('tidak_mau_indent', 'Customer tidak mau indent'),
('batal_rencana_pembelian', 'Customer membatalkan rencana pembelian'),
('vendor_tidak_support_demo', 'Vendor tidak support demo/trial product'),
('product_knowledge_kurang', 'Product knowledge kurang baik'),
('barang_tidak_sesuai', 'Barang tidak sesuai/tepat'),
('tidak_sepakat_pembayaran', 'Tidak menemukan kesepakatan untuk pembayaran'),
('dokumen_tidak_support', 'Indoteknik tidak bisa support document yang dibutuhkan (Ex: TKDN, COO, SNI)'),
('ganti_quotation', 'Ganti Quotation'),
('testing_internal', 'Testing Internal'),
('revisi_data', 'Revisi Data'),
], string='Reason for Cancel', copy=False, index=True, tracking=3)
attachment_bukti = fields.Many2one(
'ir.attachment',
string="Attachment Bukti Cancel", readonly=False,
)
nomor_so_pengganti = fields.Char(string='Nomor SO Pengganti', copy=False, tracking=3)
shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option", domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]")
select_shipping_option = fields.Selection([
('biteship', 'Biteship'),
('custom', 'Custom'),
], string='Shipping Option', help="Select shipping option for delivery", tracking=True, default='custom')
hold_outgoing = fields.Boolean('Hold Outgoing SO', tracking=3)
state_ask_cancel = fields.Selection([
('hold', 'Hold'),
('approve', 'Approve')
], tracking=True, string='State Cancel', copy=False)
ready_to_ship_status_detail = fields.Char(
string='Status Shipping Detail',
compute='_compute_ready_to_ship_status_detail'
)
date_hold = fields.Datetime(string='Date Hold', tracking=True, readonly=True, help='Waktu ketika SO di Hold'
)
date_unhold = fields.Datetime(string='Date Unhold', tracking=True, readonly=True, help='Waktu ketika SO di Unhold'
)
et_products = fields.Datetime(string='ET Products', help="Leadtime produk berdasarkan SLA vendor, tanpa logistik.", tracking=True)
eta_date_reserved = fields.Datetime(
string="Date Reserved",
compute="_compute_eta_date_reserved",
help="Tanggal pertama kali barang berhasil di-reservasi pada DO (BU/PICK/) yang berstatus Siap Dikirim."
)
refund_ids = fields.Many2many('refund.sale.order', compute='_compute_refund_ids', string='Refunds')
refund_count = fields.Integer(string='Refund Count', compute='_compute_refund_count')
advance_payment_move_id = fields.Many2one(
'account.move',
compute='_compute_advance_payment_move',
string='Advance Payment Move',
)
advance_payment_move_ids = fields.Many2many(
'account.move',
compute='_compute_advance_payment_moves',
string='All Advance Payment Moves',
)
advance_payment_move_count = fields.Integer(
string='Jumlah Jurnal Uang Muka',
compute='_compute_advance_payment_moves',
store=False
)
reserved_percent = fields.Float(
string="Reserved %", digits=(16, 2),
compute="_compute_reserved_delivered_pie", store=False
)
delivered_percent = fields.Float(
string="Delivered %", digits=(16, 2),
compute="_compute_reserved_delivered_pie", store=False
)
unreserved_percent = fields.Float(
string="Unreserved %", digits=(16, 2),
compute="_compute_reserved_delivered_pie", store=False
)
payment_state_custom = fields.Selection([
('unpaid', 'Unpaid'),
('partial', 'Partially Paid'),
('paid', 'Full Paid'),
('no_invoice', 'No Invoice'),
], string="Payment Status Invoice", compute="_compute_payment_state_custom", store=False)
partner_is_cbd_locked = fields.Boolean(
string="Partner Locked CBD",
compute="_compute_partner_is_cbd_locked"
)
internal_notes_contact = fields.Text(related='partner_id.comment', string="Internal Notes", readonly=True, help="Internal Notes dari contact utama customer.")
is_so_fiktif = fields.Boolean('SO Fiktif?', tracking=3)
team_id = fields.Many2one(tracking=True)
client_order_ref = fields.Char(tracking=True)
sourcing_job_count = fields.Integer(string='Sourcing Count', compute='_compute_sourcing_count')
forecast_raw = fields.Text(
string='Forecast Raw',
compute='_compute_forecast_raw'
)
forecast_html = fields.Html(
string='Forecast Coverage',
compute='_compute_forecast_html'
)
def cron_generate_sale_forecast(self):
Forecast = self.env['sale.forecast.coverage']
report = self.env['report.stock.report_product_product_replenishment']
# ambil SO yang punya move aktif (lebih cepat)
moves = self.env['stock.move'].search([
('state', 'not in', ['done', 'cancel']),
('sale_line_id', '!=', False)
])
sos = moves.mapped('sale_line_id.order_id').filtered(
lambda so: so.state in ('sale', 'done')
)
if not sos:
return
# hapus forecast lama sekaligus (1 query)
Forecast.search([
('sale_id', 'in', sos.ids)
]).unlink()
all_rows = []
for so in sos:
rows = self._generate_forecast_for_so_fast(so, report)
if rows:
all_rows.extend(rows)
if all_rows:
Forecast.create(all_rows)
def _generate_forecast_for_so_fast(self, so, report):
result = []
products = so.order_line.mapped('product_id')
if not products:
return result
# mapping SOL
sol_map = {l.product_id.id: l for l in so.order_line}
# cache reserved qty sekali
reserved_map = {}
for sol in so.order_line:
reserved_map[sol.product_id.id] = sum(
sol.move_ids.mapped('reserved_availability')
)
for product in products:
data = report._get_report_data(
product_variant_ids=[product.id]
)
for l in data.get('lines', []):
doc_out = l.get('document_out')
if not doc_out or doc_out._name != 'sale.order' or doc_out.id != so.id:
continue
sol = sol_map.get(product.id)
if not sol:
continue
# supply logic
doc_in = l.get('document_in')
if doc_in:
supply_name = doc_in.display_name
elif l.get('reservation'):
supply_name = "Reserved from stock"
elif l.get('replenishment_filled'):
supply_name = (
"Inventory On Hand"
if doc_out
else "Free Stock"
)
else:
supply_name = "Not Available"
receipt_date = l.get('receipt_date')
if receipt_date:
try:
receipt_date = datetime.strptime(
receipt_date,
"%d/%m/%Y %H:%M:%S"
).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
receipt_date = False
result.append({
'sale_id': so.id,
'sale_line_id': sol.id,
'product_id': sol.product_id.id,
'so_qty': sol.product_uom_qty,
'reserved_qty': reserved_map.get(product.id, 0),
'forecast_qty': l.get('quantity'),
'receipt_date': receipt_date,
'document_in_name': supply_name,
'reservation': bool(l.get('reservation')),
'is_late': bool(l.get('is_late')),
'replenishment_filled': bool(l.get('replenishment_filled')),
})
return result
@api.depends('order_line.product_id', 'order_line.product_uom_qty')
def _compute_forecast_raw(self):
report = self.env['report.stock.report_product_product_replenishment']
for so in self:
so.forecast_raw = '[]'
product_ids = so.order_line.mapped('product_id').ids
if not product_ids:
continue
data = report._get_report_data(product_variant_ids=product_ids)
result = []
for l in data.get('lines', []):
sol = self.env['sale.order.line'].search([
('order_id', '=', so.id),
('product_id', '=', l['product']['id'])
], limit=1)
so_qty = sol.product_uom_qty if sol else 0
reserved_qty = sum(
self.env['stock.move.line'].search([
('move_id.sale_line_id', '=', sol.id),
('state', '!=', 'cancel')
]).mapped('product_uom_qty')
) if sol else 0
doc_out = l.get('document_out')
if doc_out and doc_out._name == 'sale.order' and doc_out.id == so.id:
doc_in = l.get('document_in')
result.append({
'product': l['product']['display_name'],
'so_qty': so_qty,
'reserved_qty': reserved_qty,
'quantity': l.get('quantity'),
'receipt_date': l.get('receipt_date'),
'delivery_date': l.get('delivery_date'),
'document_in_name': doc_in.display_name if doc_in else '',
'document_in_model': doc_in._name if doc_in else '',
'document_in_id': doc_in.id if doc_in else False,
'document_out_exists': bool(doc_out),
'reservation': bool(l.get('reservation')),
'is_late': bool(l.get('is_late')),
'replenishment_filled': bool(l.get('replenishment_filled')),
})
so.forecast_raw = json.dumps(result)
@api.depends('forecast_raw')
def _compute_forecast_html(self):
for so in self:
rows = []
try:
data = json.loads(so.forecast_raw or '[]')
except Exception:
data = []
for l in data:
badge = 'π’' if l['replenishment_filled'] else 'π΄'
late = 'β οΈ' if l['is_late'] else ''
# ==== SUPPLY STATUS LOGIC ====
if l['document_in_id']:
supply_html = f"""
{l['document_in_name']}
"""
elif l['reservation']:
supply_html = "Reserved from stock"
elif l['replenishment_filled']:
if l['document_out_exists']:
supply_html = "Inventory On Hand"
else:
supply_html = "Free Stock"
else:
supply_html = 'Not Available'
rows.append(f"""
| {l['product']} |
{l['quantity']} |
{supply_html} |
{l['receipt_date'] or ''} |
{l['so_qty']} |
{l['reserved_qty']} |
""")
so.forecast_html = f"""
| Product |
Qty |
Supplied By |
Receipt Date |
SO Qty |
Reserved |
{''.join(rows)}
"""
def action_set_shipping_id(self):
for rec in self:
bu_pick = self.env['stock.picking'].search([
('state', 'not in', ['cancel']),
('picking_type_id', '=', 30),
('sale_id', '=', rec.id),
('linked_manual_bu_out', '=', False)
])
# bu_out = bu_pick_has_out.mapped('linked_manual_bu_out')
bu_out = self.env['stock.picking'].search([
('sale_id', '=', rec.id),
('picking_type_id', '=', 29),
('state', 'not in', ['cancel', 'done'])
])
bu_pick_has_out = self.env['stock.picking'].search([
('state', 'not in', ['cancel']),
('picking_type_id', '=', 30),
('sale_id', '=', rec.id),
('linked_manual_bu_out.id', '=', bu_out.id),
('linked_manual_bu_out.state', 'not in', ['done', 'cancel'])
])
for pick in bu_pick_has_out:
linked_out = pick.linked_manual_bu_out
if pick.real_shipping_id != rec.real_shipping_id or linked_out.partner_id != rec.partner_shipping_id:
pick.real_shipping_id = rec.real_shipping_id
pick.partner_id = rec.partner_shipping_id
linked_out.partner_id = rec.partner_shipping_id
linked_out.real_shipping_id = rec.real_shipping_id
_logger.info('Updated bu_pick [%s] and bu_out [%s]', pick.name, linked_out.name)
for pick in bu_pick:
if pick.real_shipping_id != rec.real_shipping_id:
pick.real_shipping_id = rec.real_shipping_id
pick.partner_id = rec.partner_shipping_id
bu_out.partner_id = rec.partner_shipping_id
bu_out.real_shipping_id = rec.real_shipping_id
_logger.info('Updated bu_pick [%s] without bu_out', pick.name)
def action_open_partial_delivery_wizard(self):
# raise UserError("Fitur ini sedang dalam pengembangan")
self.ensure_one()
pickings = self.picking_ids.filtered(lambda p: p.state not in ['done', 'cancel'] and p.name and 'BU/PICK/' in p.name)
return {
'type': 'ir.actions.act_window',
'name': 'Partial Delivery',
'res_model': 'partial.delivery.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_id': self.id,
# kasih langsung list of int biar ga ribet di wizard
'default_picking_ids': pickings.ids,
}
}
@api.depends('partner_id.is_cbd_locked')
def _compute_partner_is_cbd_locked(self):
for order in self:
order.partner_is_cbd_locked = order.partner_id.is_cbd_locked
@api.constrains('payment_term_id', 'partner_id')
def _check_cbd_lock_sale_order(self):
cbd_term = self.env['account.payment.term'].browse(26)
for rec in self:
if rec.state == 'draft' and rec.partner_id.is_cbd_locked:
if rec.payment_term_id and rec.payment_term_id != cbd_term:
raise ValidationError(
"Customer ini terkunci ke CBD, hanya boleh pakai Payment Term CBD."
)
@api.depends('invoice_ids.payment_state', 'invoice_ids.amount_total', 'invoice_ids.amount_residual')
def _compute_payment_state_custom(self):
for order in self:
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
total = sum(invoices.mapped('amount_total'))
residual = sum(invoices.mapped('amount_residual'))
if not invoices or total == 0:
order.payment_state_custom = 'no_invoice'
continue
paid = total - residual
percent_paid = (paid / total) * 100 if total > 0 else 0.0
if percent_paid == 100:
order.payment_state_custom = 'paid'
elif percent_paid == 0:
order.payment_state_custom = 'unpaid'
else:
order.payment_state_custom = 'partial'
@api.depends(
'order_line.move_ids.state',
'order_line.move_ids.reserved_availability',
'order_line.move_ids.quantity_done',
'order_line.move_ids.picking_type_id'
)
def _compute_reserved_delivered_pie(self):
for order in self:
order_qty = sum(order.order_line.mapped('product_uom_qty')) or 0.0
reserved_qty = delivered_qty = 0.0
if order_qty > 0:
# Kumpulin semua moves dari order
all_moves = order.order_line.mapped('move_ids')
for move in all_moves:
# --- CASE 1: Move belum selesai ---
if move.state not in ('done', 'cancel'):
# Reserved qty hanya dari move yang belum selesai
reserved_qty += move.reserved_availability or 0.0
continue
# --- CASE 2: Move sudah done ---
if move.location_dest_id.usage == 'customer':
# Barang dikirim ke customer
delivered_qty += move.quantity_done or 0.0
elif move.location_id.usage == 'customer':
# Barang balik dari customer (retur)
delivered_qty -= move.quantity_done or 0.0
# Clamp supaya delivered gak minus
delivered_qty = max(delivered_qty, 0)
# Hitung persen
order.reserved_percent = min((reserved_qty / order_qty) * 100, 100) if order_qty else 0
order.delivered_percent = min((delivered_qty / order_qty) * 100, 100) if order_qty else 0
order.unreserved_percent = max(100 - order.reserved_percent - order.delivered_percent, 0)
def _has_ccm(self):
if self.id:
self.ccm_id = self.env['tukar.guling'].search([('origin', 'ilike', self.name)], limit=1)
reason_change_date_planned = fields.Selection([
('delay', 'Delay By Vendor'),
('urgent', 'Urgent Delivery'),
], string='Reason Change Date Planned', tracking=True)
@api.depends('order_line.product_id', 'date_order')
def _compute_et_products(self):
jakarta = pytz.timezone("Asia/Jakarta")
for order in self:
if not order.order_line or not order.date_order:
order.et_products = False
continue
# Ambil tanggal order sebagai basis
base_date = order.date_order
if base_date.tzinfo is None:
base_date = jakarta.localize(base_date)
else:
base_date = base_date.astimezone(jakarta)
# Ambil nilai SLA vendor dalam hari
sla_data = order.calculate_sla_by_vendor(order.order_line)
sla_days = sla_data.get('slatime', 1)
# Hitung ETA produk (tanpa logistik)
eta_datetime = base_date + timedelta(days=sla_days)
# Simpan ke field sebagai UTC-naive datetime (standar Odoo)
order.et_products = eta_datetime.astimezone(pytz.utc).replace(tzinfo=None)
@api.depends('picking_ids.state', 'picking_ids.date_done')
def _compute_eta_date_reserved(self):
for order in self:
pickings = order.picking_ids.filtered(
lambda p: p.state in ('assigned', 'done') and p.date_reserved and 'BU/PICK/' in (p.name or '')
)
done_dates = [d for d in pickings.mapped('date_done') if d]
order.eta_date_reserved = min(done_dates) if done_dates else False
# order.eta_date_reserved = min(pickings.mapped('date_done')) if pickings else False
@api.onchange('shipping_cost_covered')
def _onchange_shipping_cost_covered(self):
if self.shipping_cost_covered == 'indoteknik' and self.select_shipping_option == 'biteship':
self.shipping_cost_covered = 'customer'
return {
'warning': {
'title': "Biteship Tidak Diizinkan",
'message': (
"Biaya pengiriman ditanggung Indoteknik, sehingga tidak diizinkan menggunakan metode Biteship. "
"Pilihan penanggung biaya akan dikembalikan sebelumnya"
)
}
}
def get_biteship_carrier_ids(self):
courier_codes = tuple(self._get_biteship_courier_codes() or [])
if not courier_codes:
return []
self.env.cr.execute("""
SELECT delivery_carrier_id
FROM rajaongkir_kurir
WHERE name IN %s AND delivery_carrier_id IS NOT NULL
""", (courier_codes,))
result = self.env.cr.fetchall()
carrier_ids = [row[0] for row in result if row[0]]
return carrier_ids
# @api.model
# def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
# res = super(SaleOrder, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu)
# if view_type == 'form':
# doc = etree.XML(res['arch'])
# # Ambil semua delivery_carrier_id dari mapping rajaongkir_kurir
# biteship_ids = self.env['rajaongkir.kurir'].search([]).mapped('delivery_carrier_id.id')
# biteship_ids = list(set(filter(None, biteship_ids))) # pastikan unik dan bukan None
# all_ids = self.env['delivery.carrier'].search([]).ids
# custom_ids = list(set(all_ids) - set(biteship_ids))
# # Format sebagai string Python list
# biteship_ids_str = ','.join(str(i) for i in biteship_ids) or '-1'
# custom_ids_str = ','.join(str(i) for i in custom_ids) or '-1'
# # Terapkan domain ke field carrier_id
# for node in doc.xpath("//field[@name='carrier_id']"):
# # Domain tergantung select_shipping_option
# node.set(
# 'domain',
# "[('id', 'in', [%s]) if select_shipping_option == 'biteship' else ('id', 'in', [%s])]" %
# (biteship_ids_str, custom_ids_str)
# )
# # Simpan kembali hasil XML ke arsitektur form
# res['arch'] = etree.tostring(doc, encoding='unicode')
# return res
# @api.onchange('shipping_option_id')
# def _onchange_shipping_option_id(self):
# if self.shipping_option_id:
# self.delivery_amt = self.shipping_option_id.price
# self.delivery_service_type = self.shipping_option_id.courier_service_code
def _get_biteship_courier_codes(self):
return [
'gojek','grab','deliveree','lalamove','jne','ninja','lion','rara','sicepat','jnt','idexpress','rpx','wahana','jdl','anteraja','sap','paxel','borzo'
]
@api.onchange('carrier_id')
def _onchange_carrier_id(self):
if not self._origin or not self._origin.id:
return
sale_order_id = self._origin.id
self.shipping_option_id = False
if not self.carrier_id:
return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
# Ambil provider dari mapping
self.env.cr.execute("""
SELECT name FROM rajaongkir_kurir
WHERE delivery_carrier_id = %s LIMIT 1
""", (self.carrier_id.id,))
row = self.env.cr.fetchone()
provider = row[0].lower() if row and row[0] else (
self.carrier_id.name.lower().split()[0] if self.carrier_id.name else False
)
_logger.info(f"[Carrier Changed] {self.carrier_id.name}, Detected Provider: {provider}")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Validasi koordinat untuk kurir instan
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
instan_kurir = ['gojek', 'grab', 'lalamove', 'borzo', 'rara', 'deliveree']
if provider in instan_kurir:
lat = self.real_shipping_id.latitude
lng = self.real_shipping_id.longtitude
def is_invalid(val):
try:
return not val or float(val) == 0.0
except (ValueError, TypeError):
return True
if is_invalid(lat) or is_invalid(lng):
self.carrier_id = self._origin.carrier_id
self.shipping_option_id = self._origin.shipping_option_id or False
return {
'warning': {
'title': "Alamat Belum Pin Point",
'message': (
"Kurir instan seperti Gojek, Grab, Lalamove, Borzo, Rara, dan Deliveree "
"membutuhkan alamat pengiriman yang sudah Pin Point.\n\n"
"Silakan tentukan lokasi dengan tepat pada Pin Point Location yang tersedia di kontak."
)
},
'domain': {'shipping_option_id': [('id', '=', -1)]}
}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Baru cek apakah shipping option sudah ada
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
total_so_options = self.env['shipping.option'].search_count([
('sale_order_id', '=', sale_order_id)
])
if total_so_options == 0:
return {'domain': {'shipping_option_id': [('id', '=', -1)]}}
# Validasi: apakah shipping option ada untuk provider ini?
matched = self.env['shipping.option'].search_count([
('sale_order_id', '=', sale_order_id),
('provider', 'ilike', provider),
])
if self.select_shipping_option == 'biteship' and matched == 0:
self.carrier_id = self._origin.carrier_id
self.shipping_option_id = self._origin.shipping_option_id or False
return {
'warning': {
'title': "Shipping Option Tidak Ditemukan",
'message': (
"Layanan kurir ini tidak tersedia pada pengiriman ini. "
"Pilihan dikembalikan ke sebelumnya."
)
},
'domain': {'shipping_option_id': [('id', '=', -1)]}
}
# Kalau semua valid, kembalikan domain normal
domain = [
'|',
'&', ('sale_order_id', '=', sale_order_id), ('provider', 'ilike', f'%{provider}%'),
'&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
]
return {'domain': {'shipping_option_id': domain}}
@api.onchange('shipping_option_id')
def _onchange_shipping_option_id(self):
if not self.shipping_option_id:
return
if not self.carrier_id:
# Jika belum pilih carrier, tetap update harga dan service type
self.delivery_amt = self.shipping_option_id.price
self.delivery_service_type = self.shipping_option_id.courier_service_code
return
# Ambil provider dari carrier
self.env.cr.execute("""
SELECT name FROM rajaongkir_kurir
WHERE delivery_carrier_id = %s LIMIT 1
""", (self.carrier_id.id,))
row = self.env.cr.fetchone()
provider = row[0].lower() if row and row[0] else self.carrier_id.name.lower().split()[0]
selected_provider = (self.shipping_option_id.provider or '').lower()
if provider not in selected_provider:
warning_msg = {
'title': "Opsi Tidak Valid",
'message': f"Opsi pengiriman '{self.shipping_option_id.name}' tidak cocok dengan metode '{self.carrier_id.name}'. Dikembalikan ke sebelumnya."
}
# Kembalikan ke nilai lama (jika record sudah disimpan)
self.shipping_option_id = self._origin.shipping_option_id if self._origin else False
return {'warning': warning_msg}
# Jika valid
self.delivery_amt = self.shipping_option_id.price
self.delivery_service_type = self.shipping_option_id.courier_service_code
def _update_delivery_service_type_from_shipping_option(self, vals):
shipping_option_id = vals.get('shipping_option_id') or self.shipping_option_id.id
if shipping_option_id:
shipping_option = self.env['shipping.option'].browse(shipping_option_id)
if shipping_option.exists():
courier_service = shipping_option.courier_service_code
vals['delivery_service_type'] = courier_service
_logger.info("Set delivery_service_type: %s from shipping_option_id: %s", courier_service, shipping_option_id)
else:
_logger.warning("shipping_option_id %s not found or invalid.", shipping_option_id)
else:
_logger.info("shipping_option_id not found in vals or record.")
# @api.model
# def fields_get(self, allfields=None, attributes=None):
# res = super().fields_get(allfields=allfields, attributes=attributes)
# # Aktifkan hanya kalau sedang buka form Sales Order (safety check)
# if self.env.context.get('params', {}).get('model') == 'sale.order' and \
# self.env.context.get('params', {}).get('id'):
# sale_id = self.env.context['params']['id']
# # Ambil carrier_id dari SO yang sedang dibuka
# self.env.cr.execute("SELECT carrier_id FROM sale_order WHERE id = %s", (sale_id,))
# row = self.env.cr.fetchone()
# carrier_id = row[0] if row else None
# provider = None
# if carrier_id:
# self.env.cr.execute("""
# SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1
# """, (carrier_id,))
# row = self.env.cr.fetchone()
# if row and row[0]:
# provider = row[0].lower()
# else:
# self.env.cr.execute("SELECT name FROM delivery_carrier WHERE id = %s", (carrier_id,))
# row = self.env.cr.fetchone()
# provider = row[0].lower().split()[0] if row and row[0] else ''
# if provider:
# domain = [
# '|',
# '&', ('sale_order_id', '=', sale_id), ('provider', 'ilike', f'%{provider}%'),
# '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%')
# ]
# if 'shipping_option_id' in res:
# res['shipping_option_id']['domain'] = domain
# _logger.info(f"fields_get - Injected domain for shipping_option_id: {domain}")
# return res
@api.onchange('select_shipping_option')
def _onchange_select_shipping_option(self):
self.shipping_option_id = False
self.delivery_service_type = False
self.carrier_id = False
self.delivery_amt = 0
biteship_carrier_ids = []
self.env.cr.execute("""
SELECT delivery_carrier_id
FROM rajaongkir_kurir
WHERE name IN %s
""", (tuple(self._get_biteship_courier_codes()),))
biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]]
if self.select_shipping_option == 'biteship':
if self.shipping_cost_covered == 'indoteknik':
self.select_shipping_option = self._origin.select_shipping_option if self._origin else 'custom'
return {
'warning': {
'title': "Biteship Tidak Diizinkan",
'message': (
"Biaya pengiriman ditanggung Indoteknik. Tidak diizinkan memilih metode Biteship. "
"Opsi pengiriman dikembalikan ke sebelumnya."
)
}
}
domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else [('id', '=', -1)]
else:
domain = [] # tampilkan semua
return {'domain': {'carrier_id': domain}}
# def _compute_total_margin_excl_third_party(self):
# for order in self:
# if order.amount_untaxed == 0:
# order.total_margin_excl_third_party = 0
# continue
#
# # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
# order.total_margin_excl_third_party = round((order.total_before_margin / (order.amount_untaxed)) * 100, 2)
# # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
#
def ask_retur_cancel_purchasing(self):
for rec in self:
if self.env.user.has_group('indoteknik_custom.group_role_purchasing'):
rec.state_ask_cancel = 'approve'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Persetujuan Diberikan',
'message': 'Proses cancel sudah disetujui',
'type': 'success',
'sticky': True
}
}
else:
rec.state_ask_cancel = 'hold'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Menunggu Persetujuan',
'message': 'Tim Purchasing akan memproses permintaan Anda',
'type': 'warning',
'sticky': False
}
}
def hold_unhold_qty_outgoing_so(self):
if self.hold_outgoing == True:
self.hold_outgoing = False
self.date_unhold = fields.Datetime.now()
else:
pick = self.env['stock.picking'].search([
('sale_id', '=', self.id),
('state', 'not in', ['cancel', 'done']),
('name', 'ilike', 'BU/PICK/%')
])
for picking in pick:
picking.do_unreserve()
self.hold_outgoing = True
self.date_hold = fields.Datetime.now()
def _validate_uniform_taxes(self):
for order in self:
tax_sets = set()
for line in order.order_line:
tax_ids = tuple(sorted(line.tax_id.ids))
if tax_ids:
tax_sets.add(tax_ids)
if len(tax_sets) > 1:
raise ValidationError("Semua produk dalam Sales Order harus memiliki kombinasi pajak yang sama.")
# @api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain', 'ongkir_ke_xpdc')
# def _check_total_margin_excl_third_party(self):
# for rec in self:
# if rec.fee_third_party == 0 and rec.total_margin_excl_third_party != rec.total_percent_margin:
# # Gunakan direct SQL atau flag context untuk menghindari rekursi
# self.env.cr.execute("""
# UPDATE sale_order
# SET total_margin_excl_third_party = %s
# WHERE id = %s
# """, (rec.total_percent_margin, rec.id))
# self.invalidate_cache()
def _compute_shipping_method_picking(self):
for order in self:
if order.picking_ids:
carrier_names = order.picking_ids.mapped('carrier_id.name')
order.shipping_method_picking = ', '.join(filter(None, carrier_names))
else:
order.shipping_method_picking = False
@api.onchange('payment_status')
def _is_continue_transaction(self):
if not self.is_continue_transaction:
if self.payment_status == 'settlement':
self.is_continue_transaction = True
else:
self.is_continue_transaction = False
def _compute_total_weight(self):
total_weight = 0
missing_weight_products = []
for line in self.order_line:
if line.weight > 0:
total_weight += line.weight * line.product_uom_qty
self.total_weight = total_weight
def action_indoteknik_estimate_shipping(self):
if not self.real_shipping_id.kota_id.is_jabodetabek:
raise UserError('Estimasi ongkir hanya bisa dilakukan di kota Jabodetabek')
total_weight = 0
missing_weight_products = []
for line in self.order_line:
if line.weight > 0:
total_weight += line.weight * line.product_uom_qty
line.product_id.weight = line.weight
else:
missing_weight_products.append(line.product_id.name)
if missing_weight_products:
product_names = '
'.join(missing_weight_products)
self.message_post(body=f"Produk berikut tidak memiliki berat:
{product_names}")
if total_weight == 0:
raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
if total_weight < 10:
total_weight = 10
self.delivery_amt = total_weight * 3000
shipping_option = self.env["shipping.option"].create({
"name": "Indoteknik Delivery",
"price": self.delivery_amt,
"provider": "Indoteknik",
"etd": "1-2 Hari",
"sale_order_id": self.id,
})
self.shipping_option_id = shipping_option.id
self.message_post(
body=(
f"Estimasi pengiriman Indoteknik berhasil:
"
f"Layanan: {shipping_option.name}
"
f"ETD: {shipping_option.etd}
"
f"Biaya: Rp {shipping_option.price:,}
"
f"Provider: {shipping_option.provider}"
),
message_type="comment",
)
def action_estimate_shipping(self):
# if self.carrier_id.id in [1, 151]:
# self.action_indoteknik_estimate_shipping()
# return
if self.select_shipping_option == 'biteship':
return self.action_estimate_shipping_biteship()
elif self.carrier_id.id in [1, 151]: # ID untuk Indoteknik Delivery
return self.action_indoteknik_estimate_shipping()
else:
total_weight = 0
missing_weight_products = []
for line in self.order_line:
if line.weight > 0:
total_weight += line.weight * line.product_uom_qty
line.product_id.weight = line.weight
else:
missing_weight_products.append(line.product_id.name)
if missing_weight_products:
product_names = '
'.join(missing_weight_products)
self.message_post(body=f"Produk berikut tidak memiliki berat:
{product_names}")
if total_weight == 0:
raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.")
kecamatan_name = self.real_shipping_id.kecamatan_id.name
kota_name = self.real_shipping_id.kota_id.name
kelurahan_name = self.real_shipping_id.kelurahan_id.name
destination_subsdistrict_id = self._get_subdistrict_id_from_komerce(kecamatan_name, kota_name, kelurahan_name)
# destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id
if not destination_subsdistrict_id:
raise UserError("Gagal mendapatkan ID kota tujuan.")
result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id)
if not result or not result.get('data'):
raise UserError(_("Kurir %s tidak tersedia untuk tujuan ini. Silakan pilih kurir lain.") % self.carrier_id.name)
if result:
shipping_options = []
for cost in result.get('data', []):
service = cost.get('service')
description = cost.get('description')
etd = cost.get('etd', '')
value = cost.get('cost', 0)
provider = cost.get('code')
shipping_options.append((service, description, etd, value, provider))
self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
_logger.info(f"Shipping options: {shipping_options}")
for service, description, etd, value, provider in shipping_options:
self.env["shipping.option"].create({
"name": service,
"price": value,
"provider": provider,
"etd": etd,
"sale_order_id": self.id,
})
self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id
_logger.info(f"Shipping option SO ID: {self.shipping_option_id}")
self.message_post(
body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}
Detail Lain:
"
f"{'
'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]}, Cost: Rp {s[3]}' for s in shipping_options])}",
message_type="comment"
)
else:
raise UserError("Gagal mendapatkan estimasi ongkir.")
def _validate_for_shipping_estimate(self):
# Cek berat produk
total_weight = 0
missing_weight_products = []
for line in self.order_line:
product_weight = line.product_id.weight or 0
if product_weight > 0:
total_weight += product_weight * line.product_uom_qty
line.weight = product_weight
else:
missing_weight_products.append(line.product_id.name)
if missing_weight_products:
product_names = '
'.join(missing_weight_products)
self.message_post(body=f"Produk berikut tidak memiliki berat:
{product_names}")
if total_weight == 0:
raise UserError("Tidak dapat mengestimasi ongkir tanpa karena berat produk = 0 kg.")
# Validasi alamat pengiriman
if not self.real_shipping_id:
raise UserError("Alamat pengiriman (Real Delivery Address) harus diisi.")
if not self.real_shipping_id.kota_id:
raise UserError("Kota pada alamat pengiriman harus diisi.")
if not self.real_shipping_id.zip:
raise UserError("Kode pos pada alamat pengiriman harus diisi.")
if not self.real_shipping_id.state_id:
raise UserError("Provinsi pada alamat pengiriman harus diisi.")
return total_weight
def action_estimate_shipping_biteship(self):
total_weight = self._validate_for_shipping_estimate()
weight_gram = int(total_weight * 1000)
if weight_gram < 100:
weight_gram = 100
value = int(self.amount_untaxed or sum(line.price_subtotal for line in self.order_line))
items = [{
"name": "Paket Pesanan",
"description": f"Sale Order {self.name}",
"value": value,
"weight": weight_gram,
"quantity": 1,
}]
shipping_address = self.real_shipping_id
_logger.info(f"Shipping Address: {shipping_address}")
origin_data = {
"origin_latitude": -6.3031123,
"origin_longitude": 106.7794934,
}
destination_data = {}
use_coordinate = False
if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'):
if shipping_address.latitude and shipping_address.longtitude:
try:
lat = float(shipping_address.latitude)
lng = float(shipping_address.longtitude)
destination_data = {
"destination_latitude": lat,
"destination_longitude": lng
}
use_coordinate = True
_logger.info(f"Using coordinates: lat={lat}, lng={lng}")
except (ValueError, TypeError):
_logger.warning(f"Invalid coordinates, falling back to postal code")
use_coordinate = False
if not use_coordinate:
if shipping_address.zip:
origin_data = {"origin_postal_code": 14440}
destination_data = {
"destination_postal_code": shipping_address.zip
}
_logger.info(f"Using postal code: {shipping_address.zip}")
else:
raise UserError("Tidak dapat mengestimasikan ongkir: Kode pos tujuan tidak tersedia.")
couriers = ','.join(self._get_biteship_courier_codes())
api_mode = "koordinat" if use_coordinate else "kode_pos"
_logger.info(f"Calling Biteship API with mode: {api_mode}")
result = self._call_biteship_api(origin_data, destination_data, items, couriers)
if not result:
raise UserError("Gagal mendapatkan estimasi ongkir dari Biteship.")
self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
shipping_options = []
courier_options = {}
shipping_services = result.get('pricing', [])
_logger.info(f"Ditemukan {len(shipping_services)} layanan pengiriman")
for service in shipping_services:
courier_code = service.get('courier_code', '').lower()
courier_name = service.get('courier_name', '')
service_name = service.get('courier_service_name', '')
raw_price = service.get('price', 0)
markup_price = int(raw_price * 1.1)
price = round(markup_price / 1000) * 1000
_logger.info(f"Layanan: {courier_name} - {service_name}, Harga: {price}")
if not price:
_logger.warning(f"Melewati layanan dengan harga 0: {courier_name} - {service_name}")
continue
duration = service.get('duration', '')
shipment_range = service.get('shipment_duration_range', '')
shipment_unit = service.get('shipment_duration_unit', 'days')
if duration:
etd = duration
elif shipment_range:
etd = f"{shipment_range} {shipment_unit}"
else:
etd = "1-3 days"
try:
shipping_option = self.env["shipping.option"].create({
"name": f"{courier_name} - {service_name}",
"price": price,
"provider": courier_code,
"etd": etd,
"courier_service_code": service.get('courier_service_code'),
"sale_order_id": self.id,
})
shipping_options.append(shipping_option)
courier_upper = courier_code.upper()
if courier_upper not in courier_options:
courier_options[courier_upper] = []
courier_options[courier_upper].append({
"name": service_name,
"etd": etd,
"price": price
})
_logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}")
except Exception as e:
_logger.error(f"Gagal membuat opsi pengiriman: {str(e)}")
if not shipping_options:
raise UserError(f"Tidak ada layanan pengiriman ditemukan untuk kode pos {destination_data.get('destination_postal_code', '')}. Mohon periksa kembali kode pos atau gunakan metode pengiriman lain.")
# Temukan shipping option yang cocok berdasarkan carrier_id
selected_option = None
if self.carrier_id:
rajaongkir_kurir = self.env['rajaongkir.kurir'].search([
('delivery_carrier_id', '=', self.carrier_id.id)
], limit=1)
if rajaongkir_kurir:
courier_code = rajaongkir_kurir.name.lower()
carrier_name = self.carrier_id.name.lower()
possible_codes = list({
courier_code,
carrier_name,
carrier_name.split()[0] if ' ' in carrier_name else carrier_name
})
_logger.info(f"[MATCHING] Mencari shipping option untuk kurir: {possible_codes}")
for option in shipping_options:
option_provider = (option.provider or '').lower()
option_name = (option.name or '').lower()
if any(code in option_provider or code in option_name for code in possible_codes):
selected_option = option
_logger.info(f"[MATCHED] Shipping option cocok: {option.name}")
break
if not selected_option and shipping_options:
if not self.env.context.get('from_website_checkout'):
_logger.info(f"[DEFAULT] Tidak ada yang cocok, pakai opsi pertama: {shipping_options[0].name}")
selected_option = shipping_options[0]
# β Ganti carrier_id hanya jika BELUM terisi sama sekali (contoh: user dari backend)
if not self.carrier_id:
provider = selected_option.provider.lower()
self.env.cr.execute("""
SELECT delivery_carrier_id FROM rajaongkir_kurir
WHERE LOWER(name) = %s AND delivery_carrier_id IS NOT NULL
LIMIT 1
""", (provider,))
row = self.env.cr.fetchone()
matched_carrier_id = row[0] if row else False
if matched_carrier_id:
self.carrier_id = matched_carrier_id
_logger.info(f"[AUTO-SET] Carrier diisi otomatis ke ID {matched_carrier_id} (provider: {provider})")
else:
_logger.warning(f"[WARNING] Provider {provider} tidak ditemukan di rajaongkir_kurir")
# Set shipping option dan nilai ongkir
if selected_option:
if self.env.context.get('from_website_checkout'):
# Simpan di context sebagai nilai sementara
self = self.with_context(
_temp_delivery_amt=selected_option.price,
_temp_delivery_service=selected_option.courier_service_code,
_temp_shipping_option=selected_option.id
)
else:
self.shipping_option_id = selected_option.id
self.delivery_amt = selected_option.price
self.delivery_service_type = selected_option.courier_service_code
message_lines = [f"Estimasi Ongkos Kirim Biteship:
"]
for courier, options in courier_options.items():
message_lines.append(f"{courier}:
")
for opt in options:
message_lines.append(f"Service: {opt['name']}, ETD: {opt['etd']}, Cost: Rp {int(opt['price']):,}
")
if courier != list(courier_options.keys())[-1]:
message_lines.append("
")
origin_address = "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN"
destination_address = ', '.join(filter(None, [
shipping_address.street,
shipping_address.kelurahan_id.name if shipping_address.kelurahan_id else None,
shipping_address.kecamatan_id.name if shipping_address.kecamatan_id else None,
shipping_address.kota_id.name if shipping_address.kota_id else None,
shipping_address.state_id.name if shipping_address.state_id else None
]))
if use_coordinate:
origin_suffix = f"(Koordinat: {origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})"
destination_suffix = f"(Koordinat: {destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})"
else:
origin_suffix = f"(Kode Pos: {origin_data.get('origin_postal_code')})"
destination_suffix = f"(Kode Pos: {destination_data.get('destination_postal_code')})"
message_lines.append("
Info Lokasi:
")
message_lines.append(f"Asal: {origin_address} {origin_suffix}
")
message_lines.append(f"Tujuan: {destination_address} {destination_suffix}
")
message_body = "".join(message_lines)
self.message_post(
body=message_body,
message_type="comment"
)
# Simpan informasi untuk note ekspedisi
# selected_option = shipping_options[0] # Opsi pertama dipilih sebagai default
# self.note_ekspedisi = f"Pengiriman: {selected_option.name} - Rp {selected_option.price:,.0f} ({selected_option.etd}) [via {api_mode}]"
def _call_biteship_api(self, origin_data, destination_data, items, couriers=None):
url = "https://api.biteship.com/v1/rates/couriers"
api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
# api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
# api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live')
# api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test')
headers = {
'Authorization': api_key,
'Content-Type': 'application/json'
}
if not couriers:
couriers = ','.join(self._get_biteship_courier_codes())
# Persiapkan payload dengan menggabungkan origin, destination, dan items
payload = {
**origin_data,
**destination_data,
"couriers": couriers,
"items": items
}
api_mode = "koordinat" if "destination_latitude" in destination_data else "kode_pos"
try:
_logger.info(f"Calling Biteship API with mode: {api_mode}")
_logger.info(f"Payload: {payload}")
response = requests.post(url, headers=headers, json=payload, timeout=30)
_logger.info(f"Biteship API Status Code: {response.status_code}")
if response.status_code != 200:
_logger.error(f"Biteship API Error Response: {response.text}")
if response.status_code == 200:
result = response.json()
result['api_mode'] = api_mode # Tambahkan info mode API
return result
else:
error_msg = response.text
_logger.error(f"Error calling Biteship API: {response.status_code} - {error_msg}")
return False
except Exception as e:
_logger.error(f"Exception calling Biteship API: {str(e)}")
return False
def _call_rajaongkir_api(self, total_weight, destination_subsdistrict_id):
url = 'https://rajaongkir.komerce.id/api/v1/calculate/domestic-cost'
headers = {
'key': '9b1310f644056d84d60b0af6bb21611a',
}
courier = self.carrier_id.name.lower()
data = {
'origin': 17656,
# 'originType': 'subdistrict',
'destination': int(destination_subsdistrict_id),
# 'destinationType': 'subdistrict',
'weight': int(total_weight * 1000),
'courier': courier,
}
try:
_logger.info(f"Calling RajaOngkir API with data: {data}")
response = requests.post(url, headers=headers, data=data)
_logger.info(f"RajaOngkir response: {response.status_code} - {response.text}")
if response.status_code == 200:
return response.json()
except Exception as e:
_logger.error(f"Exception while calling RajaOngkir: {str(e)}")
def _normalize_city_name(self, city_name):
city_name = city_name.lower()
if city_name.startswith('kabupaten'):
city_name = city_name.replace('kabupaten', '').strip()
elif city_name.startswith('kota'):
city_name = city_name.replace('kota', '').strip()
city_name = " ".join(city_name.split())
return city_name
# def _get_city_id_by_name(self, city_name):
# url = 'https://pro.rajaongkir.com/api/city'
# headers = {
# 'key': '9b1310f644056d84d60b0af6bb21611a',
# }
# normalized_city_name = self._normalize_city_name(city_name)
# response = requests.get(url, headers=headers)
# if response.status_code == 200:
# city_data = response.json()
# for city in city_data['rajaongkir']['results']:
# if city['city_name'].lower() == normalized_city_name:
# return city['city_id']
# return None
# def _get_subdistrict_id_by_name(self, city_id, subdistrict_name):
# url = f'https://pro.rajaongkir.com/api/subdistrict?city={city_id}'
# headers = {
# 'key': '9b1310f644056d84d60b0af6bb21611a',
# }
# response = requests.get(url, headers=headers)
# if response.status_code == 200:
# subdistrict_data = response.json()
# for subdistrict in subdistrict_data['rajaongkir']['results']:
# subsdistrict_1 = subdistrict['subdistrict_name'].lower()
# subsdistrict_2 = subdistrict_name.lower()
# if subsdistrict_1 == subsdistrict_2:
# return subdistrict['subdistrict_id']
# return None
def _get_subdistrict_id_from_komerce(self, kecamatan_name, kota_name, kelurahan_name=None):
url = 'https://rajaongkir.komerce.id/api/v1/destination/domestic-destination'
headers = {
'key': '9b1310f644056d84d60b0af6bb21611a',
}
if kelurahan_name:
search = f"{kelurahan_name} {kecamatan_name} {kota_name}"
else:
search = f"{kecamatan_name} {kota_name}"
params = {
'search': search,
'limit': 5
}
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
if response.status_code == 200:
data = response.json().get('data', [])
_logger.info(f"[Komerce] Fetched {len(data)} subdistricts for search '{search}'")
_logger.info(f"[Komerce] Response: {data}")
normalized_kota = self._normalize_city_name(kota_name)
for item in data:
match_kelurahan = (
not kelurahan_name or
item.get('subdistrict_name', '').lower() == kelurahan_name.lower()
)
if (
match_kelurahan and
item.get('district_name', '').lower() == kecamatan_name.lower() and
item.get('city_name', '').lower() == normalized_kota
):
return item.get('id')
_logger.warning(f"[Komerce] No match for '{kecamatan_name}' in city '{kota_name}' with kelurahan '{kelurahan_name}'")
else:
_logger.error(f"[Komerce] HTTP Error {response.status_code}: {response.text}")
except Exception as e:
_logger.error(f"[Komerce] Exception: {e}")
return None
def _compute_type_promotion(self):
for rec in self:
promotion_types = []
for promotion in rec.order_promotion_ids:
for line_program in promotion.program_line_id:
if line_program.promotion_type:
promotion_types.append(
dict(line_program._fields['promotion_type'].selection).get(line_program.promotion_type))
rec.type_promotion = ', '.join(sorted(set(promotion_types)))
def _compute_purchase_delivery_amount(self):
for order in self:
match = self.env['purchase.order.sales.match']
result2 = match.search([
('sale_id.id', '=', order.id)
])
delivery_amt = 0
for res in result2:
delivery_amt = res.delivery_amt
order.purchase_delivery_amt = delivery_amt
def _compute_margin_after_delivery_purchase(self):
for order in self:
order.margin_after_delivery_purchase = order.total_margin - order.purchase_delivery_amt
if order.amount_untaxed == 0:
order.percent_margin_after_delivery_purchase = 0
continue
if order.shipping_cost_covered == 'indoteknik':
delivery_amt = order.delivery_amt
else:
delivery_amt = 0
order.percent_margin_after_delivery_purchase = round((order.margin_after_delivery_purchase / (
order.amount_untaxed - delivery_amt - order.fee_third_party - order.biaya_lain_lain)) * 100, 2)
def _compute_date_kirim(self):
for rec in self:
picking = self.env['stock.picking'].search(
[('sale_id', '=', rec.id), ('state', 'not in', ['cancel']), ('name', 'not ilike', 'BU/PICK/%')],
order='date_doc_kirim desc', limit=1)
rec.date_kirim_ril = picking.date_doc_kirim
rec.date_status_done = picking.date_done
rec.date_driver_arrival = picking.driver_arrival_date
rec.date_driver_departure = picking.driver_departure_date
def open_form_multi_create_uang_muka(self):
action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_sale_order_multi_uangmuka')
action['context'] = {
'so_ids': [x.id for x in self]
}
return action
def _compute_fullfillment(self):
for rec in self:
# rec.fullfillment_line.unlink()
#
# for line in rec.order_line:
# line._compute_reserved_from()
rec.compute_fullfillment = True
@api.depends('expected_ready_to_ship', 'shipping_option_id.etd', 'state')
def _compute_eta_date(self):
for rec in self:
if rec.expected_ready_to_ship and rec.shipping_option_id and rec.shipping_option_id.etd and rec.state not in ['cancel']:
etd_text = rec.shipping_option_id.etd.strip().lower()
match = re.match(r"(\d+)\s*-\s*(\d+)\s*(days?|hours?)", etd_text)
single_match = re.match(r"(\d+)\s*(days?|hours?)", etd_text)
if match:
start_val = int(match.group(1))
end_val = int(match.group(2))
unit = match.group(3)
if 'hour' in unit:
rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=start_val)
rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=end_val)
else:
rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=start_val)
rec.eta_date = rec.expected_ready_to_ship + timedelta(days=end_val)
elif single_match:
val = int(single_match.group(1))
unit = single_match.group(2)
if 'hour' in unit:
rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=val)
rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=val)
else:
rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=val)
rec.eta_date = rec.expected_ready_to_ship + timedelta(days=val)
else:
rec.eta_date_start = False
rec.eta_date = False
else:
rec.eta_date_start = False
rec.eta_date = False
def get_days_until_next_business_day(self, start_date=None, *args, **kwargs):
jakarta = pytz.timezone("Asia/Jakarta")
now = datetime.now(jakarta)
if start_date is None:
start_date = now
if start_date.tzinfo is None:
start_date = jakarta.localize(start_date)
holiday = self.env['hr.public.holiday']
batas_waktu = datetime.strptime("15:00", "%H:%M").time()
current_day = start_date.date()
offset = 0
is3pm = False
# Step 1: Lewat jam 15 β Tambah 1 hari
if start_date.time() > batas_waktu:
is3pm = True
offset += 1
# Step 2: Hitung hari libur selama offset itu
i = 0
total_days = 0
while i < offset:
current_day += timedelta(days=1)
total_days += 1
is_weekend = current_day.weekday() >= 5
is_holiday = holiday.search([("start_date", "=", current_day)])
if not is_weekend and not is_holiday:
i += 1 # hanya hitung hari kerja
# Step 3: Tambah 1 hari masa persiapan gudang
i = 0
while i < 1:
current_day += timedelta(days=1)
total_days += 1
is_weekend = current_day.weekday() >= 5
is_holiday = holiday.search([("start_date", "=", current_day)])
if not is_weekend and not is_holiday:
i += 1
# Step 4: Kalau current_day ternyata weekend/libur, cari hari kerja berikutnya
while True:
is_weekend = current_day.weekday() >= 5
is_holiday = holiday.search([("start_date", "=", current_day)])
if is_weekend or is_holiday:
current_day += timedelta(days=1)
total_days += 1
else:
break
offset = (current_day - start_date.date()).days
return offset, is3pm
def calculate_sla_by_vendor(self, products):
product_ids = products.mapped('product_id.id') # Kumpulkan semua ID produk
include_instant = True # Default True, tetapi bisa menjadi False
# Cek apakah SEMUA produk memiliki qty_free_bandengan >= qty_needed
all_fast_products = all(
product.product_id.qty_free_bandengan >= product.product_uom_qty for product in products)
if all_fast_products:
return {'slatime': 0, 'include_instant': include_instant}
# Cari semua vendor pemenang untuk produk yang diberikan
vendors = self.env['purchase.pricelist'].search([
('product_id', 'in', product_ids),
('is_winner', '=', True)
])
max_slatime = 1
for vendor in vendors:
vendor_sla = self.env['vendor.sla'].search([('id_vendor', '=', vendor.vendor_id.id)], limit=1)
slatime = 15
if vendor_sla:
if vendor_sla.unit == 'hari':
vendor_duration = vendor_sla.duration * 24 * 60
include_instant = False
else:
vendor_duration = vendor_sla.duration * 60
include_instant = True
estimation_sla = (1 * 24 * 60) + vendor_duration
estimation_sla_days = estimation_sla / (24 * 60)
slatime = math.ceil(estimation_sla_days)
max_slatime = max(max_slatime, slatime)
return {'slatime': max_slatime, 'include_instant': include_instant}
def _calculate_etrts_date(self):
for rec in self:
if not rec.date_order:
rec.expected_ready_to_ship = False
return
jakarta = pytz.timezone("Asia/Jakarta")
current_date = datetime.now(jakarta)
max_slatime = 1 # Default SLA jika tidak ada
slatime = self.calculate_sla_by_vendor(rec.order_line)
max_slatime = max(max_slatime, slatime['slatime'])
offset , is3pm = self.get_days_until_next_business_day(current_date)
sum_days = max_slatime + offset
sum_days -= 1
if not rec.estimated_arrival_days:
rec.estimated_arrival_days = sum_days
eta_date = current_date + timedelta(days=sum_days)
if is3pm:
eta_date = datetime.combine(eta_date, time(10, 0)) # jam 10:00
eta_date = jakarta.localize(eta_date).astimezone(timezone.utc) # ubah ke UTC
eta_date = eta_date.astimezone(timezone.utc).replace(tzinfo=None)
rec.commitment_date = eta_date
rec.expected_ready_to_ship = eta_date
@api.depends("order_line.product_id", "date_order")
def _compute_etrts_date(self):
self._calculate_etrts_date()
# def _validate_expected_ready_ship_date(self):
# for rec in self:
# if not rec.order_line:
# _logger.info("β© Lewati validasi ERTS karena belum ada produk.")
# return # Lewati validasi jika belum ada produk
# now = fields.Datetime.now()
# expected_date = rec.expected_ready_to_ship and rec.expected_ready_to_ship.date() or None
# if not expected_date:
# return # Tidak validasi jika tidak ada input sama sekali
# sla = rec.calculate_sla_by_vendor()
# offset_day, lewat_jam_3 = rec.get_days_until_next_business_day()
# eta_minimum = now + timedelta(days=sla + offset_day)
# if expected_date < eta_minimum.date():
# rec.expected_ready_to_ship = eta_minimum
# raise ValidationError(
# "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}."
# .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y'))
# )
def _validate_expected_ready_ship_date(self):
"""
Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum.
Dipanggil setiap onchange / simpan SO.
"""
for rec in self:
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 1. Hanya validasi kalau field sudah terisi
# (quotation baru / belum ada tanggal β abaikan)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
if not rec.expected_ready_to_ship:
continue
current_date = datetime.now()
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 2. Hitung SLA berdasarkan product lines (jika ada)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
products = rec.order_line
if products:
sla_data = rec.calculate_sla_by_vendor(products)
max_sla_time = sla_data.get('slatime', 1)
else:
# belum ada item β gunakan default 1 hari
max_sla_time = 1
# offset hari liburβ/βweekend
offset, is3pm = rec.get_days_until_next_business_day(current_date)
min_days = max_sla_time + offset - 1
eta_minimum = current_date + timedelta(days=min_days)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
# 3. Validasi - raise error bila terlalu cepat
# βββββββββββββββββββββββββββββββββββββββββββββββββββββ
if rec.expected_ready_to_ship.date() < eta_minimum.date():
# set otomatis ke tanggal minimum supaya user tidak perlu
# menekan Save dua kali
rec.expected_ready_to_ship = eta_minimum
raise ValidationError(
_("Tanggal 'Expected Ready to Ship' tidak boleh "
"lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.")
% {'tgl': eta_minimum.strftime('%d-%m-%Y')}
)
else:
# sinkronkan ke field commitment_date
rec.commitment_date = rec.expected_ready_to_ship
# def _validate_expected_ready_ship_date(self):
# """
# Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum.
# Dipanggil setiap onchange / simpan SO.
# """
# for rec in self:
# if not rec.expected_ready_to_ship:
# continue
#
# # ADDED: gunakan "sekarang" lokal user, bukan datetime.now() server
# current_date = fields.Datetime.context_timestamp(rec, fields.Datetime.now())
#
# # Hitung SLA
# products = rec.order_line
# if products:
# sla_data = rec.calculate_sla_by_vendor(products)
# max_sla_time = sla_data.get('slatime', 1)
# else:
# max_sla_time = 1
#
# # offset hari libur/weekend
# offset, is3pm = rec.get_days_until_next_business_day(current_date)
# min_days = max_sla_time + offset - 1
# eta_minimum = current_date + timedelta(days=min_days)
#
# if rec._fields['expected_ready_to_ship'].type == 'date':
# exp_date_local = rec.expected_ready_to_ship
# else:
# exp_date_local = fields.Datetime.context_timestamp(
# rec, rec.expected_ready_to_ship
# ).date()
#
# if exp_date_local < eta_minimum.date():
# # (opsional) auto-set ke minimum β konversi balik ke UTC naive bila field Datetime
# if rec._fields['expected_ready_to_ship'].type == 'date':
# rec.expected_ready_to_ship = eta_minimum.date()
# else:
# rec.expected_ready_to_ship = eta_minimum.astimezone(pytz.UTC).replace(tzinfo=None)
#
# raise ValidationError(
# _("Tanggal 'Expected Ready to Ship' tidak boleh "
# "lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.")
# % {'tgl': eta_minimum.strftime('%d-%m-%Y')}
# )
# else:
# rec.commitment_date = rec.expected_ready_to_ship
@api.onchange('expected_ready_to_ship')
def _onchange_expected_ready_ship_date(self):
self._validate_expected_ready_ship_date()
def _set_etrts_date(self):
for order in self:
if order.state in ('done', 'cancel', 'sale'):
raise UserError(
_("You cannot change the Estimated Ready To Ship Date on a done, sale or cancelled order."))
# order.move_lines.write({'estimated_ready_ship_date': order.estimated_ready_ship_date})
def _prepare_invoice(self):
"""
Prepare the dict of values to create the new invoice for a sales order. This method may be
overridden to implement custom invoice generation (making sure to call super() to establish
a clean extension chain).
"""
self.ensure_one()
journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal()
if not journal:
raise UserError(
_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name,
self.company_id.id))
parent_id = self.partner_id.parent_id
parent_id = parent_id if parent_id else self.partner_id
invoice_vals = {
'ref': self.client_order_ref or '',
'move_type': 'out_invoice',
'narration': self.note,
'currency_id': self.pricelist_id.currency_id.id,
'campaign_id': self.campaign_id.id,
'medium_id': self.medium_id.id,
'source_id': self.source_id.id,
'down_payment': 229625 in [line.product_id.id for line in self.order_line],
'user_id': self.user_id.id,
'sale_id': self.id,
'invoice_user_id': self.user_id.id,
'team_id': self.team_id.id,
'partner_id': parent_id.id,
'partner_shipping_id': parent_id.id,
'real_invoice_id': self.real_invoice_id.id,
'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(
self.partner_invoice_id.id)).id,
'partner_bank_id': self.company_id.partner_id.bank_ids[:1].id,
'journal_id': journal.id, # company comes from the journal
'invoice_origin': self.name,
'invoice_payment_term_id': self.payment_term_id.id,
'payment_reference': self.reference,
'transaction_ids': [(6, 0, self.transaction_ids.ids)],
'invoice_line_ids': [],
'company_id': self.company_id.id,
}
return invoice_vals
@api.constrains('email')
def _validate_email(self):
rule_regex = self.env['ir.config_parameter'].sudo().get_param('sale.order.validate_email') or ''
pattern = rf'^{rule_regex}$'
if self.email and not re.match(pattern, self.email):
raise UserError('Email yang anda input kurang valid')
# @api.constrains('delivery_amt', 'carrier_id', 'shipping_cost_covered')
def _validate_delivery_amt(self):
is_indoteknik = self.carrier_id.id == 1 or self.shipping_cost_covered == 'indoteknik'
is_active_id = not self.env.context.get('active_id', [])
if is_indoteknik and is_active_id:
if self.delivery_amt == 0:
if self.carrier_id.id == 1:
raise UserError('Untuk Kurir Indoteknik Delivery, estimasi ongkos kirim belum diisi.')
else:
raise UserError('Untuk Shipping Covered Indoteknik, estimasi ongkos kirim belum diisi.')
if self.delivery_amt < 100:
if self.carrier_id.id == 1:
raise UserError(
'Untuk Kurir Indoteknik Delivery, estimasi ongkos kirim belum memenuhi tarif minimum.')
else:
raise UserError(
'Untuk Shipping Covered Indoteknik, estimasi ongkos kirim belum memenuhi tarif minimum.')
# if self.delivery_amt < 5000:
# if (self.carrier_id.id == 1 or self.shipping_cost_covered == 'indoteknik') and not self.env.context.get('active_id', []):
# if self.carrier_id.id == 1:
# raise UserError('Untuk Kurir Indoteknik Delivery, estimasi ongkos kirim belum memenuhi jumlah minimum.')
# else:
# raise UserError('Untuk Shipping Covered Indoteknik, estimasi ongkos kirim belum memenuhi jumlah minimum.')
def override_allow_create_invoice(self):
if not self.env.user.is_accounting:
raise UserError('Hanya Finance Accounting yang dapat klik tombol ini')
# for term in self.payment_term_id.line_ids:
# if term.days > 0:
# raise UserError('Hanya dapat digunakan pada Cash Before Delivery')
for line in self.order_line:
if line.product_id.type == 'product':
line.qty_to_invoice = line.product_uom_qty
# def _get_pickings(self):
# state = ['assigned']
# for order in self:
# pickings = self.env['stock.picking'].search([
# ('sale_id.id', '=', order.id),
# ('state', 'in', state)
# ])
# order.picking_ids = pickings
@api.model
def action_multi_update_state(self):
for sale in self:
for picking_ids in sale.picking_ids:
if not picking_ids.state == 'cancel':
raise UserError('DO harus cancel terlebih dahulu')
sale.update({
'state': 'cancel',
})
if sale.state == 'cancel':
sale.update({
'approval_status': False,
})
def open_form_multi_update_status(self):
if self.env.user.id != 688 or self.env.user.has_group('indoteknik_custom.group_role_it'):
raise UserError("Hanya Finance nya yang bisa approve.")
action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_sale_orders_multi_update')
action['context'] = {
'sale_ids': [x.id for x in self]
}
return action
def open_form_multi_update_state(self):
action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_quotation_so_multi_update')
action['context'] = {
'quotation_ids': [x.id for x in self]
}
return action
def action_multi_update_invoice_status(self):
for sale in self:
sale.update({
'invoice_status': 'invoiced',
})
def _compute_purchase_total(self):
for order in self:
total = 0
for line in order.order_line:
total += line.vendor_subtotal
order.purchase_total = total
def check_data_real_delivery_address(self):
real_delivery_address = self.real_shipping_id
if not real_delivery_address.state_id:
raise UserError('State Real Delivery Address harus diisi')
if not real_delivery_address.zip:
raise UserError('Zip code Real Delivery Address harus diisi')
if not real_delivery_address.mobile:
raise UserError('Mobile Real Delivery Address harus diisi')
if not real_delivery_address.phone:
raise UserError('Phone Real Delivery Address harus diisi')
if not real_delivery_address.kecamatan_id:
raise UserError('Kecamatan Real Delivery Address harus diisi')
# if not real_delivery_address.kelurahan_id:
# raise UserError('Kelurahan Real Delivery Address harus diisi')
def generate_payment_link_midtrans_sales_order(self):
# midtrans_url = 'https://app.sandbox.midtrans.com/snap/v1/transactions' # dev - sandbox
# midtrans_auth = 'Basic U0ItTWlkLXNlcnZlci1uLVY3ZDJjMlpCMFNWRUQyOU95Q1dWWXA6' # dev - sandbox
midtrans_url = 'https://app.midtrans.com/snap/v1/transactions' # production
midtrans_auth = 'Basic TWlkLXNlcnZlci1SbGMxZ2gzWGpSVW5scl9JblZzTV9OTnU6' # production
so_number = self.name
so_number = so_number.replace('/', '-')
so_grandtotal = math.floor(self.grand_total)
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': midtrans_auth,
}
# ==== ENV ====
# check_url = f'https://api.sandbox.midtrans.com/v2/{so_number}/status' # dev - sandbox
check_url = f'https://api.midtrans.com/v2/{so_number}/status' # production
# =============================================
check_response = requests.get(check_url, headers=headers)
if check_response.status_code == 200:
status_response = check_response.json()
if status_response.get('transaction_status') in ('expire', 'cancel'):
so_number = so_number + '-cpl'
json_data = {
'transaction_details': {
'order_id': so_number,
'gross_amount': so_grandtotal,
},
'credit_card': {
'secure': True,
},
}
response = requests.post(midtrans_url, headers=headers, json=json_data).json()
lookup_json = json.dumps(response, indent=4, sort_keys=True)
redirect_url = json.loads(lookup_json)['redirect_url']
self.payment_link_midtrans = str(redirect_url)
if 'redirect_url' in response:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(redirect_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
qr_code_img = base64.b64encode(buffer.getvalue()).decode()
self.payment_qr_code = qr_code_img
@api.model
def _generate_so_access_token(self, limit=50):
orders = self.search([('access_token', '=', False)], limit=limit)
for order in orders:
token_source = string.ascii_letters + string.digits
order.access_token = ''.join(random.choice(token_source) for i in range(20))
def calculate_line_no(self):
line_no = 0
for line in self.order_line:
if line.product_id.type == 'product':
line_no += 1
line.line_no = line_no
# def write(self, vals):
# if 'carrier_id' in vals:
# for picking in self.picking_ids:
# if picking.state == 'assigned':
# picking.carrier_id = self.carrier_id
def calculate_so_status(self):
so_state = ['sale']
sales = self.search([
('state', 'in', so_state),
('so_status', '!=', 'terproses'),
])
for sale in sales:
picking_states = ['draft', 'assigned', 'confirmed', 'waiting']
have_outstanding_pick = any(x.state in picking_states for x in sale.picking_ids)
sum_qty_so = sum(so_line.product_uom_qty for so_line in sale.order_line)
sum_qty_ship = sum(so_line.qty_delivered for so_line in sale.order_line)
if sum_qty_so > sum_qty_ship > 0:
sale.so_status = 'sebagian'
elif not have_outstanding_pick:
sale.so_status = 'terproses'
else:
sale.so_status = 'menunggu'
for picking in sale.picking_ids:
sum_qty_pick = sum(move_line.product_uom_qty for move_line in picking.move_ids_without_package)
sum_qty_reserved = sum(move_line.product_uom_qty for move_line in picking.move_line_ids_without_package)
if picking.state == 'done':
continue
elif sum_qty_pick == sum_qty_reserved and not picking.date_reserved: # baru ke reserved
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
picking.date_reserved = current_time
elif sum_qty_pick == sum_qty_reserved: # sudah ada data reserved
picking.date_reserved = picking.date_reserved
else:
picking.date_reserved = ''
_logger.info('Calculate SO Status %s' % sale.id)
# def _search_picking_ids(self, operator, value):
# if operator == 'in' and value:
# self.env.cr.execute("""
# SELECT array_agg(so.sale_id)
# FROM stock_picking so
# WHERE
# so.sale_id is not null and so.id = ANY(%s)
# """, (list(value),))
# so_ids = self.env.cr.fetchone()[0] or []
# return [('id', 'in', so_ids)]
# elif operator == '=' and not value:
# order_ids = self._search([
# ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund'))
# ])
# return [('id', 'not in', order_ids)]
# return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)]
# @api.depends('partner_id')
# def _compute_partner_field(self):
# for order in self:
# partner = order.partner_id.parent_id or order.partner_id
# order.npwp = partner.npwp
# order.sppkp = partner.sppkp
# order.customer_type = partner.customer_type
@api.onchange('partner_id')
def onchange_partner_contact(self):
parent_id = self.partner_id.parent_id
parent_id = parent_id if parent_id else self.partner_id
# self.npwp = parent_id.npwp
# self.sppkp = parent_id.sppkp
# self.customer_type = parent_id.customer_type
self.email = parent_id.email
self.pareto_status = parent_id.pareto_status
self.user_id = parent_id.user_id
@api.onchange('partner_id')
def onchange_partner_id(self):
# INHERIT
result = super(SaleOrder, self).onchange_partner_id()
parent_id = self.partner_id.parent_id
parent_id = parent_id if parent_id else self.partner_id
self.partner_invoice_id = parent_id
return result
def _get_purchases(self):
po_state = ['done', 'draft', 'purchase']
for order in self:
purchases = self.env['purchase.order'].search([
('sale_order_id', '=', order.id),
('state', 'in', po_state)
])
order.purchase_ids = purchases
def _have_outstanding_invoice(self):
invoice_state = ['posted', 'draft']
for order in self:
order.have_outstanding_invoice = any(inv.state in invoice_state for inv in order.invoice_ids)
def _have_outstanding_picking(self):
picking_state = ['done', 'confirmed', 'draft']
for order in self:
order.have_outstanding_picking = any(pick.state in picking_state for pick in order.picking_ids)
def _have_outstanding_po(self):
po_state = ['done', 'draft', 'purchase']
for order in self:
order.have_outstanding_po = any(po.state in po_state for po in order.purchase_ids)
def _have_visit_service(self):
minimum_amount = 20000000
for order in self:
order.have_visit_service = self.amount_total > minimum_amount
def _get_helper_ids(self):
helper_ids_str = self.env['ir.config_parameter'].sudo().get_param('sale.order.user_helper_ids')
return helper_ids_str.split(', ')
# def write(self, values):
# helper_ids = self._get_helper_ids()
# if str(self.env.user.id) in helper_ids:
# values['helper_by_id'] = self.env.user.id
#
# return super(SaleOrder, self).write(values)
def check_due(self):
"""To show the due amount and warning stage"""
for order in self:
partner = order.partner_id.parent_id or order.partner_id
if partner and partner.active_limit and partner.enable_credit_limit:
order.has_due = partner.due_amount > 0
if order.outstanding_amount >= partner.warning_stage and partner.warning_stage != 0:
order.is_warning = True
else:
order.has_due = False
order.is_warning = False
def _validate_order(self):
if self.payment_term_id.id == 31 and self.total_percent_margin < 25:
raise UserError("Jika ingin menggunakan Tempo 90 Hari maka margin harus di atas 25%")
if self.warehouse_id.id != 8 and self.warehouse_id.id != 10 and self.warehouse_id.id != 12: # GD Bandengan / Pameran
raise UserError('Gudang harus Bandengan atau Pameran')
if self.state not in ['draft', 'sent']:
raise UserError("Status harus draft atau sent")
def _validate_npwp(self):
if not self.npwp:
raise UserError("NPWP partner kosong, silahkan isi terlebih dahulu npwp nya di contact partner")
if not self.customer_type:
raise UserError("Customer Type partner kosong, silahkan isi terlebih dahulu Customer Type nya di contact partner")
if not self.sppkp:
raise UserError("SPPKP partner kosong, silahkan isi terlebih dahulu SPPKP nya di contact partner")
num_digits = sum(c.isdigit() for c in self.npwp)
if num_digits < 10:
raise UserError("NPWP harus memiliki minimal 10 digit")
# pattern = r'^\d{10,}$'
# return re.match(pattern, self.npwp) is not None
def sale_order_check_approve(self):
self.check_due()
self._validate_order()
for order in self:
order._validate_npwp()
order._validate_uniform_taxes()
order.order_line.validate_line()
term_days = 0
for term_line in order.payment_term_id.line_ids:
term_days += term_line.days
partner = order.partner_id.parent_id or order.partner_id
if not partner.property_payment_term_id:
raise UserError("Payment Term pada Master Data Customer harus diisi")
if not partner.active_limit and term_days > 0:
raise UserError("Credit Limit pada Master Data Customer harus diisi")
if order.payment_term_id != partner.property_payment_term_id and not order.partner_id.id == 29179:
raise UserError("Payment Term berbeda pada Master Data Customer")
if (partner.customer_type == 'pkp' or order.customer_type == 'pkp') and order.npwp != partner.npwp:
raise UserError("NPWP berbeda pada Master Data Customer")
if (partner.customer_type == 'pkp' or order.customer_type == 'pkp') and order.sppkp != partner.sppkp:
raise UserError("SPPKP berbeda pada Master Data Customer")
if not order.client_order_ref and order.create_date > datetime(2024, 6, 27):
raise UserError(
"Customer Reference kosong, di isi dengan NO PO jika PO tidak ada mohon ditulis Tanpa PO")
if not order.user_id.active:
raise UserError("Salesperson sudah tidak aktif, mohon diisi yang benar pada data SO dan Contact")
def check_product_bom(self):
for order in self:
for line in order.order_line:
if 'bom-it' in line.name.lower() or 'bom' in line.product_id.default_code.lower() if line.product_id.default_code else False:
search_bom = self.env['mrp.production'].search([('product_id', '=', line.product_id.id), ('sale_order', '=', order.id), ('state', '!=', 'cancel')],
order='name desc')
if search_bom:
confirmed_bom = search_bom.filtered(lambda x: x.state in ['confirmed', 'to_close', 'progress', 'done'])
if not confirmed_bom:
raise UserError(
"Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi Purchasing.")
else:
raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi Purchasing")
def check_duplicate_product(self):
for order in self:
for line in order.order_line:
search_product = self.env['sale.order.line'].search(
[('product_id', '=', line.product_id.id), ('order_id', '=', order.id), ('display_type', '=', False)])
if len(search_product) > 1:
raise UserError("Terdapat DUPLIKASI data pada Product {}".format(line.product_id.display_name))
def sale_order_approve(self):
self.check_duplicate_product()
self.check_product_bom()
self.check_credit_limit()
self.check_limit_so_to_invoice()
if self.validate_different_vendor() and not self.vendor_approval:
return self._create_notification_action('Notification', 'Terdapat Vendor yang berbeda dengan MD Vendor')
self.check_due()
for order in self:
order._validate_npwp()
order._validate_delivery_amt()
order._validate_uniform_taxes()
order.order_line.validate_line()
order.check_data_real_delivery_address()
order._validate_order()
order.sale_order_check_approve()
main_parent = order.partner_id.get_main_parent()
SYSTEM_UID = 25
FROM_WEBSITE = order.create_uid.id == SYSTEM_UID
if FROM_WEBSITE and main_parent.use_so_approval and order.web_approval not in ['cust_procurement',
'cust_director']:
raise UserError("This order not yet approved by customer procurement or director")
if not order.client_order_ref and order.create_date > datetime(2024, 6, 27):
raise UserError(
"Customer Reference kosong, di isi dengan NO PO jika PO tidak ada mohon ditulisΒ TanpaΒ PO")
if not order.commitment_date and order.create_date > datetime(2024, 9, 12):
raise UserError("Expected Delivery Date kosong, wajib diisi")
if not order.real_shipping_id:
UserError('Real Delivery Address harus di isi')
if not self.env.context.get('due_approve', []):
if order.validate_partner_invoice_due():
return self._create_notification_action('Notification',
'Terdapat invoice yang telah melewati batas waktu, mohon perbarui pada dokumen Due Extension')
term_days = 0
for term_line in order.payment_term_id.line_ids:
term_days += term_line.days
partner = order.partner_id.parent_id or order.partner_id
if not partner.property_payment_term_id:
raise UserError("Payment Term pada Master Data Customer harus diisi")
if not partner.active_limit and term_days > 0:
raise UserError("Credit Limit pada Master Data Customer harus diisi")
if order.payment_term_id != partner.property_payment_term_id and not order.partner_id.id == 29179:
raise UserError("Payment Term berbeda pada Master Data Customer")
if (partner.customer_type == 'pkp' or order.customer_type == 'pkp') and order.npwp != partner.npwp:
raise UserError("NPWP berbeda pada Master Data Customer")
if (partner.customer_type == 'pkp' or order.customer_type == 'pkp') and order.sppkp != partner.sppkp:
raise UserError("SPPKP berbeda pada Master Data Customer")
if not order.client_order_ref and order.create_date > datetime(2024, 6, 27):
raise UserError(
"Customer Reference kosong, di isi dengan NO PO jika PO tidak ada mohon ditulisΒ TanpaΒ PO")
if not order.user_id.active:
raise UserError("Salesperson sudah tidak aktif, mohon diisi yang benar pada data SO dan Contact")
# if order.validate_partner_invoice_due():
# return self._create_notification_action('Notification',
# 'Terdapat invoice yang telah melewati batas waktu, mohon perbarui pada dokumen Due Extension')
value_trigger = order._requires_approval_by_value()
if value_trigger:
self.check_product_bom()
self.check_credit_limit()
self.check_limit_so_to_invoice()
order.approval_status = 'pengajuan0'
order.message_post(body="Mengajukan approval ke Leader Team Sales_")
return self._create_approval_notification('Team Sales')
elif order._requires_approval_team_sales():
self.check_product_bom()
self.check_credit_limit()
self.check_limit_so_to_invoice()
order.approval_status = 'pengajuan0'
order.message_post(body="Mengajukan approval ke Leader Team Sales")
return self._create_approval_notification('Team Sales')
elif order._requires_approval_margin_manager():
self.check_product_bom()
self.check_credit_limit()
self.check_limit_so_to_invoice()
order.approval_status = 'pengajuan1'
order.message_post(body="Mengajukan approval ke Sales Manager")
return self._create_approval_notification('Sales Manager')
elif order._requires_approval_margin_leader():
order.approval_status = 'pengajuan2'
order.message_post(body="Mengajukan approval ke Pimpinan")
return self._create_approval_notification('Pimpinan')
# elif value_trigger:
# self.check_product_bom()
# self.check_credit_limit()
# self.check_limit_so_to_invoice()
# order.approval_status = 'pengajuan0'
# order.message_post(body="Mengajukan approval ke Team Sales_")
# return self._create_approval_notification('Team Sales')
if not order.with_context(ask_approval=True)._is_request_to_own_team_leader():
return self._create_notification_action(
'Peringatan',
'Hanya bisa konfirmasi SO tim Anda.'
)
user = self.env.user
is_sales_admin = user.id in (3401, 20, 3988, 17340)
if is_sales_admin:
order.approval_status = 'pengajuan1'
order.message_post(body="Mengajukan approval ke Sales")
return self._create_approval_notification('Sales')
raise UserError("Bisa langsung Confirm")
def send_notif_to_salesperson(self, cancel=False):
if not cancel:
grouping_so = self.search([
('partner_id.parent_id.id', '=', self.partner_id.parent_id.id),
('partner_id.site_id.id', '=', self.partner_id.site_id.id),
])
else:
grouping_so = self.search([
('partner_id.parent_id.id', '=', self.partner_id.parent_id.id),
('partner_id.site_id.id', '=', self.partner_id.site_id.id),
('id', '!=', self.id),
])
# Kelompokkan data berdasarkan salesperson
salesperson_data = {}
for rec in grouping_so:
if rec.user_id.id not in salesperson_data:
salesperson_data[rec.user_id.id] = {'name': rec.user_id.name, 'orders': [], 'total_amount': 0,
'sum_total_amount': 0, 'business_partner': '',
'site': ''} # Menetapkan nilai awal untuk 'site'
if rec.picking_ids:
if not any(picking.state in ['assigned', 'confirmed', 'waiting'] for picking in rec.picking_ids):
continue
if all(picking.state == 'done' for picking in rec.picking_ids):
continue
if all(picking.state == 'cancel' for picking in rec.picking_ids):
continue
if not rec.partner_id.main_parent_id.use_so_approval:
continue
order_total_amount = rec.amount_total # Mengakses langsung rec.amount_total
salesperson_data[rec.user_id.id]['orders'].append({
'order_name': rec.name,
'parent_name': rec.partner_id.name,
'site_name': rec.partner_id.site_id.name,
'total_amount': rec.amount_total,
})
salesperson_data[rec.user_id.id]['sum_total_amount'] += order_total_amount
salesperson_data[rec.user_id.id]['business_partner'] = grouping_so[0].partner_id.main_parent_id.name
salesperson_data[rec.user_id.id]['site'] = grouping_so[
0].partner_id.site_id.name # Menambahkan nilai hanya jika ada
# Kirim email untuk setiap salesperson
for salesperson_id, data in salesperson_data.items():
if data['orders']:
# Buat isi tabel untuk email
table_content = ''
for order_data in data['orders']:
table_content += f"""
| {order_data['order_name']} |
{order_data['parent_name']} |
{order_data['site_name']} |
{order_data['total_amount']} |
"""
# Dapatkan email salesperson
salesperson_email = self.env['res.users'].browse(salesperson_id).partner_id.email
# Kirim email hanya jika ada data yang dikumpulkan
template = self.env.ref('indoteknik_custom.mail_template_sale_order_notification_to_salesperson')
email_body = template.body_html.replace('${table_content}', table_content)
email_body = email_body.replace('${salesperson_name}', data['name'])
email_body = email_body.replace('${sum_total_amount}', str(data['sum_total_amount']))
email_body = email_body.replace('${site}', str(data['site']))
email_body = email_body.replace('${business_partner}', str(data['business_partner']))
# Kirim email
self.env['mail.mail'].create({
'subject': 'Notification: Sale Orders',
'body_html': email_body,
'email_to': salesperson_email,
}).send()
def check_credit_limit(self):
for rec in self:
outstanding_amount = rec.outstanding_amount
check_credit_limit = False
######
block_stage = 0
if rec.partner_id.parent_id:
if rec.partner_id.parent_id.active_limit and rec.partner_id.parent_id.enable_credit_limit:
check_credit_limit = True
else:
if rec.partner_id.active_limit and rec.partner_id.enable_credit_limit:
check_credit_limit = True
term_days = 0
for term_line in rec.payment_term_id.line_ids:
term_days += term_line.days
if term_days == 0:
check_credit_limit = False
if check_credit_limit:
if rec.partner_id.parent_id:
block_stage = rec.partner_id.parent_id.blocking_stage or 0
else:
block_stage = rec.partner_id.blocking_stage or 0
if (outstanding_amount + rec.amount_total) >= block_stage:
if block_stage != 0:
remaining_credit_limit = block_stage - outstanding_amount
raise UserError(
_("%s is in Blocking Stage, Remaining credit limit is %s, from %s and outstanding %s")
% (rec.partner_id.name, remaining_credit_limit, block_stage, outstanding_amount))
def check_limit_so_to_invoice(self):
for rec in self:
# Ambil jumlah outstanding_amount dan rec.amount_total sebagai current_total
outstanding_amount = rec.outstanding_amount
current_total = rec.amount_total + outstanding_amount
# Ambil blocking stage dari partner
block_stage = rec.partner_id.parent_id.blocking_stage if rec.partner_id.parent_id else rec.partner_id.blocking_stage or 0
is_cbd = rec.partner_id.parent_id.property_payment_term_id.id == 26 if rec.partner_id.parent_id else rec.partner_id.property_payment_term_id.id == 26 or False
partner_term = rec.partner_id.property_payment_term_id
partner_term_days_total = 0
if partner_term:
partner_term_days_total = sum((line.days or 0) for line in partner_term.line_ids)
is_partner_cbd = (partner_term_days_total == 0)
is_so_cbd = bool(rec.payment_term_id.id == 26)
so_to_invoice = 0
for sale in rec.partner_id.sale_order_ids:
if sale.invoice_status == 'to invoice':
so_to_invoice = so_to_invoice + sale.amount_total
remaining_credit_limit = block_stage - current_total - so_to_invoice if not is_cbd and not is_partner_cbd else 0
# Validasi limit
if remaining_credit_limit <= 0 and block_stage > 0 and not is_cbd and not is_so_cbd and not is_partner_cbd:
raise UserError(
_("The credit limit for %s will exceed the Blocking Stage if the Sale Order is confirmed. The remaining credit limit is %s, from %s and the outstanding amount is %s.")
% (rec.partner_id.name, block_stage - current_total, block_stage, outstanding_amount))
def validate_different_vendor(self):
if self.vendor_approval_id.filtered(lambda v: v.state == 'draft'):
draft_names = ", ".join(self.vendor_approval_id.filtered(lambda v: v.state == 'draft').mapped('number'))
raise UserError(f"SO ini sedang dalam review Vendor Approval: {draft_names}")
if self.vendor_approval_id and all(v.state != 'draft' for v in self.vendor_approval_id):
return False
different_vendor = self.order_line.filtered(
lambda l: l.vendor_id and l.vendor_md_id and l.vendor_id.id != l.vendor_md_id.id
)
if different_vendor:
vendor_approvals = []
for line in different_vendor:
vendor_approval = self.env['vendor.approval'].create({
'order_id': self.id,
'order_line_id': line.id,
'create_date_so': self.create_date,
'partner_id': self.partner_id.id,
'state': 'draft',
'product_id': line.product_id.id,
'product_uom_qty': line.product_uom_qty,
'vendor_id': line.vendor_id.id,
'vendor_md_id': line.vendor_md_id.id,
'purchase_price': line.purchase_price,
'purchase_price_md': line.purchase_price_md,
'sales_price': line.price_unit,
'margin_before': line.margin_md,
'margin_after': line.item_percent_margin,
'purchase_tax_id': line.purchase_tax_id.id,
'sales_tax_id': line.tax_id[0].id if line.tax_id else False,
'percent_margin_difference': (
(line.price_unit - line.purchase_price_md) / line.purchase_price_md
if line.purchase_price_md else False
),
})
vendor_approvals.append(vendor_approval.id)
self.vendor_approval_id = [(4, vid) for vid in vendor_approvals]
return True
else:
return False
def check_archived_product(self):
for order in self:
for line in order.order_line:
# Skip section & note
if line.display_type:
continue
if line.product_id and not line.product_id.active:
raise UserError(
"Terdapat Product yang sudah di Archive pada Product: {}".format(
line.product_id.display_name
)
)
def check_archived_uom(self):
for order in self:
for line in order.order_line:
if line.display_type:
continue
if line.product_uom.active == False:
raise UserError("Terdapat UoM yang sudah di Archive pada UoM {} di Product {}".format(line.product_uom.name, line.product_id.display_name))
def action_confirm(self):
for order in self:
order._validate_delivery_amt()
order._validate_uniform_taxes()
order.check_duplicate_product()
order.check_product_bom()
order.check_credit_limit()
order.check_limit_so_to_invoice()
if self.validate_different_vendor() and not self.vendor_approval:
return self._create_notification_action('Notification', 'Terdapat Vendor yang berbeda dengan MD Vendor')
order.check_data_real_delivery_address()
order.sale_order_check_approve()
order._validate_order()
order._validate_npwp()
order.order_line.validate_line()
order.check_archived_product()
order.check_archived_uom()
main_parent = order.partner_id.get_main_parent()
SYSTEM_UID = 25
FROM_WEBSITE = order.create_uid.id == SYSTEM_UID
if FROM_WEBSITE and main_parent.use_so_approval and order.web_approval not in ['cust_procurement',
'cust_director']:
raise UserError("This order not yet approved by customer procurement or director")
if not order.client_order_ref and order.create_date > datetime(2024, 6, 27):
raise UserError(
"Customer Reference kosong, di isi dengan NO PO jika PO tidak ada mohon ditulisΒ TanpaΒ PO")
if not order.commitment_date and order.create_date > datetime(2024, 9, 12):
raise UserError("Expected Delivery Date kosong, wajib diisi")
if not order.real_shipping_id:
UserError('Real Delivery Address harus di isi')
if not self.env.context.get('due_approve', []):
if order.validate_partner_invoice_due():
return self._create_notification_action('Notification',
'Terdapat invoice yang telah melewati batas waktu, mohon perbarui pada dokumen Due Extension')
if not order._is_request_to_own_team_leader():
return self._create_notification_action(
'Warning',
'Hanya bisa konfirmasi SO tim Anda.'
)
value_trigger = order._requires_approval_by_value()
if value_trigger:
order.approval_status = 'pengajuan0'
order.message_post(body="Mengajukan approval ke Leader Team Sales")
return self._create_approval_notification('Team Sales')
elif value_trigger or order._requires_approval_team_sales():
order.approval_status = 'pengajuan0'
order.message_post(body="Mengajukan approval ke Leader Team Sales")
return self._create_approval_notification('Team Sales')
elif order._requires_approval_margin_manager():
order.approval_status = 'pengajuan1'
return self._create_approval_notification('Sales Manager')
elif order._requires_approval_margin_leader():
order.approval_status = 'pengajuan2'
return self._create_approval_notification('Pimpinan')
# elif value_trigger:
# order.approval_status = 'pengajuan0'
# order.message_post(body="Mengajukan approval ke Team Sales (Total SO > 50jt)")
# return self._create_approval_notification('Team Sales')
order.approval_status = 'approved'
order._set_sppkp_npwp_contact()
order.calculate_line_no()
order.send_notif_to_salesperson()
# order._compute_etrts_date()
# order.order_line.get_reserved_from()
res = super(SaleOrder, self).action_confirm()
for order in self:
note = []
for line in order.order_line:
if line.display_type == 'line_note':
note.append(line.name)
if order.picking_ids:
# Sort picking_ids by creation date to get the most recent one
latest_picking = order.picking_ids.sorted(key=lambda p: p.create_date, reverse=True)[0]
latest_picking.notee = '\n'.join(note)
return res
def action_cancel(self):
# TODO stephan prevent cancel if have invoice, do, and po
if self.state_ask_cancel != 'approve' and self.state not in ['draft', 'sent']:
raise UserError("Anda harus approval purchasing terlebih dahulu")
main_parent = self.partner_id.get_main_parent()
if self._name != 'sale.order':
return super(SaleOrder, self).action_cancel()
if self.have_outstanding_invoice:
raise UserError("Invoice harus di Cancel dahulu")
disallow_states = ['draft', 'waiting', 'confirmed', 'assigned']
for picking in self.picking_ids:
if picking.state in disallow_states:
raise UserError("DO yang draft, waiting, confirmed, atau assigned harus di-cancel oleh Logistik")
for line in self.order_line:
if line.qty_delivered > 0:
raise UserError("DO yang done harus di-Return oleh Logistik")
if not self.web_approval:
self.web_approval = 'company'
# elif self.have_outstanding_po:
# raise UserError("PO harus di Cancel dahulu")
self.approval_status = False
self.due_id = False
if main_parent.use_so_approval:
self.send_notif_to_salesperson(cancel=True)
for order in self:
if order.amount_total > 30000000:
return {
'type': 'ir.actions.act_window',
'name': _('Cancel Reason'),
'res_model': 'cancel.reason.order',
'view_mode': 'form',
'target': 'new',
'context': {'default_request_id': self.id},
}
return super(SaleOrder, self).action_cancel()
def confirm_cancel_order(self):
"""Fungsi ini akan dipanggil oleh wizard setelah alasan pembatalan dipilih"""
if self.state != 'cancel':
self.state = 'cancel'
return super(SaleOrder, self).action_cancel()
def validate_partner_invoice_due(self):
parent_id = self.partner_id.parent_id.id
parent_id = parent_id if parent_id else self.partner_id.id
if self.due_id and self.due_id.is_approve == False:
raise UserError('Document Over Due Yang Anda Buat Belum Di Approve')
query = [
('partner_id', '=', parent_id),
('state', '=', 'posted'),
('move_type', '=', 'out_invoice'),
('amount_residual_signed', '>', 0)
]
invoices = self.env['account.move'].search(query, order='invoice_date')
if invoices:
due_extension = self.env['due.extension'].create([{
'partner_id': parent_id,
'day_extension': '3',
'order_id': self.id,
}])
due_extension.generate_due_line()
self.due_id = due_extension.id
if len(self.due_id.due_line) > 0:
return True
else:
due_extension.unlink()
return False
def _requires_approval_margin_leader(self):
return self.total_percent_margin <= 15 and not self.env.user.is_leader
def _requires_approval_margin_manager(self):
return 15 < self.total_percent_margin < 18 and not self.env.user.is_sales_manager and not self.env.user.id == 375 and not self.env.user.is_leader
def _requires_approval_team_sales(self):
return (
18 <= self.total_percent_margin <= 24
# self.total_percent_margin >= 18
and self.env.user.id not in [11, 9, 375] # Eko, Ade, Putra
and not self.env.user.is_sales_manager
and not self.env.user.is_leader
)
def _requires_approval_by_value(self):
# LIMIT_VALUE = 50000000
LIMIT_VALUE = float(self.env['ir.config_parameter'].sudo().get_param('so.limit_value_approve', default='50000000'))
return (
self.amount_total >= LIMIT_VALUE
and self.env.user.id not in [11, 9, 375] # Eko, Ade, Putra
and not self.env.user.is_sales_manager
and not self.env.user.is_leader
)
def _is_request_to_own_team_leader(self):
user = self.env.user
# Pengecualian Pak Akbar & Darren
if user.is_leader or user.is_sales_manager:
return True
if self.env.context.get("ask_approval") and user.id in (3401, 20, 3988, 17340):
return True
if not self.env.context.get("ask_approval") and user.id in (3401, 20, 3988, 17340): # admin (fida, nabila, ninda, feby)
raise UserError("Sales Admin tidak bisa confirm SO, silahkan hubungi Salesperson yang bersangkutan.")
salesperson_id = self.user_id.id
approver_id = user.id
team_leader_id = self.team_id.user_id.id
team = self.env['crm.team'].search([('user_id', '=', approver_id)], limit=1)
return salesperson_id == approver_id or bool(team)
def _create_approval_notification(self, approval_role):
title = 'Warning'
message = f'SO butuh approval {approval_role}'
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 _set_sppkp_npwp_contact(self):
partner = self.partner_id.parent_id or self.partner_id
# if not partner.sppkp:
# partner.sppkp = self.sppkp
# if not partner.npwp:
# partner.npwp = self.npwp
if not partner.email:
partner.email = self.email
# if not partner.customer_type:
# partner.customer_type = self.customer_type
if not partner.user_id:
partner.user_id = self.user_id.id
def _compute_total_margin(self):
for order in self:
total_margin = sum(line.item_margin for line in order.order_line if line.product_id)
if order.ongkir_ke_xpdc:
total_margin -= order.ongkir_ke_xpdc
order.total_margin = total_margin
def _compute_total_before_margin(self):
for order in self:
total_before_margin = sum(line.item_before_margin for line in order.order_line if line.product_id)
order.total_before_margin = total_before_margin
# Perhitungan Lama
# def _compute_total_percent_margin(self):
# for order in self:
# if order.amount_untaxed == 0:
# order.total_percent_margin = 0
# continue
# if order.shipping_cost_covered == 'indoteknik':
# delivery_amt = order.delivery_amt
# else:
# delivery_amt = 0
#
# net_margin = order.total_margin - order.biaya_lain_lain
#
# order.total_percent_margin = round(
# (net_margin / (order.amount_untaxed - order.fee_third_party)) * 100, 2)
# order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2)
# order.total_percent_margin = round(
# (order.total_margin / (order.amount_untaxed - order.fee_third_party - order.biaya_lain_lain)) * 100, 2)
# order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2)
def _compute_total_percent_margin(self):
for order in self:
if order.amount_untaxed == 0:
order.total_percent_margin = 0
continue
if order.shipping_cost_covered == 'indoteknik':
delivery_amt = order.delivery_amt
else:
delivery_amt = 0
net_margin = order.total_margin - order.fee_third_party - order.biaya_lain_lain
if order.amount_untaxed > 0:
order.total_percent_margin = round((net_margin / order.amount_untaxed) * 100, 2)
else:
order.total_percent_margin = 0
# @api.onchange('biaya_lain_lain')
# def _onchange_biaya_lain_lain(self):
# """Ketika biaya_lain_lain berubah, simpan nilai margin sebelumnya"""
# if hasattr(self, '_origin') and self._origin.id:
# # Hitung margin sebelum biaya_lain_lain ditambahkan
# if self.amount_untaxed > 0:
# original_net_margin = self.total_margin # tanpa biaya_lain_lain
# self.total_margin_excl_third_party = round(
# (original_net_margin / (self.amount_untaxed - self.fee_third_party)) * 100, 2)
def _prepare_before_margin_values(self, vals):
margin_sebelumnya = {}
margin_affecting_fields = [
'biaya_lain_lain', 'fee_third_party', 'delivery_amt',
'ongkir_ke_xpdc', 'shipping_cost_covered', 'order_line'
]
if not any(field in vals for field in margin_affecting_fields):
return {}
for order in self:
if order.amount_untaxed <= 0:
continue
current_before = order.total_margin_excl_third_party or 0
# CASE 1: Before margin masih kosong β ambil dari item_percent_margin
if current_before == 0:
line_margin = 0
for line in order.order_line:
if line.item_percent_margin is not None:
line_margin = line.item_percent_margin
break
margin_sebelumnya[order.id] = line_margin
_logger.info(f"[BEFORE] SO {order.name}: Before margin kosong, ambil dari order line: {line_margin}%")
else:
# CASE 2: Ada perubahan field yang mempengaruhi margin
for field in margin_affecting_fields:
if field in vals:
old_val = getattr(order, field, 0) or 0
new_val = vals[field] or 0
if old_val != new_val:
margin_sebelumnya[order.id] = order.total_percent_margin
_logger.info(
f"[BEFORE] SO {order.name}: {field} berubah dari {old_val} ke {new_val}, simpan {order.total_percent_margin}%")
break
return margin_sebelumnya
@api.onchange('sales_tax_id')
def onchange_sales_tax_id(self):
for line in self.order_line:
line.product_id_change()
def _compute_grand_total(self):
for order in self:
if order.shipping_cost_covered == 'customer':
order.grand_total = order.delivery_amt + order.amount_total
else:
order.grand_total = order.amount_total
def action_apply_voucher(self):
for line in self.order_line:
if line.order_promotion_id:
_logger.warning(f"[CHECKOUT FAILED] Produk promo ditemukan: {line.product_id.display_name}")
raise UserError('Voucher tidak dapat digabung dengan promotion program')
voucher = self.voucher_id
if voucher.limit > 0 and voucher.count_order >= voucher.limit:
raise UserError('Voucher tidak dapat digunakan karena sudah habis digunakan')
partner_voucher_orders = []
for order in voucher.order_ids:
if order.partner_id.id == self.partner_id.id:
partner_voucher_orders.append(order)
if voucher.limit_user > 0 and len(partner_voucher_orders) >= voucher.limit_user:
raise UserError('Voucher tidak dapat digunakan karena Customer ini sudah menghabiskan kuota voucher')
if self.pricelist_id.id in [x.id for x in voucher.excl_pricelist_ids]:
raise UserError('Voucher tidak dapat digunakan karena pricelist ini tidak berlaku pada voucher')
self.apply_voucher()
def action_apply_voucher_shipping(self):
for line in self.order_line:
if line.order_promotion_id:
raise UserError('Voucher tidak dapat digabung dengan promotion program')
voucher = self.voucher_shipping_id
if voucher.limit > 0 and voucher.count_order >= voucher.limit:
raise UserError('Voucher tidak dapat digunakan karena sudah habis digunakan')
partner_voucher_orders = []
for order in voucher.order_ids:
if order.partner_id.id == self.partner_id.id:
partner_voucher_orders.append(order)
if voucher.limit_user > 0 and len(partner_voucher_orders) >= voucher.limit_user:
raise UserError('Voucher tidak dapat digunakan karena Customer ini sudah menghabiskan kuota voucher')
if self.pricelist_id.id in [x.id for x in voucher.excl_pricelist_ids]:
raise UserError('Voucher tidak dapat digunakan karena pricelist ini tidak berlaku pada voucher')
self.apply_voucher_shipping()
def apply_voucher(self):
def _is_promo_line(line):
# TRUE jika baris tidak boleh kena voucher
if getattr(line, 'order_promotion_id', False):
return True # baris dari program promo
if (line.price_unit or 0.0) == 0.0:
return True # free item
if getattr(line, 'is_has_disc', False):
return True # sudah promo/flashsale/berdiskon
if (line.discount or 0.0) >= 100.0:
return True # safety
return False
# --- LOOP 1: susun input untuk voucher.apply() ---
order_line = []
for line in self.order_line:
if _is_promo_line(line):
continue
order_line.append({
'product_id': line.product_id,
'price': line.price_unit,
'discount': line.discount,
'qty': line.product_uom_qty,
'subtotal': line.price_subtotal,
})
if not order_line:
return
voucher = self.voucher_id.apply(order_line)
# --- LOOP 2: tulis hasilnya HANYA ke non-promo ---
for line in self.order_line:
if _is_promo_line(line):
continue
line.initial_discount = line.discount
voucher_type = voucher['type']
total_map = voucher['total'][voucher_type]
discount_map = voucher['discount'][voucher_type]
if voucher_type == 'brand':
m_id = line.product_id.x_manufacture.id
used_total = (total_map or {}).get(m_id)
used_discount = (discount_map or {}).get(m_id)
else:
used_total = total_map
used_discount = discount_map
if not used_total or not used_discount or (line.product_uom_qty or 0.0) == 0.0:
continue
line_contribution = line.price_subtotal / used_total
line_voucher = used_discount * line_contribution
per_item_voucher = line_voucher / line.product_uom_qty
has_ppn_11 = any(tax.id == 23 for tax in line.tax_id)
base_unit = line.price_unit / 1.11 if has_ppn_11 else line.price_unit
new_disc_value = base_unit * line.discount / 100 + per_item_voucher
new_disc_pct = (new_disc_value / base_unit) * 100
line.amount_voucher_disc = line_voucher
line.discount = new_disc_pct
_logger.info(
"[VOUCHER_APPLIED] SO=%s voucher=%s type=%s line_id=%s product=%s qty=%s discount_pct=%.2f amount_voucher=%s",
self.name,
getattr(self.voucher_id, "code", None),
voucher.get("type"),
line.id,
line.product_id.display_name,
line.product_uom_qty,
line.discount,
line.amount_voucher_disc,
)
self.amount_voucher_disc = voucher['discount']['all']
self.applied_voucher_id = self.voucher_id
def apply_voucher_shipping(self):
for order in self:
delivery_amt = order.delivery_amt
voucher = order.voucher_shipping_id
if voucher:
max_discount_amount = voucher.discount_amount
voucher_type = voucher.discount_type
if voucher_type == 'fixed_price':
discount = max_discount_amount
elif voucher_type == 'percentage':
discount = delivery_amt * (max_discount_amount / 100)
delivery_amt -= discount
delivery_amt = max(delivery_amt, 0)
order.delivery_amt = delivery_amt
order.amount_voucher_shipping_disc = discount
order.applied_voucher_shipping_id = order.voucher_id.id
def cancel_voucher(self):
self.applied_voucher_id = False
self.amount_voucher_disc = 0
for line in self.order_line:
line.amount_voucher_disc = 0
line.discount = line.initial_discount
line.initial_discount = False
def cancel_voucher_shipping(self):
self.delivery_amt + self.amount_voucher_shipping_disc
self.applied_voucher_shipping_id = False
self.amount_voucher_shipping_disc = 0
def action_web_approve(self):
if self.env.uid != self.partner_id.user_id.id:
raise UserError(
'You are not authorized to approve this order. Only %s can approve this order.' % self.partner_id.user_id.name)
self.web_approval = 'company'
template = self.env.ref('indoteknik_custom.mail_template_sale_order_web_approve_notification')
template.send_mail(self.id, force_send=True)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Notification',
'message': 'Berhasil approve web order',
'next': {'type': 'ir.actions.act_window_close'},
}
}
def calculate_selling_price(self):
# ongkos kirim, biaya pihak ketiga calculate @stephan
# TODO voucher @stephan
# vendor hilangin child di field SO Line @stephan
# button pindahin @stephan
# last so 1 tahun ke belakang @stephan
# pastikan harga beli 1 tahun ke belakang jg
# harga yg didapat dari semua kumpulan parent parner dan child nya
# counter di klik berapa banyak @stephan
for order_line in self.order_line:
if not order_line.product_id:
continue
current_time = datetime.now()
delta_time = current_time - timedelta(days=365)
delta_time = delta_time.strftime('%Y-%m-%d %H:%M:%S')
# Initialize partners list with parent_id or partner_id
partners = []
parent_id = self.partner_id.parent_id or self.partner_id
# Add all child_ids and the parent itself to partners as IDs
partners.extend(parent_id.child_ids.ids)
partners.append(parent_id.id)
rec_purchase_price, rec_taxes_id, rec_vendor_id = order_line._get_purchase_price(order_line.product_id)
state = ['sale', 'done']
last_so = self.env['sale.order.line'].search([
# ('order_id.partner_id.id', '=', order_line.order_id.partner_id.id),
('order_id.partner_id', 'in', partners),
('product_id.id', '=', order_line.product_id.id),
('order_id.state', 'in', state),
('id', '!=', order_line.id),
('order_id.date_order', '>=', delta_time)
], limit=1, order='create_date desc')
if last_so and rec_vendor_id != last_so.vendor_id.id:
last_so = self.env['sale.order.line'].search([
# ('order_id.partner_id.id', '=', order_line.order_id.partner_id.id),
('order_id.partner_id', 'in', partners),
('product_id.id', '=', order_line.product_id.id),
('order_id.state', 'in', state),
('vendor_id', '=', rec_vendor_id),
('id', '!=', order_line.id),
('order_id.date_order', '>=', delta_time)
], limit=1, order='create_date desc')
if last_so and rec_purchase_price != last_so.purchase_price:
rec_taxes = self.env['account.tax'].search([('id', '=', rec_taxes_id)], limit=1)
if rec_taxes.price_include:
selling_price = (rec_purchase_price / 1.11) / (
1 - (last_so.item_percent_margin_without_deduction / 100))
else:
selling_price = rec_purchase_price / (1 - (last_so.item_percent_margin_without_deduction / 100))
tax_id = last_so.tax_id
for tax in tax_id:
if tax.price_include:
selling_price = selling_price + (selling_price * 11 / 100)
else:
selling_price = selling_price
discount = 0
elif last_so:
selling_price = last_so.price_unit
tax_id = last_so.tax_id
discount = last_so.discount
else:
selling_price = order_line.price_unit
tax_id = order_line.tax_id
discount = order_line.discount
elif last_so and rec_vendor_id == order_line.vendor_id.id and rec_purchase_price != last_so.purchase_price:
rec_taxes = self.env['account.tax'].search([('id', '=', rec_taxes_id)], limit=1)
if rec_taxes.price_include:
selling_price = (rec_purchase_price / 1.11) / (
1 - (last_so.item_percent_margin_without_deduction / 100))
else:
selling_price = rec_purchase_price / (1 - (last_so.item_percent_margin_without_deduction / 100))
tax_id = last_so.tax_id
for tax in tax_id:
if tax.price_include:
selling_price = selling_price + (selling_price * 11 / 100)
else:
selling_price = selling_price
discount = 0
elif last_so:
selling_price = last_so.price_unit
tax_id = last_so.tax_id
discount = last_so.discount
else:
selling_price = order_line.price_unit
tax_id = order_line.tax_id
discount = order_line.discount
order_line.price_unit = selling_price
order_line.tax_id = tax_id
order_line.discount = discount
order_line.order_id.use_button = True
def _auto_set_shipping_from_website(self):
if not self.env.context.get('from_website_checkout'):
return
for order in self:
# Validasi source website
if not order.source_id or order.source_id.id != 59:
continue
# Skip jika Self Pick Up
if int(order.carrier_id.id or 0) == 32:
_logger.info(f"[Checkout] Skip estimasi: Self Pickup untuk SO {order.name}")
order.select_shipping_option = 'custom'
continue
# Simpan pilihan user sebelum estimasi
user_carrier_id = order.carrier_id.id if order.carrier_id else None
user_service = order.delivery_service_type
user_amount = order.delivery_amt
# Jalankan estimasi untuk refresh data
order.select_shipping_option = 'biteship'
order.action_estimate_shipping()
temp_price = self.env.context.get('_temp_delivery_amt')
temp_service = self.env.context.get('_temp_delivery_service')
temp_option_id = self.env.context.get('_temp_shipping_option')
if temp_price and temp_option_id:
order.shipping_option_id = temp_option_id
order.delivery_amt = temp_price
order.delivery_service_type = temp_service
# Restore pilihan user setelah estimasi
if user_carrier_id and user_service:
# Dapatkan provider
self.env.cr.execute("SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1", (user_carrier_id,))
result = self.env.cr.fetchone()
provider = result[0].lower() if result else order.env['delivery.carrier'].browse(user_carrier_id).name.lower().split()[0]
# Cari opsi yang cocok (prioritas: service code > nama > harga > fallback)
domain_options = [
[('courier_service_code', '=', user_service), ('provider', 'ilike', provider)], # exact service
[('name', 'ilike', user_service), ('provider', 'ilike', provider)], # nama service
[('price', '=', user_amount), ('provider', 'ilike', provider)] if user_amount > 0 else None, # harga sama
[('provider', 'ilike', provider)] # fallback
]
matched_option = None
for domain in domain_options:
if domain:
matched_option = self.env['shipping.option'].search([('sale_order_id', '=', order.id)] + domain, limit=1)
if matched_option:
break
# Set opsi yang cocok atau buat manual
if matched_option:
order.shipping_option_id = matched_option.id
order.delivery_amt = matched_option.price
order.delivery_service_type = matched_option.courier_service_code
# Notif jika harga berubah
if user_amount > 0 and abs(matched_option.price - user_amount) > 1000:
order.message_post(body=f"Harga shipping berubah dari Rp {user_amount:,} ke Rp {matched_option.price:,}")
elif user_amount > 0:
# Buat opsi manual jika tidak ada yang cocok
manual_option = self.env['shipping.option'].create({
'name': f"{provider.upper()} - {user_service}",
'price': user_amount,
'provider': provider,
'courier_service_code': user_service,
'sale_order_id': order.id,
})
order.shipping_option_id = manual_option.id
@api.model
def create(self, vals):
# Ensure partner details are updated when a sale order is created
order = super(SaleOrder, self).create(vals)
# _logger.info(f"[CREATE CONTEXT] {self.env.context}")
# order._auto_set_shipping_from_website()
order._compute_etrts_date()
order._validate_expected_ready_ship_date()
# for line in order.order_line:
# updated_vals = line._update_purchase_info()
# if updated_vals:
# line.write(updated_vals)
# order._validate_delivery_amt()
# order._check_total_margin_excl_third_party()
# order._update_partner_details()
return order
# @api.depends('commitment_date')
def _compute_ready_to_ship_status_detail(self):
def is_empty(val):
"""Helper untuk cek data kosong yang umum di Odoo."""
return val is None or val == "" or val == [] or val == {}
for order in self:
order.ready_to_ship_status_detail = 'On Track' # Default value
# Skip if no commitment date
if is_empty(order.commitment_date):
continue
eta = order.commitment_date
match_lines = self.env['purchase.order.sales.match'].search([
('sale_id', '=', order.id)
])
if match_lines:
for match in match_lines:
po = match.purchase_order_id
product = match.product_id
po_line = self.env['purchase.order.line'].search([
('order_id', '=', po.id),
('product_id', '=', product.id)
], limit=1)
if is_empty(po_line):
continue
stock_move = self.env['stock.move'].search([
('purchase_line_id', '=', po_line.id)
], limit=1)
if is_empty(stock_move) or is_empty(stock_move.picking_id):
continue
picking_in = stock_move.picking_id
result_date = picking_in.date_done
if is_empty(result_date):
continue
try:
if result_date < eta:
order.ready_to_ship_status_detail = f"Early (Actual: {result_date.strftime('%m/%d/%Y')})"
else:
order.ready_to_ship_status_detail = f"Delay (Actual: {result_date.strftime('%m/%d/%Y')})"
except Exception as e:
_logger.error(f"Error computing ready to ship status: {str(e)}")
continue
def write(self, vals):
margin_sebelumnya = self._prepare_before_margin_values(vals)
for order in self:
if order.state in ['sale', 'cancel']:
if 'order_line' in vals:
for command in vals.get('order_line', []):
if command[0] == 0:
raise UserError(
"SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.")
order._update_delivery_service_type_from_shipping_option(vals)
if 'carrier_id' in vals:
for order in self:
for picking in order.picking_ids:
if picking.state == 'assigned':
picking.carrier_id = vals['carrier_id']
for picking in order.picking_ids:
if picking.state not in ['done', 'cancel', 'assigned']:
picking.write({'carrier_id': vals['carrier_id']})
try:
helper_ids = self._get_helper_ids()
if str(self.env.user.id) in helper_ids:
vals['helper_by_id'] = self.env.user.id
except:
pass
#payment term vals
if 'payment_term_id' in vals and any(
order.approval_status in ['pengajuan0','pengajuan1', 'pengajuan2', 'approved'] for order in self):
raise UserError(
"Payment Term tidak dapat diubah karena Sales Order sedang dalam proses approval atau sudah diapprove.")
if 'payment_term_id' in vals:
for order in self:
partner = order.partner_id.parent_id or order.partner_id
customer_payment_term = partner.property_payment_term_id
if vals['payment_term_id'] != customer_payment_term.id and not order.partner_id.id == 29179:
raise UserError(
f"Payment Term berbeda pada Master Data Customer. "
f"Harap ganti ke '{customer_payment_term.name}' "
f"sesuai dengan payment term yang terdaftar pada customer."
)
if order.partner_id.id == 29179 and vals['payment_term_id'] not in [25,28]:
raise UserError(_("Pilih payment term 60 hari atau 30 hari."))
res = super(SaleOrder, self).write(vals)
# Update before margin setelah write
if margin_sebelumnya:
for order_id, margin_value in margin_sebelumnya.items():
_logger.info(f"[UPDATE] SO ID {order_id}: Set before margin ke {margin_value}%")
self.env.cr.execute("""
UPDATE sale_order
SET total_margin_excl_third_party = %s
WHERE id = %s
""", (margin_value, order_id))
self.env.cr.commit()
self.invalidate_cache(['total_margin_excl_third_party'])
# Validasi setelah write
if any(field in vals for field in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']):
self._validate_delivery_amt()
if any(field in vals for field in ["order_line", "client_order_ref"]):
self._calculate_etrts_date()
# for order in self:
# for line in order.order_line:
# updated_vals = line._update_purchase_info()
# if updated_vals:
# line.write(updated_vals)
if 'real_shipping_id' in vals:
self.action_set_shipping_id()
return res
def button_refund(self):
self.ensure_one()
invoice_ids = self.invoice_ids.filtered(lambda inv: inv.state == 'posted')
moves = self.env['account.move'].search([
('sale_id', '=', self.id),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
piutangbca = self.env['account.move']
piutangmdr = self.env['account.move']
cabinvoice = self.env['account.move']
for inv_name in invoice_ids.mapped('name'):
piutangbca |= self.env['account.move'].search([
('ref', 'ilike', inv_name),
('journal_id', '=', 4),
('state', '=', 'posted'),
])
piutangmdr |= self.env['account.move'].search([
('ref', 'ilike', inv_name),
('journal_id', '=', 7),
('state', '=', 'posted'),
])
cabinvoice |= self.env['account.move'].search([
('ref', 'ilike', inv_name),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
moves2 = self.env['account.move'].search([
('ref', 'ilike', self.name),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
# Default 0
total_uang_muka = 0.0
has_moves = bool(moves)
has_moves2 = bool(moves2)
has_piutangmdr = bool(piutangmdr)
has_piutangbca = bool(piutangbca)
has_cabinvoice = bool(cabinvoice)
has_settlement = self.payment_status == 'settlement'
if has_moves and has_settlement:
total_uang_muka = sum(moves.mapped('amount_total_signed')) + self.gross_amount
elif has_moves:
total_uang_muka = sum(moves.mapped('amount_total_signed'))
elif has_moves2:
total_uang_muka = sum(moves2.mapped('amount_total_signed'))
elif has_settlement:
total_uang_muka = self.gross_amount
elif has_cabinvoice:
total_uang_muka = sum(cabinvoice.mapped('amount_total_signed'))
elif has_piutangbca:
total_uang_muka = sum(piutangbca.mapped('amount_total_signed'))
elif has_piutangmdr:
total_uang_muka = sum(piutangmdr.mapped('amount_total_signed'))
else:
raise UserError(
"Tidak bisa melakukan refund karena SO tidak memiliki Record Uang Masuk "
"(Journal Uang Muka/Payment Invoices/Midtrans Payment)."
)
total_refunded = sum(self.refund_ids.mapped('amount_refund'))
sisa_uang_muka = total_uang_muka - total_refunded
if sisa_uang_muka <= 0:
raise UserError("β Tidak ada sisa transaksi untuk di-refund. Semua dana sudah dikembalikan.")
return {
'name': 'Refund Sale Order',
'type': 'ir.actions.act_window',
'res_model': 'refund.sale.order',
'view_mode': 'form',
'target':'new',
'target': 'current',
'context': {
'default_sale_order_ids': [(6, 0, [self.id])],
'default_invoice_ids': [(6, 0, invoice_ids.ids)],
'default_uang_masuk': sisa_uang_muka,
'default_ongkir': self.delivery_amt or 0.0,
'default_bank': '',
'default_account_name': '',
'default_account_no': '',
'default_refund_type': '',
},
}
def open_form_multi_create_refund(self):
if not self:
raise UserError("Tidak ada Sale Order yang dipilih.")
if len(self) > 1:
not_cancel_orders = self.filtered(lambda so: so.state != 'cancel')
if not_cancel_orders:
raise ValidationError(
f"β Refund Multi SO hanya bisa dibuat untuk SO dengan status Cancel. "
f"SO berikut tidak Cancel: {', '.join(not_cancel_orders.mapped('name'))}"
)
invalid_status_orders = []
for order in self:
if order.state not in ['cancel', 'sale']:
invalid_status_orders.append(order.name)
elif order.state == 'sale':
not_done_pickings = order.picking_ids.filtered(lambda p: p.state != 'done')
if not_done_pickings:
invalid_status_orders.append(order.name)
if invalid_status_orders:
raise ValidationError(
f"β Refund tidak bisa dibuat untuk SO {', '.join(invalid_status_orders)}. "
f"SO harus Cancel atau Sale dengan semua Pengiriman sudah selesai."
)
partner_set = set(self.mapped('partner_id.id'))
if len(partner_set) > 1:
raise UserError("Tidak dapat membuat refund untuk Multi SO dengan Customer berbeda. Harus memiliki Customer yang sama.")
invoice_status_set = set(self.mapped('invoice_status'))
if len(invoice_status_set) > 1:
raise UserError("Tidak dapat membuat refund untuk SO dengan status invoice berbeda. Harus memiliki status invoice yang sama.")
refunded_orders = self.filtered(lambda so: self.env['refund.sale.order'].search([('sale_order_ids', 'in', so.id)], limit=1))
if refunded_orders:
raise ValidationError(
f"SO {', '.join(refunded_orders.mapped('name'))} sudah pernah di-refund dan tidak bisa ikut dalam refund Multi SO."
)
total_uang_masuk = 0.0
invalid_orders=[]
for order in self:
moves = self.env['account.move'].search([
('sale_id', '=', order.id),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
moves2 = self.env['account.move'].search([
('ref', 'ilike', order.name),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
total_uang_muka = 0.0
if moves and order.payment_status == 'settlement':
total_uang_muka = order.gross_amount + sum(moves.mapped('amount_total_signed')) or 0.0
elif moves:
total_uang_muka = sum(moves.mapped('amount_total_signed')) or 0.0
elif moves2:
total_uang_muka = sum(moves2.mapped('amount_total_signed')) or 0.0
elif order.payment_status == 'settlement':
total_uang_muka = order.gross_amount
else:
invalid_orders.append(order.name)
total_uang_masuk += total_uang_muka
if invalid_orders:
raise ValidationError(
f"Tidak dapat membuat refund untuk SO {', '.join(invalid_orders)} karena tidak memiliki Record Uang Masuk (Journal Uang Muka/Midtrans).\n"
"Pastikan semua SO yang dipilih sudah memiliki Record pembayaran yang valid."
)
invoice_ids = self.mapped('invoice_ids').filtered(lambda inv: inv.state != 'cancel')
delivery_total = sum(self.mapped('delivery_amt'))
return {
'type': 'ir.actions.act_window',
'name': 'Create Refund',
'res_model': 'refund.sale.order',
'view_mode': 'form',
'target': 'current',
'context': {
'default_sale_order_ids': [(6, 0, self.ids)],
'default_invoice_ids': [(6, 0, invoice_ids.ids)],
'default_uang_masuk': total_uang_masuk,
'default_ongkir': delivery_total,
'default_bank': '',
'default_account_name': '',
'default_account_no': '',
'default_refund_type': '',
}
}
def action_view_related_refunds(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Refunds',
'res_model': 'refund.sale.order',
'view_mode': 'tree,form',
'domain': [('sale_order_ids', 'in', [self.id])],
'context': {'default_sale_order_ids': [self.id]},
}
def action_view_related_sjo(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Sourcing Job',
'res_model': 'sourcing.job.order',
'view_mode': 'tree,form',
'domain': [('so_id', '=', self.id)],
'context': {'default_so_id': self.id},
}
def _compute_refund_ids(self):
for order in self:
refunds = self.env['refund.sale.order'].search([
('sale_order_ids', 'in', [order.id])
])
order.refund_ids = refunds
def _compute_refund_count(self):
for order in self:
order.refund_count = self.env['refund.sale.order'].search_count([
('sale_order_ids', 'in', order.id)
])
def _compute_sourcing_count(self):
for order in self:
order.sourcing_job_count = self.env['sourcing.job.order'].search_count([
('so_id', '=', order.id)
])
@api.depends('invoice_ids')
def _compute_advance_payment_move(self):
for order in self:
move = self.env['account.move'].search([
('sale_id', '=', order.id),
('journal_id', '=', 11),
('state', '=', 'posted'),
], limit=1, order="id desc")
order.advance_payment_move_id = move
@api.depends('invoice_ids')
def _compute_advance_payment_moves(self):
for order in self:
moves = self.env['account.move'].search([
('sale_id', '=', order.id),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
order.advance_payment_move_ids = moves
@api.depends('invoice_ids')
def _compute_advance_payment_moves(self):
for order in self:
moves = self.env['account.move'].search([
('sale_id', '=', order.id),
('journal_id', '=', 11),
('state', '=', 'posted'),
])
order.advance_payment_move_ids = moves
order.advance_payment_move_count = len(moves)
def action_open_advance_payment_moves(self):
self.ensure_one()
moves = self.advance_payment_move_ids
if not moves:
return
return {
'type': 'ir.actions.act_window',
'name': 'Journals Sales Order',
'res_model': 'account.move',
'view_mode': 'tree,form',
'domain': [('id', 'in', moves.ids)],
'target': 'current',
}
def action_open_sjo(self):
return {
'name': 'SJO',
'type': 'ir.actions.act_window',
'res_model': 'sourcing.job.order',
'view_mode': 'form',
'target': 'current',
'context': {
'default_so_id': self.id,
}
}
class SaleForecastCoverage(models.Model):
_name = 'sale.forecast.coverage'
_description = 'Sale Forecast Coverage'
sale_id = fields.Many2one('sale.order', index=True)
sale_line_id = fields.Many2one('sale.order.line', index=True)
product_id = fields.Many2one('product.product')
so_qty = fields.Float()
reserved_qty = fields.Float()
forecast_qty = fields.Float()
receipt_date = fields.Datetime()
document_in_model = fields.Char()
document_in_id = fields.Integer()
document_in_name = fields.Char()
reservation = fields.Boolean()
is_late = fields.Boolean()
replenishment_filled = fields.Boolean()