diff options
| -rwxr-xr-x | indoteknik_custom/__manifest__.py | 1 | ||||
| -rw-r--r-- | indoteknik_custom/models/automatic_purchase.py | 230 | ||||
| -rwxr-xr-x | indoteknik_custom/models/purchase_order.py | 281 | ||||
| -rwxr-xr-x | indoteknik_custom/models/sale_order.py | 262 | ||||
| -rwxr-xr-x | indoteknik_custom/security/ir.model.access.csv | 2 | ||||
| -rwxr-xr-x | indoteknik_custom/views/purchase_order.xml | 40 | ||||
| -rwxr-xr-x | indoteknik_custom/views/sale_order.xml | 29 | ||||
| -rw-r--r-- | indoteknik_custom/views/sale_order_forecast.xml | 35 |
8 files changed, 733 insertions, 147 deletions
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py index d0fd4c22..894828df 100755 --- a/indoteknik_custom/__manifest__.py +++ b/indoteknik_custom/__manifest__.py @@ -196,6 +196,7 @@ 'views/token_log.xml', 'views/gudang_service.xml', 'views/kartu_stock.xml', + 'views/sale_order_forecast.xml', ], 'demo': [], 'css': [], diff --git a/indoteknik_custom/models/automatic_purchase.py b/indoteknik_custom/models/automatic_purchase.py index f7c0d75e..8e817409 100644 --- a/indoteknik_custom/models/automatic_purchase.py +++ b/indoteknik_custom/models/automatic_purchase.py @@ -291,7 +291,7 @@ class AutomaticPurchase(models.Model): line.current_po_id = new_po.id line.current_po_line_id = new_po_line.id - self.create_purchase_order_sales_match(new_po) + # self.create_purchase_order_sales_match(new_po) @@ -304,146 +304,146 @@ class AutomaticPurchase(models.Model): for sales in sales_match: sales.sale_line_id.purchase_price = apo.last_price - def create_purchase_order_sales_match(self, purchase_order): - matches_so_product_ids = [line.product_id.id for line in purchase_order.order_line] - - matches_so = self.env['v.sale.notin.matchpo'].search([ - ('automatic_purchase_id', '=', self.id), - ('sale_line_id.product_id', 'in', matches_so_product_ids), - ]) - - sale_ids_set = set() - sale_ids_name = set() - retur_cache = {} - incoming_cache = {} - for sale_order in matches_so: - exist = self.env['purchase.order.sales.match'].search([ - ('product_id', '=', sale_order.product_id.id), - ('sale_line_id', '=', sale_order.sale_line_id.id), - ('sale_id', '=', sale_order.sale_id.id), - ('purchase_order_id.state', '!=', 'cancel'), - ]) + # def create_purchase_order_sales_match(self, purchase_order): + # matches_so_product_ids = [line.product_id.id for line in purchase_order.order_line] + + # matches_so = self.env['v.sale.notin.matchpo'].search([ + # ('automatic_purchase_id', '=', self.id), + # ('sale_line_id.product_id', 'in', matches_so_product_ids), + # ]) + + # sale_ids_set = set() + # sale_ids_name = set() + # retur_cache = {} + # incoming_cache = {} + # for sale_order in matches_so: + # exist = self.env['purchase.order.sales.match'].search([ + # ('product_id', '=', sale_order.product_id.id), + # ('sale_line_id', '=', sale_order.sale_line_id.id), + # ('sale_id', '=', sale_order.sale_id.id), + # ('purchase_order_id.state', '!=', 'cancel'), + # ]) - skip_line = False + # skip_line = False - sale_line_id = sale_order.sale_line_id.id + # sale_line_id = sale_order.sale_line_id.id - if sale_line_id not in incoming_cache: + # if sale_line_id not in incoming_cache: - qty_incoming = 0 + # qty_incoming = 0 - for existing in exist: - if existing.purchase_order_id.state in ['done', 'purchase']: + # for existing in exist: + # if existing.purchase_order_id.state in ['done', 'purchase']: - incoming_moves = self.env['stock.move'].search([ - ('reference', 'ilike', 'BU/INPUT'), - ('state', 'not in', ['done','cancel']), - ('product_id', '=', existing.product_id.id), - ('purchase_line_id', '=', existing.purchase_line_id.id), - ]) + # incoming_moves = self.env['stock.move'].search([ + # ('reference', 'ilike', 'BU/INPUT'), + # ('state', 'not in', ['done','cancel']), + # ('product_id', '=', existing.product_id.id), + # ('purchase_line_id', '=', existing.purchase_line_id.id), + # ]) - qty_incoming += sum(incoming_moves.mapped('product_uom_qty')) + # qty_incoming += sum(incoming_moves.mapped('product_uom_qty')) - incoming_cache[sale_line_id] = qty_incoming + # incoming_cache[sale_line_id] = qty_incoming - qty_need = sale_order.sale_line_id.product_uom_qty + # qty_need = sale_order.sale_line_id.product_uom_qty - if incoming_cache[sale_line_id] >= qty_need: - skip_line = True + # if incoming_cache[sale_line_id] >= qty_need: + # skip_line = True - sale_line_id = sale_order.sale_line_id.id + # sale_line_id = sale_order.sale_line_id.id - if sale_line_id not in retur_cache: + # if sale_line_id not in retur_cache: - fully_received = True + # fully_received = True - for existing in exist: - if existing.purchase_order_id.state in ['done', 'purchase']: + # for existing in exist: + # if existing.purchase_order_id.state in ['done', 'purchase']: - if existing.purchase_line_id.qty_received != existing.purchase_line_id.product_qty: - fully_received = False - break + # if existing.purchase_line_id.qty_received != existing.purchase_line_id.product_qty: + # fully_received = False + # break - retur_cache[sale_line_id] = fully_received + # retur_cache[sale_line_id] = fully_received - if retur_cache[sale_line_id] and exist: - skip_line = True + # if retur_cache[sale_line_id] and exist: + # skip_line = True - if skip_line: - continue + # if skip_line: + # continue - stock_move = self.env['stock.move'].search([ - ('reference', 'ilike', 'BU/PICK'), - ('state', 'in', ['confirmed','waiting','partially_available']), - ('product_id', '=', sale_order.product_id.id), - ('sale_line_id', '=', sale_order.sale_line_id.id), - ]) - if not stock_move: - continue - # @stephan skip so line yang sudah pernah ada di purchase order sales match sebelumnya + # stock_move = self.env['stock.move'].search([ + # ('reference', 'ilike', 'BU/PICK'), + # ('state', 'in', ['confirmed','waiting','partially_available']), + # ('product_id', '=', sale_order.product_id.id), + # ('sale_line_id', '=', sale_order.sale_line_id.id), + # ]) + # if not stock_move: + # continue + # # @stephan skip so line yang sudah pernah ada di purchase order sales match sebelumnya - salesperson_name = sale_order.sale_id.user_id.name + # salesperson_name = sale_order.sale_id.user_id.name - sale_id_with_salesperson = f"{sale_order.sale_id.name} - {salesperson_name}" + # sale_id_with_salesperson = f"{sale_order.sale_id.name} - {salesperson_name}" - sale_ids_set.add(sale_id_with_salesperson) - sale_ids_name.add(sale_order.sale_id.name) - - margin_item = sale_order.sale_line_id.item_margin / sale_order.qty_so if sale_order.qty_so else 0 - margin_item = margin_item * sale_order.qty_po - - matches_so_line = { - 'purchase_order_id': purchase_order.id, - 'sale_id': sale_order.sale_id.id, - 'sale_line_id': sale_order.sale_line_id.id, - 'picking_id': sale_order.picking_id.id, - 'move_id': sale_order.move_id.id, - 'partner_id': sale_order.partner_id.id, - 'partner_invoice_id': sale_order.partner_invoice_id.id, - 'salesperson_id': sale_order.salesperson_id.id, - 'product_id': sale_order.product_id.id, - 'qty_so': sale_order.qty_so, - 'qty_po': sale_order.qty_po, - 'margin_so': sale_order.sale_line_id.item_percent_margin, - 'margin_item': margin_item - } - po_matches_so_line = self.env['purchase.order.sales.match'].create([matches_so_line]) - - sale_ids_str = ','.join(sale_ids_set) - sale_ids_str_name = ','.join(sale_ids_name) - - purchase_order.sale_order = sale_ids_str_name - purchase_order.notes = sale_ids_str + # sale_ids_set.add(sale_id_with_salesperson) + # sale_ids_name.add(sale_order.sale_id.name) + + # margin_item = sale_order.sale_line_id.item_margin / sale_order.qty_so if sale_order.qty_so else 0 + # margin_item = margin_item * sale_order.qty_po + + # matches_so_line = { + # 'purchase_order_id': purchase_order.id, + # 'sale_id': sale_order.sale_id.id, + # 'sale_line_id': sale_order.sale_line_id.id, + # 'picking_id': sale_order.picking_id.id, + # 'move_id': sale_order.move_id.id, + # 'partner_id': sale_order.partner_id.id, + # 'partner_invoice_id': sale_order.partner_invoice_id.id, + # 'salesperson_id': sale_order.salesperson_id.id, + # 'product_id': sale_order.product_id.id, + # 'qty_so': sale_order.qty_so, + # 'qty_po': sale_order.qty_po, + # 'margin_so': sale_order.sale_line_id.item_percent_margin, + # 'margin_item': margin_item + # } + # po_matches_so_line = self.env['purchase.order.sales.match'].create([matches_so_line]) + + # sale_ids_str = ','.join(sale_ids_set) + # sale_ids_str_name = ','.join(sale_ids_name) + + # purchase_order.sale_order = sale_ids_str_name + # purchase_order.notes = sale_ids_str - self.create_sales_order_purchase_match(purchase_order) + # self.create_sales_order_purchase_match(purchase_order) - def create_sales_order_purchase_match(self, purchase_order): - #TODO add matches po to sales order by automatic_purchase.sales.match - matches_po_product_ids = [line.product_id.id for line in purchase_order.order_line] - - sales_match_line = self.env['automatic.purchase.sales.match'].search([ - ('automatic_purchase_id', '=', self.id), - ('sale_line_id.product_id', 'in', matches_po_product_ids), - ]) - - for sales_match in sales_match_line: - purchase_line = self.env['automatic.purchase.line'].search([ - ('automatic_purchase_id', '=', self.id), - ('product_id', 'in', [sales_match.product_id.id]), - ], limit=1) - - matches_po_line = { - 'sales_order_id' : sales_match.sale_id.id, - 'purchase_order_id' : purchase_line.current_po_id.id, - 'purchase_line_id' : purchase_line.current_po_line_id.id, - 'product_id' : sales_match.product_id.id, - 'qty_so' : sales_match.qty_so, - 'qty_po' : sales_match.qty_po, - } - - sales_order_purchase_match = self.env['sales.order.purchase.match'].create([matches_po_line]) + # def create_sales_order_purchase_match(self, purchase_order): + # #TODO add matches po to sales order by automatic_purchase.sales.match + # matches_po_product_ids = [line.product_id.id for line in purchase_order.order_line] + + # sales_match_line = self.env['automatic.purchase.sales.match'].search([ + # ('automatic_purchase_id', '=', self.id), + # ('sale_line_id.product_id', 'in', matches_po_product_ids), + # ]) + + # for sales_match in sales_match_line: + # purchase_line = self.env['automatic.purchase.line'].search([ + # ('automatic_purchase_id', '=', self.id), + # ('product_id', 'in', [sales_match.product_id.id]), + # ], limit=1) + + # matches_po_line = { + # 'sales_order_id' : sales_match.sale_id.id, + # 'purchase_order_id' : purchase_line.current_po_id.id, + # 'purchase_line_id' : purchase_line.current_po_line_id.id, + # 'product_id' : sales_match.product_id.id, + # 'qty_so' : sales_match.qty_so, + # 'qty_po' : sales_match.qty_po, + # } + + # sales_order_purchase_match = self.env['sales.order.purchase.match'].create([matches_po_line]) def generate_regular_purchase(self, jobs): current_time = datetime.utcnow() diff --git a/indoteknik_custom/models/purchase_order.py b/indoteknik_custom/models/purchase_order.py index 244575ae..1c573371 100755 --- a/indoteknik_custom/models/purchase_order.py +++ b/indoteknik_custom/models/purchase_order.py @@ -23,6 +23,11 @@ class PurchaseOrder(models.Model): _inherit = 'purchase.order' vcm_id = fields.Many2one('tukar.guling.po', string='Doc VCM', readonly=True, compute='_has_vcm', copy=False) + forecast_line_ids = fields.One2many( + 'purchase.order.forecast.line', + 'order_id', + string='Forecast Coverage', + ) order_sales_match_line = fields.One2many('purchase.order.sales.match', 'purchase_order_id', string='Sales Match Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True) sale_order_id = fields.Many2one('sale.order', string='Sale Order') procurement_status = fields.Char(string='Procurement Status', compute='get_procurement_status', readonly=True) @@ -132,6 +137,196 @@ class PurchaseOrder(models.Model): soo_price = fields.Float('SOO Price', copy=False) soo_discount = fields.Float('SOO Discount', copy=False) soo_tax = fields.Float('SOO Tax', copy=False) + + forecast_raw = fields.Text(string='Forecast Raw', compute='_compute_forecast_raw') + forecast_html = fields.Html(string='Forecast Matches SO', compute='_compute_forecast_html') + + @api.model + def cron_generate_po_forecast(self): + + # two_months_ago = fields.Datetime.now() - relativedelta(months=1) + + # pos = self.env['purchase.order'].search([ + # ('state', 'in', ['purchase','done']), + # '|', + # ('create_date', '>=', two_months_ago), + # ('write_date', '>=', two_months_ago), + # ]) + + pos = self.env['purchase.order'].search([ + ('state','in',['purchase','done']) + ]) + + pos = pos.filtered( + lambda po: any( + m.state not in ['done','cancel'] and 'BU/INPUT' in m.reference + for l in po.order_line + for m in l.move_ids + ) + ) + + itung = len(pos) + + for po in pos: + po._generate_forecast_lines() + + # def _generate_forecast_lines(self): + + # report = self.env['report.stock.report_product_product_replenishment'] + # Forecast = self.env['purchase.order.forecast.line'] + + # for po in self: + + # try: + + # Forecast.search([ + # ('order_id', '=', po.id) + # ]).unlink() + + # products = po.order_line.mapped('product_id') + # if not products: + # continue + + # for product in products: + + # try: + + # data = report._get_report_data( + # product_variant_ids=[product.id] + # ) + + # for l in data.get('lines', []): + + # doc = l.get('document_in') + + # if doc and doc._name == 'purchase.order' and doc.id == po.id: + + # doc_out = l.get('document_out') + + # Forecast.create({ + # 'order_id': po.id, + # 'product_id': product.id, + # 'quantity': l['quantity'], + # 'receipt_date': l.get('receipt_date'), + # 'delivery_date': l.get('delivery_date'), + # 'sale_order': doc_out.display_name if doc_out else '', + # 'sale_order_id': doc_out.id if doc_out else '', + # 'is_late': bool(l.get('is_late')), + # 'replenishment_filled': bool(l.get('replenishment_filled')), + # }) + + # except Exception as line_error: + + # po.message_post( + # body=f"⚠️ Forecast line error: {str(line_error)}" + # ) + + # continue + + # except Exception as po_error: + + # po.message_post( + # body=f"❌ Forecast generation failed: {str(po_error)}" + # ) + + # continue + + @api.depends('forecast_raw') + def _compute_forecast_html(self): + for po in self: + rows = [] + try: + data = json.loads(po.forecast_raw or '[]') + except Exception: + data = [] + + for l in data: + badge = '🟢' if l['replenishment_filled'] else '🔴' + late = '⚠️' if l['is_late'] else '' + + rows.append(f""" + <tr> + <td>{l['product']}</td> + <td style="text-align:right">{l['quantity']}</td> + <td>{l['document_out'] or ''}</td> + </tr> + """) + + po.forecast_html = f""" + <table class="table table-sm table-bordered"> + <thead> + <tr> + <th>Product</th> + <th>Qty</th> + <th>Sale Order</th> + </tr> + </thead> + <tbody> + {''.join(rows)} + </tbody> + </table> + """ + + @api.depends('order_line.product_id', 'order_line.product_qty') + def _compute_forecast_raw(self): + import json + report = self.env['report.stock.report_product_product_replenishment'] + + for po in self: + po.forecast_raw = '[]' + + product_ids = po.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', []): + doc = l.get('document_in') + if doc and doc._name == 'purchase.order' and doc.id == po.id: + doc_out = l.get('document_out') + result.append({ + 'product': l['product']['display_name'], + 'quantity': l['quantity'], + 'receipt_date': l.get('receipt_date'), + 'delivery_date': l.get('delivery_date'), + 'document_out': doc_out.display_name if doc_out else '', + 'is_late': l.get('is_late'), + 'replenishment_filled': l.get('replenishment_filled'), + }) + + po.forecast_raw = json.dumps(result) + + def _generate_forecast_lines(self): + report = self.env['report.stock.report_product_product_replenishment'] + Forecast = self.env['purchase.order.forecast.line'] + + for po in self: + + Forecast.search([('order_id','=',po.id)]).unlink() + + product_ids = po.order_line.mapped('product_id').ids + if not product_ids: + continue + + data = report._get_report_data(product_variant_ids=product_ids) + + for l in data.get('lines', []): + doc = l.get('document_in') + + if doc and doc._name == 'purchase.order' and doc.id == po.id: + doc_out = l.get('document_out') + + Forecast.create({ + 'order_id': po.id, + 'product_id': l['product']['id'], + 'quantity': l['quantity'], + 'sale_order': doc_out.display_name if doc_out else '', + 'sale_order_id': doc_out.id if doc_out else '', + 'is_late': bool(l.get('is_late')), + 'replenishment_filled': bool(l.get('replenishment_filled')), + }) def _get_altama_token(self, source='auto'): ICP = self.env['ir.config_parameter'].sudo() @@ -1486,7 +1681,7 @@ class PurchaseOrder(models.Model): # return self.action_view_related_bu() if self.partner_id.id == 5571 and not self.revisi_po: self.action_create_order_altama() - + return res def _remove_product_bom(self): @@ -1691,31 +1886,43 @@ class PurchaseOrder(models.Model): return sum_so_margin = sum_sales_price = sum_margin = 0 - for line in self.order_line: - sale_order_line = line.so_line_id - if not sale_order_line: - sale_order_line = self.env['sale.order.line'].search([ - ('product_id', '=', line.product_id.id), - ('order_id', '=', line.order_id.sale_order_id.id) - ], limit=1, order='price_reduce_taxexcl') - - sum_so_margin += sale_order_line.item_margin - sales_price = sale_order_line.price_reduce_taxexcl * sale_order_line.product_uom_qty + for line in self.forecast_line_ids: + sale_order_line = self.env['sale.order.line'].search([ + ('product_id', '=', line.product_id.id), + ('order_id', '=', line.sale_order_id.id) + ], limit=1, order='price_reduce_taxexcl') + + purchase_order_line = self.env['purchase.order.line'].search([ + ('product_id', '=', line.product_id.id), + ('order_id', '=', line.order_id.id) + ], limit=1) + + sol_qty = sale_order_line.product_uom_qty + unit_margin = sale_order_line.item_margin / sol_qty if sol_qty else 0 + sum_so_margin += unit_margin * line.quantity + sales_price = sale_order_line.price_reduce_taxexcl * line.quantity if sale_order_line.order_id.shipping_cost_covered == 'indoteknik': sales_price -= sale_order_line.delivery_amt_line if sale_order_line.order_id.fee_third_party > 0: sales_price -= sale_order_line.fee_third_party_line sum_sales_price += sales_price - purchase_price = line.price_subtotal - if line.ending_price > 0: - if line.taxes_id.id == 22: - ending_price = line.ending_price / 1.11 - purchase_price = ending_price + po_qty = purchase_order_line.product_qty + + if purchase_order_line.ending_price > 0: + if purchase_order_line.taxes_id.id == 22: + ending_price = purchase_order_line.ending_price / 1.11 else: - purchase_price = line.ending_price + ending_price = purchase_order_line.ending_price + + unit_cost = ending_price / po_qty if po_qty else 0 + purchase_price = unit_cost * line.quantity + + else: + unit_cost = purchase_order_line.price_subtotal / po_qty if po_qty else 0 + purchase_price = unit_cost * line.quantity # purchase_price = line.price_subtotal if line.order_id.delivery_amount > 0: - purchase_price += line.delivery_amt_line + purchase_price += purchase_order_line.delivery_amt_line if line.order_id.delivery_amt > 0: purchase_price += line.order_id.delivery_amt real_item_margin = sales_price - purchase_price @@ -1751,23 +1958,25 @@ class PurchaseOrder(models.Model): def compute_total_margin_from_apo(self): sum_so_margin = sum_sales_price = sum_margin = cashback_amount = 0 - for line in self.order_sales_match_line: + for line in self.forecast_line_ids: po_line = self.env['purchase.order.line'].search([ ('product_id', '=', line.product_id.id), - ('order_id', '=', line.purchase_order_id.id) + ('order_id', '=', line.order_id.id) ], limit=1) - sale_order_line = line.sale_line_id or self.env['sale.order.line'].search([ + sale_order_line = self.env['sale.order.line'].search([ ('product_id', '=', line.product_id.id), - ('order_id', '=', line.sale_id.id) + ('order_id', '=', line.sale_order_id.id) ], limit=1, order='price_reduce_taxexcl') if sale_order_line and po_line: - qty_so = line.qty_so or 0 - qty_po = line.qty_po or 0 + qty_so = line.quantity or 0 + qty_po = po_line.product_qty or 0 # Hindari division by zero so_margin = (qty_po / qty_so) * sale_order_line.item_margin if qty_so > 0 else 0 - sum_so_margin += so_margin + sol_qty = sale_order_line.product_uom_qty + unit_margin = sale_order_line.item_margin / sol_qty if sol_qty else 0 + sum_so_margin += unit_margin * line.quantity sales_price = sale_order_line.price_reduce_taxexcl * qty_po if sale_order_line.order_id.shipping_cost_covered == 'indoteknik': @@ -1782,10 +1991,10 @@ class PurchaseOrder(models.Model): purchase_price = po_line.ending_price / 1.11 else: purchase_price = po_line.ending_price - if line.purchase_order_id.delivery_amount > 0: + if line.order_id.delivery_amount > 0: purchase_price += (po_line.delivery_amt_line / po_line.product_qty) * qty_po - if line.purchase_order_id.delivery_amt > 0: - purchase_price += line.purchase_order_id.delivery_amt + if line.order_id.delivery_amt > 0: + purchase_price += line.order_id.delivery_amt if self.partner_id.id == 5571: cashback_percent = line.product_id.x_manufacture.cashback_percent or 0.0 @@ -1994,4 +2203,20 @@ class ChangeDatePlannedWizard(models.TransientModel): }) +class PurchaseOrderForecastLine(models.Model): + _name = 'purchase.order.forecast.line' + _description = 'PO Forecast Coverage' + + order_id = fields.Many2one('purchase.order', ondelete='cascade') + + sale_order_id = fields.Many2one('sale.order', ondelete='cascade') + product_id = fields.Many2one('product.product') + quantity = fields.Float() + + sale_order = fields.Char() + + receipt_date = fields.Datetime() + delivery_date = fields.Datetime() + replenishment_filled = fields.Boolean() + is_late = fields.Boolean() diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py index 567259af..185cee0d 100755 --- a/indoteknik_custom/models/sale_order.py +++ b/indoteknik_custom/models/sale_order.py @@ -125,6 +125,7 @@ class SaleOrderLine(models.Model): 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) @@ -405,7 +406,242 @@ class SaleOrder(models.Model): client_order_ref = fields.Char(tracking=True) sourcing_job_count = fields.Integer(string='Sourcing Count', compute='_compute_sourcing_count') + forecast_raw = fields.Text( + string='Forecast Raw', + compute='_compute_forecast_raw' + ) + + forecast_html = fields.Html( + string='Forecast Coverage', + compute='_compute_forecast_html' + ) + + def cron_generate_sale_forecast(self): + + Forecast = self.env['sale.forecast.coverage'] + report = self.env['report.stock.report_product_product_replenishment'] + + # ambil SO yang punya move aktif (lebih cepat) + moves = self.env['stock.move'].search([ + ('state', 'not in', ['done', 'cancel']), + ('sale_line_id', '!=', False) + ]) + + sos = moves.mapped('sale_line_id.order_id').filtered( + lambda so: so.state in ('sale', 'done') + ) + + if not sos: + return + + # hapus forecast lama sekaligus (1 query) + Forecast.search([ + ('sale_id', 'in', sos.ids) + ]).unlink() + + all_rows = [] + + for so in sos: + + rows = self._generate_forecast_for_so_fast(so, report) + + if rows: + all_rows.extend(rows) + + if all_rows: + Forecast.create(all_rows) + + + def _generate_forecast_for_so_fast(self, so, report): + + result = [] + + products = so.order_line.mapped('product_id') + if not products: + return result + + # mapping SOL + sol_map = {l.product_id.id: l for l in so.order_line} + + # cache reserved qty sekali + reserved_map = {} + for sol in so.order_line: + reserved_map[sol.product_id.id] = sum( + sol.move_ids.mapped('reserved_availability') + ) + + for product in products: + + data = report._get_report_data( + product_variant_ids=[product.id] + ) + + for l in data.get('lines', []): + + doc_out = l.get('document_out') + + if not doc_out or doc_out._name != 'sale.order' or doc_out.id != so.id: + continue + + sol = sol_map.get(product.id) + + if not sol: + continue + + # supply logic + doc_in = l.get('document_in') + + if doc_in: + supply_name = doc_in.display_name + elif l.get('reservation'): + supply_name = "Reserved from stock" + elif l.get('replenishment_filled'): + supply_name = ( + "Inventory On Hand" + if doc_out + else "Free Stock" + ) + else: + supply_name = "Not Available" + + receipt_date = l.get('receipt_date') + + if receipt_date: + try: + receipt_date = datetime.strptime( + receipt_date, + "%d/%m/%Y %H:%M:%S" + ).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + receipt_date = False + + result.append({ + 'sale_id': so.id, + 'sale_line_id': sol.id, + 'product_id': sol.product_id.id, + 'so_qty': sol.product_uom_qty, + 'reserved_qty': reserved_map.get(product.id, 0), + 'forecast_qty': l.get('quantity'), + 'receipt_date': receipt_date, + 'document_in_name': supply_name, + 'reservation': bool(l.get('reservation')), + 'is_late': bool(l.get('is_late')), + 'replenishment_filled': bool(l.get('replenishment_filled')), + }) + + return result + + @api.depends('order_line.product_id', 'order_line.product_uom_qty') + def _compute_forecast_raw(self): + report = self.env['report.stock.report_product_product_replenishment'] + + for so in self: + so.forecast_raw = '[]' + + product_ids = so.order_line.mapped('product_id').ids + if not product_ids: + continue + + data = report._get_report_data(product_variant_ids=product_ids) + + result = [] + for l in data.get('lines', []): + sol = self.env['sale.order.line'].search([ + ('order_id', '=', so.id), + ('product_id', '=', l['product']['id']) + ], limit=1) + so_qty = sol.product_uom_qty if sol else 0 + reserved_qty = sum( + self.env['stock.move.line'].search([ + ('move_id.sale_line_id', '=', sol.id), + ('state', '!=', 'cancel') + ]).mapped('product_uom_qty') + ) if sol else 0 + + doc_out = l.get('document_out') + + if doc_out and doc_out._name == 'sale.order' and doc_out.id == so.id: + doc_in = l.get('document_in') + + result.append({ + 'product': l['product']['display_name'], + 'so_qty': so_qty, + 'reserved_qty': reserved_qty, + 'quantity': l.get('quantity'), + 'receipt_date': l.get('receipt_date'), + 'delivery_date': l.get('delivery_date'), + 'document_in_name': doc_in.display_name if doc_in else '', + 'document_in_model': doc_in._name if doc_in else '', + 'document_in_id': doc_in.id if doc_in else False, + 'document_out_exists': bool(doc_out), + 'reservation': bool(l.get('reservation')), + 'is_late': bool(l.get('is_late')), + 'replenishment_filled': bool(l.get('replenishment_filled')), + }) + + so.forecast_raw = json.dumps(result) + @api.depends('forecast_raw') + def _compute_forecast_html(self): + for so in self: + rows = [] + + try: + data = json.loads(so.forecast_raw or '[]') + except Exception: + data = [] + + for l in data: + badge = '🟢' if l['replenishment_filled'] else '🔴' + late = '⚠️' if l['is_late'] else '' + + # ==== SUPPLY STATUS LOGIC ==== + if l['document_in_id']: + supply_html = f""" + <a href="/web#id={l['document_in_id']}&model={l['document_in_model']}&view_type=form" + class="font-weight-bold"> + {l['document_in_name']} + </a> + """ + 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 = '<span class="text-muted">Not Available</span>' + + rows.append(f""" + <tr class="{ 'o_grid_warning' if l['is_late'] else '' }"> + <td>{l['product']}</td> + <td style="text-align:right">{l['quantity']}</td> + <td>{supply_html}</td> + <td>{l['receipt_date'] or ''}</td> + <td style="text-align:right">{l['so_qty']}</td> + <td style="text-align:right">{l['reserved_qty']}</td> + </tr> + """) + + so.forecast_html = f""" + <table class="table table-sm table-bordered"> + <thead> + <tr> + <th>Product</th> + <th>Qty</th> + <th>Supplied By</th> + <th>Receipt Date</th> + <th>SO Qty</th> + <th>Reserved</th> + </tr> + </thead> + <tbody> + {''.join(rows)} + </tbody> + </table> + """ + def action_set_shipping_id(self): for rec in self: bu_pick = self.env['stock.picking'].search([ @@ -2430,7 +2666,7 @@ class SaleOrder(models.Model): user = self.env.user is_sales_admin = user.id in (3401, 20, 3988, 17340) if is_sales_admin: - order.approval_status = 'pengajuan1' + order.approval_status = 'pengajuan0' order.message_post(body="Mengajukan approval ke Sales") return self._create_approval_notification('Sales') @@ -3771,6 +4007,7 @@ class SaleOrder(models.Model): 'target': 'current', } +<<<<<<< HEAD def action_open_sjo(self): return { 'name': 'SJO', @@ -3782,3 +4019,26 @@ class SaleOrder(models.Model): 'default_so_id': self.id, } } +======= +class SaleForecastCoverage(models.Model): + _name = 'sale.forecast.coverage' + _description = 'Sale Forecast Coverage' + + sale_id = fields.Many2one('sale.order', index=True) + sale_line_id = fields.Many2one('sale.order.line', index=True) + + product_id = fields.Many2one('product.product') + so_qty = fields.Float() + reserved_qty = fields.Float() + + forecast_qty = fields.Float() + receipt_date = fields.Datetime() + + document_in_model = fields.Char() + document_in_id = fields.Integer() + document_in_name = fields.Char() + + reservation = fields.Boolean() + is_late = fields.Boolean() + replenishment_filled = fields.Boolean() +>>>>>>> e6b6691f518a7400babdbd4b95541fb3d07f154d diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv index 4776b9e8..b685413d 100755 --- a/indoteknik_custom/security/ir.model.access.csv +++ b/indoteknik_custom/security/ir.model.access.csv @@ -231,6 +231,8 @@ access_sourcing_job_order_line_template_wizard,sourcing.job.order.line.template. access_sjo_give_wizard_user,sjo.give.wizard user,model_sjo_give_wizard,base.group_user,1,1,1,1 access_sjo_reject_give_wizard_user,sjo.reject.give.wizard user,model_sjo_reject_give_wizard,base.group_user,1,1,1,1 access_token_log,access.token.log,model_token_log,,1,1,1,1 +access_purchase_order_forecast_line,access.purchase.order.forecast.line,model_purchase_order_forecast_line,,1,1,1,1 +access_sale_forecast_coverage,access.sale.forecast.coverage,model_sale_forecast_coverage,,1,1,1,1 access_reopen_cancel_line_wizard,reopen.cancel.line.wizard,model_reopen_cancel_line_wizard,base.group_user,1,1,1,1 access_account_move_change_date_wizard,access.account.move.change.date.wizard,model_account_move_change_date_wizard,,1,1,1,1 diff --git a/indoteknik_custom/views/purchase_order.xml b/indoteknik_custom/views/purchase_order.xml index 581690a1..c2a45c7a 100755 --- a/indoteknik_custom/views/purchase_order.xml +++ b/indoteknik_custom/views/purchase_order.xml @@ -230,11 +230,37 @@ <field name="purchase_order_lines"/> </page> </xpath> - <xpath expr="//form/sheet/notebook/page[@name='purchase_delivery_invoice']" position="after"> + <!-- <xpath expr="//form/sheet/notebook/page[@name='purchase_delivery_invoice']" position="after"> <page string="Matches SO" name="purchase_order_sales_matches_lines"> <field name="order_sales_match_line"/> </page> + </xpath> --> + <xpath expr="//notebook" position="inside"> + <page string="Forecast"> + <field name="forecast_html" nolabel="1"/> + </page> + </xpath> + + <xpath expr="//notebook" position="inside"> + <page string="Forecast Coverage"> + + <field name="forecast_line_ids"> + + <tree decoration-danger="is_late" decoration-success="replenishment_filled"> + <field name="product_id"/> + <field name="quantity"/> + <field name="sale_order"/> + <field name="sale_order_id"/> + <field name="receipt_date"/> + <field name="delivery_date"/> + <field name="replenishment_filled"/> + <field name="is_late"/> + </tree> + + </field> + </page> </xpath> + <xpath expr="//form/sheet/notebook/page[@name='purchase_delivery_invoice']" position="after"> <page string="Other Info" name="purchase_order_sales_matches_lines"> <group string="Return Doc"> @@ -467,6 +493,18 @@ </record> </data> <data> + <record id="cron_generate_po_forecast" model="ir.cron"> + <field name="name">Generate PO Forecast Coverage</field> + <field name="model_id" ref="purchase.model_purchase_order"/> + <field name="state">code</field> + <field name="code">model.cron_generate_po_forecast()</field> + <field name="interval_number">7</field> + <field name="interval_type">minutes</field> + <field name="numbercall">-1</field> + <field name="active">True</field> + </record> + </data> + <data> <record id="action_update_receipt_date_po" model="ir.actions.server"> <field name="name">Update Receipt Date</field> <field name="model_id" ref="purchase.model_purchase_order"/> diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml index 80eea779..79604e75 100755 --- a/indoteknik_custom/views/sale_order.xml +++ b/indoteknik_custom/views/sale_order.xml @@ -451,15 +451,30 @@ <!-- <page string="Fullfillment" name="page_sale_order_fullfillment"> <field name="fullfillment_line" readonly="1"/> </page> --> - <page string="Fulfillment v2" name="page_sale_order_fullfillment2"> + <!-- <page string="Fulfillment v2" name="page_sale_order_fullfillment2"> <field name="fulfillment_line_v2" readonly="1"/> - </page> + </page> --> <page string="Reject Line" name="page_sale_order_reject_line"> <field name="reject_line" readonly="0"/> </page> <page string="Koli" name="page_sales_order_koli_line"> <field name="koli_lines" readonly="1"/> </page> + <page string="Forecast"> + <field name="forecast_html" nolabel="1"/> + </page> + <page string="Fulfillment"> + <field name="sale_forecast_lines"> + <tree> + <field name="product_id"/> + <field name="forecast_qty"/> + <field name="document_in_model"/> + <field name="document_in_id"/> + <field name="document_in_name"/> + <field name="receipt_date"/> + </tree> + </field> + </page> </page> </field> </record> @@ -585,6 +600,16 @@ <field name="code">action = records.open_form_multi_update_state()</field> </record> + <record id="cron_sale_forecast_generate" model="ir.cron"> + <field name="name">Generate Sale Forecast</field> + <field name="model_id" ref="model_sale_order"/> + <field name="state">code</field> + <field name="code">model.cron_generate_sale_forecast()</field> + <field name="interval_number">14</field> + <field name="interval_type">minutes</field> + <field name="active">True</field> + </record> + <record id="sale_order_update_multi_actions_server" model="ir.actions.server"> <field name="name">Mark As Completed</field> <field name="model_id" ref="sale.model_sale_order"/> diff --git a/indoteknik_custom/views/sale_order_forecast.xml b/indoteknik_custom/views/sale_order_forecast.xml new file mode 100644 index 00000000..8e1d13b4 --- /dev/null +++ b/indoteknik_custom/views/sale_order_forecast.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<odoo> + <data> + + <record id="view_sale_forecast_coverage_tree" model="ir.ui.view"> + <field name="name">sale.forecast.coverage.tree</field> + <field name="model">sale.forecast.coverage</field> + <field name="arch" type="xml"> + <tree string="Sale Forecast Coverage"> + <field name="sale_id"/> + <field name="product_id"/> + <field name="so_qty"/> + <field name="reserved_qty"/> + <field name="forecast_qty"/> + <field name="document_in_name"/> + <field name="receipt_date"/> + </tree> + </field> + </record> + + <record id="action_sale_forecast_coverage" model="ir.actions.act_window"> + <field name="name">Sale Forecast Coverage</field> + <field name="res_model">sale.forecast.coverage</field> + <field name="view_mode">tree</field> + </record> + + <menuitem + id="menu_sale_forecast_coverage" + name="Forecast Coverage" + parent="sale.product_menu_catalog" + action="action_sale_forecast_coverage" + sequence="35" + /> + </data> +</odoo>
\ No newline at end of file |
