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
from io import BytesIO
from collections import defaultdict
import pytz
_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")
sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete="cascade")
class SaleOrder(models.Model):
_inherit = "sale.order"
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)
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_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([
('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)
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')
], required=True)
sppkp = fields.Char(string="SPPKP", required=True, tracking=True)
npwp = fields.Char(string="NPWP", required=True, tracking=True)
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='Select Shipping Option', help="Select shipping option for delivery", Tracking=True)
@api.onchange('shipping_option_id')
def _onchange_shipping_option_id(self):
if self.shipping_option_id:
self.delivery_amt = self.shipping_option_id.price
def _get_biteship_courier_codes(self):
return [
'gojek','grab','deliveree','lalamove','jne','tiki','ninja','lion','rara','sicepat','jnt','pos','idexpress','rpx','wahana','jdl','pos','anteraja','sap','paxel','borzo'
]
@api.onchange('select_shipping_option')
def _onchange_select_shipping_option(self):
self.shipping_option_id = False
self.carrier_id = False
self.delivery_amt = 0
# Dapatkan semua ID carrier untuk Biteship
biteship_carrier_ids = []
# Gunakan SQL langsung untuk menghindari masalah ORM
self.env.cr.execute("""
SELECT delivery_carrier_id
FROM rajaongkir_kurir
WHERE name IN %s
""", (tuple(self._get_biteship_courier_codes()),))
# Ambil ID numerik hasil query
biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]]
if self.select_shipping_option == 'biteship':
domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else []
else: # 'custom'
domain = [('id', 'not in', biteship_carrier_ids)] if biteship_carrier_ids else []
return {'domain': {'carrier_id': domain}}
@api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain')
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()
@api.constrains('shipping_option_id')
def _check_shipping_option(self):
for rec in self:
if rec.shipping_option_id:
rec.delivery_amt = rec.shipping_option_id.price
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.")
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 result:
shipping_options = []
for courier in result['rajaongkir']['results']:
for cost_detail in courier['costs']:
service = cost_detail['service']
description = cost_detail['description']
etd = cost_detail['cost'][0]['etd']
value = cost_detail['cost'][0]['value']
shipping_options.append((service, description, etd, value, courier['code']))
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]} hari, Cost: Rp {s[3]}' for s in shipping_options])}",
message_type="comment"
)
# self.message_post(body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}
Detail Lain:
{'
'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, 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:
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.")
# 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()
# Konversi berat ke gram untuk Biteship
weight_gram = int(total_weight * 1000)
if weight_gram < 100:
weight_gram = 100 # Minimum weight untuk Biteship
# Persiapkan data item
items = [{
"name": "Paket Pesanan",
"description": f"Sale Order {self.name}",
"value": int(self.amount_untaxed),
"weight": weight_gram,
"quantity": 1,
"height": 10,
"width": 10,
"length": 10
}]
# Coba dapatkan data koordinat dari alamat pengiriman
shipping_address = self.real_shipping_id
_logger.info(f"Shipping Address: {shipping_address}")
# Data asal (tetap gudang Bandengan)
origin_data = {
"origin_latitude": -6.3031123,
"origin_longitude": 106.7794934,
}
# Prioritaskan penggunaan koordinat jika tersedia
destination_data = {}
use_coordinate = False
# Cek apakah latitude dan longitude tersedia dan valid
if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'):
if shipping_address.latitude and shipping_address.longtitude:
try:
# Validasi format koordinat
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
# Jika koordinat tidak tersedia atau tidak valid, gunakan kode pos
if not use_coordinate:
if shipping_address.zip:
origin_data = {"origin_postal_code": 14440} # Reset origin untuk mode kode pos
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.")
# Siapkan daftar kurir
couriers = ','.join(self._get_biteship_courier_codes())
# Panggil API Biteship dengan format yang benar
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.")
# Hapus shipping_option lama
self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink()
# Proses hasil API
shipping_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', '')
price = service.get('price', 0)
_logger.info(f"Layanan: {courier_name} - {service_name}, Harga: {price}")
# Lewati layanan dengan harga 0
if not price:
_logger.warning(f"Melewati layanan dengan harga 0: {courier_name} - {service_name}")
continue
# Format estimasi waktu
duration = service.get('duration', '')
shipment_range = service.get('shipment_duration_range', '')
shipment_unit = service.get('shipment_duration_unit', 'days')
# Gunakan duration jika tersedia, jika tidak, buat dari range
if duration:
etd = duration
elif shipment_range:
etd = f"{shipment_range} {shipment_unit}"
else:
etd = "1-3 days" # Default fallback
# Buat shipping option
try:
shipping_option = self.env["shipping.option"].create({
"name": f"{courier_name} - {service_name}",
"price": price,
"provider": courier_code,
"etd": etd,
"sale_order_id": self.id,
})
shipping_options.append(shipping_option)
_logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}")
except Exception as e:
_logger.error(f"Gagal membuat opsi pengiriman: {str(e)}")
# Jika tidak ada opsi pengiriman
if not shipping_options:
raise UserError(f"Tidak ada layanan pengiriman ditemukan untuk kode pos {destination_data}. Mohon periksa kembali kode pos atau gunakan metode pengiriman lain.")
# Set opsi pertama sebagai default
self.shipping_option_id = shipping_options[0].id
self.delivery_amt = shipping_options[0].price
# Format pesan untuk log yang lebih informatif
if use_coordinate:
origin_info = f"Koordinat ({origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})"
destination_info = f"Koordinat ({destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})"
else:
origin_info = f"Kode Pos {origin_data.get('origin_postal_code')}"
destination_info = f"Kode Pos {destination_data.get('destination_postal_code')}"
# Format daftar opsi pengiriman
option_list = '
'.join([
f"{opt.name}: Rp {opt.price:,.0f} ({opt.etd})"
for opt in shipping_options
])
# Log hasil estimasi dengan format yang lebih baik
self.message_post(
body=f"Estimasi Ongkir Biteship ({origin_info} → {destination_info}):
{option_list}",
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}]"
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Estimasi Ongkir Berhasil',
'message': f'Mendapatkan {len(shipping_options)} opsi pengiriman menggunakan {api_mode}',
'type': 'success',
'sticky': False,
}
}
def _call_biteship_api(self, origin_data, destination_data, items, couriers=None):
url = 'https://api.biteship.com/v1/rates/couriers'
api_key = 'biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA'
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://pro.rajaongkir.com/api/cost'
headers = {
'key': '9b1310f644056d84d60b0af6bb21611a',
}
courier = self.carrier_id.name.lower()
data = {
'origin': 2127,
'originType': 'subdistrict',
'destination': int(destination_subsdistrict_id),
'destinationType': 'subdistrict',
'weight': int(total_weight * 1000),
'courier': courier,
}
response = requests.post(url, headers=headers, data=data)
if response.status_code == 200:
return response.json()
return None
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 _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('date_order', 'estimated_arrival_days', 'state', 'estimated_arrival_days_start')
def _compute_eta_date(self):
current_date = datetime.now()
for rec in self:
if rec.date_order and rec.state not in ['cancel'] and rec.estimated_arrival_days and rec.estimated_arrival_days_start:
rec.eta_date = current_date + timedelta(days=rec.estimated_arrival_days)
rec.eta_date_start = current_date + timedelta(days=rec.estimated_arrival_days_start)
else:
rec.eta_date = False
rec.eta_date_start = 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): #Function to calculate Estimated Ready To Ship Date
self._calculate_etrts_date()
def _validate_expected_ready_ship_date(self):
for rec in self:
if rec.expected_ready_to_ship and rec.commitment_date:
current_date = datetime.now()
# Hanya membandingkan tanggal saja, tanpa jam
expected_date = rec.expected_ready_to_ship.date()
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
eta_minimum = current_date + timedelta(days=sum_days)
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'))
)
else:
rec.commitment_date = rec.expected_ready_to_ship
@api.onchange('expected_ready_to_ship') #Hangle Onchange form 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,
'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:
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):
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,
}
check_url = f'https://api.midtrans.com/v2/{so_number}/status'
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') == 'expire' or status_response.get('transaction_status') == '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.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: #GD Bandengan
raise UserError('Gudang harus Bandengan')
if self.state not in ['draft', 'sent']:
raise UserError("Status harus draft atau sent")
self._validate_npwp()
def _validate_npwp(self):
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.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:
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)],order='name desc')
if search_bom:
confirmed_bom = search_bom.filtered(lambda x: x.state == 'confirmed')
if not confirmed_bom:
raise UserError("Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi MD.")
else:
raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi MD")
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)])
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()
self._validate_order()
for order in self:
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 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:
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')
if order._requires_approval_margin_leader():
order.approval_status = 'pengajuan2'
return self._create_approval_notification('Pimpinan')
elif order._requires_approval_margin_manager():
self.check_product_bom()
self.check_credit_limit()
self.check_limit_so_to_invoice()
order.approval_status = 'pengajuan1'
return self._create_approval_notification('Sales Manager')
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"""