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) 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""" {''.join(rows)}
    Product Qty Supplied By Receipt Date SO Qty Reserved
    """ 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 _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) ]) @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', } 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()