summaryrefslogtreecommitdiff
path: root/indoteknik_custom/models/sale_order.py
diff options
context:
space:
mode:
authorAzka Nathan <darizkyfaz@gmail.com>2026-03-11 10:24:54 +0700
committerAzka Nathan <darizkyfaz@gmail.com>2026-03-11 10:24:54 +0700
commit9e5744f9e219d284eebb2ee006a772ba78ad054d (patch)
tree4a2aecdb9ecc3811163cc0d6b175eecf27be374c /indoteknik_custom/models/sale_order.py
parent004ea4b603f6121b536c0639f4a5e1bc538eecd5 (diff)
forecast line on po and so
Diffstat (limited to 'indoteknik_custom/models/sale_order.py')
-rwxr-xr-xindoteknik_custom/models/sale_order.py260
1 files changed, 259 insertions, 1 deletions
diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py
index 0cb6670e..19a22cec 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)
@@ -404,7 +405,242 @@ class SaleOrder(models.Model):
client_order_ref = fields.Char(tracking=True)
+ forecast_raw = fields.Text(
+ string='Forecast Raw',
+ compute='_compute_forecast_raw'
+ )
+
+ forecast_html = fields.Html(
+ string='Forecast Coverage',
+ compute='_compute_forecast_html'
+ )
+
+ def cron_generate_sale_forecast(self):
+
+ Forecast = self.env['sale.forecast.coverage']
+ report = self.env['report.stock.report_product_product_replenishment']
+
+ # ambil SO yang punya move aktif (lebih cepat)
+ moves = self.env['stock.move'].search([
+ ('state', 'not in', ['done', 'cancel']),
+ ('sale_line_id', '!=', False)
+ ])
+
+ sos = moves.mapped('sale_line_id.order_id').filtered(
+ lambda so: so.state in ('sale', 'done')
+ )
+
+ if not sos:
+ return
+
+ # hapus forecast lama sekaligus (1 query)
+ Forecast.search([
+ ('sale_id', 'in', sos.ids)
+ ]).unlink()
+
+ all_rows = []
+
+ for so in sos:
+
+ rows = self._generate_forecast_for_so_fast(so, report)
+
+ if rows:
+ all_rows.extend(rows)
+
+ if all_rows:
+ Forecast.create(all_rows)
+
+
+ def _generate_forecast_for_so_fast(self, so, report):
+
+ result = []
+
+ products = so.order_line.mapped('product_id')
+ if not products:
+ return result
+
+ # mapping SOL
+ sol_map = {l.product_id.id: l for l in so.order_line}
+
+ # cache reserved qty sekali
+ reserved_map = {}
+ for sol in so.order_line:
+ reserved_map[sol.product_id.id] = sum(
+ sol.move_ids.mapped('reserved_availability')
+ )
+
+ for product in products:
+
+ data = report._get_report_data(
+ product_variant_ids=[product.id]
+ )
+
+ for l in data.get('lines', []):
+
+ doc_out = l.get('document_out')
+
+ if not doc_out or doc_out._name != 'sale.order' or doc_out.id != so.id:
+ continue
+
+ sol = sol_map.get(product.id)
+
+ if not sol:
+ continue
+
+ # supply logic
+ doc_in = l.get('document_in')
+
+ if doc_in:
+ supply_name = doc_in.display_name
+ elif l.get('reservation'):
+ supply_name = "Reserved from stock"
+ elif l.get('replenishment_filled'):
+ supply_name = (
+ "Inventory On Hand"
+ if doc_out
+ else "Free Stock"
+ )
+ else:
+ supply_name = "Not Available"
+
+ receipt_date = l.get('receipt_date')
+
+ if receipt_date:
+ try:
+ receipt_date = datetime.strptime(
+ receipt_date,
+ "%d/%m/%Y %H:%M:%S"
+ ).strftime("%Y-%m-%d %H:%M:%S")
+ except Exception:
+ receipt_date = False
+
+ result.append({
+ 'sale_id': so.id,
+ 'sale_line_id': sol.id,
+ 'product_id': sol.product_id.id,
+ 'so_qty': sol.product_uom_qty,
+ 'reserved_qty': reserved_map.get(product.id, 0),
+ 'forecast_qty': l.get('quantity'),
+ 'receipt_date': receipt_date,
+ 'document_in_name': supply_name,
+ 'reservation': bool(l.get('reservation')),
+ 'is_late': bool(l.get('is_late')),
+ 'replenishment_filled': bool(l.get('replenishment_filled')),
+ })
+
+ return result
+
+ @api.depends('order_line.product_id', 'order_line.product_uom_qty')
+ def _compute_forecast_raw(self):
+ report = self.env['report.stock.report_product_product_replenishment']
+
+ for so in self:
+ so.forecast_raw = '[]'
+
+ product_ids = so.order_line.mapped('product_id').ids
+ if not product_ids:
+ continue
+
+ data = report._get_report_data(product_variant_ids=product_ids)
+
+ result = []
+ for l in data.get('lines', []):
+ sol = self.env['sale.order.line'].search([
+ ('order_id', '=', so.id),
+ ('product_id', '=', l['product']['id'])
+ ], limit=1)
+ so_qty = sol.product_uom_qty if sol else 0
+ reserved_qty = sum(
+ self.env['stock.move.line'].search([
+ ('move_id.sale_line_id', '=', sol.id),
+ ('state', '!=', 'cancel')
+ ]).mapped('product_uom_qty')
+ ) if sol else 0
+
+ doc_out = l.get('document_out')
+
+ if doc_out and doc_out._name == 'sale.order' and doc_out.id == so.id:
+ doc_in = l.get('document_in')
+
+ result.append({
+ 'product': l['product']['display_name'],
+ 'so_qty': so_qty,
+ 'reserved_qty': reserved_qty,
+ 'quantity': l.get('quantity'),
+ 'receipt_date': l.get('receipt_date'),
+ 'delivery_date': l.get('delivery_date'),
+ 'document_in_name': doc_in.display_name if doc_in else '',
+ 'document_in_model': doc_in._name if doc_in else '',
+ 'document_in_id': doc_in.id if doc_in else False,
+ 'document_out_exists': bool(doc_out),
+ 'reservation': bool(l.get('reservation')),
+ 'is_late': bool(l.get('is_late')),
+ 'replenishment_filled': bool(l.get('replenishment_filled')),
+ })
+
+ so.forecast_raw = json.dumps(result)
+ @api.depends('forecast_raw')
+ def _compute_forecast_html(self):
+ for so in self:
+ rows = []
+
+ try:
+ data = json.loads(so.forecast_raw or '[]')
+ except Exception:
+ data = []
+
+ for l in data:
+ badge = '🟢' if l['replenishment_filled'] else '🔴'
+ late = '⚠️' if l['is_late'] else ''
+
+ # ==== SUPPLY STATUS LOGIC ====
+ if l['document_in_id']:
+ supply_html = f"""
+ <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([
@@ -3749,4 +3985,26 @@ class SaleOrder(models.Model):
'view_mode': 'tree,form',
'domain': [('id', 'in', moves.ids)],
'target': 'current',
- } \ No newline at end of file
+ }
+
+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() \ No newline at end of file