from odoo import models, fields, api, tools, _ from datetime import datetime, timedelta import math import logging from odoo.exceptions import AccessError, UserError, ValidationError class MrpProduction(models.Model): _inherit = 'mrp.production' check_bom_product_lines = fields.One2many('check.bom.product', 'production_id', string='Check Product', auto_join=True, copy=False) desc = fields.Text(string='Description') sale_order = fields.Many2one('sale.order', string='Sale Order', copy=False) production_purchase_match = fields.One2many('production.purchase.match', 'production_id', string='Purchase Matches', auto_join=True) is_po = fields.Boolean(string='Is PO') state_reserve = fields.Selection([ ('waiting', 'Waiting For Fullfilment'), ('ready', 'Ready to Ship'), ('done', 'Done'), ('cancel', 'Cancelled'), ], string='Status Reserve', tracking=True, copy=False, help="The current state of the stock picking.") date_reserved = fields.Datetime(string="Date Reserved", help='Tanggal ter-reserved semua barang nya', copy=False) def action_cancel(self): for production in self: moves_with_forecast = production.move_raw_ids.filtered( lambda m: m.reserved_availability > 0 ) if moves_with_forecast: # bikin list produk per baris product_list = "\n".join( "- %s" % p.display_name for p in moves_with_forecast.mapped('product_id') ) raise UserError(_( "You cannot cancel this Manufacturing Order because the following raw materials " "still have forecast availability:\n\n%s" % product_list )) return super(MrpProduction, self).action_cancel() @api.constrains('check_bom_product_lines') def constrains_check_bom_product_lines(self): for rec in self: if len(rec.check_bom_product_lines) > 0: rec.qty_producing = rec.product_qty def button_mark_done(self): """Override button_mark_done untuk mengirim pesan ke Sale Order jika state berubah menjadi 'confirmed'.""" if self._name != 'mrp.production': return super(MrpProduction, self).button_mark_done() result = super(MrpProduction, self).button_mark_done() for record in self: if len(record.check_bom_product_lines) < 1: raise UserError("Check Product Tidak Boleh Kosong") if not record.sale_order: raise UserError("Sale Order Tidak Boleh Kosong") if record.sale_order and record.state == 'confirmed': message = _("Manufacturing order telah dibuat dengan nomor %s") % (record.name) record.sale_order.message_post(body=message) return result def action_confirm(self): """Override action_confirm untuk mengirim pesan ke Sale Order jika state berubah menjadi 'confirmed'.""" if self._name != 'mrp.production': return super(MrpProduction, self).action_confirm() result = super(MrpProduction, self).action_confirm() for record in self: # if len(record.check_bom_product_lines) < 1: # raise UserError("Check Product Tidak Boleh Kosong") if record.sale_order and record.state == 'confirmed': message = _("Manufacturing order telah dibuat dengan nomor %s") % (record.name) record.sale_order.message_post(body=message) return result def create_po_from_manufacturing(self): if not self.state == 'confirmed': raise UserError('Harus Di Approve oleh Merchandiser') if self.is_po == True: raise UserError('Sudah pernah di buat PO') if not self.move_raw_ids: raise UserError('Tidak ada Lines, belum bisa create PO') # if self.is_po: # raise UserError('Sudah pernah di create PO') vendor_ids = self.env['stock.move'].read_group([ ('raw_material_production_id', '=', self.id), ('vendor_id', '!=', False) ], fields=['vendor_id'], groupby=['vendor_id']) po_ids = [] for vendor in vendor_ids: result_po = self.create_po_by_vendor(vendor['vendor_id'][0]) po_ids += result_po return { 'name': _('Purchase Order'), 'view_mode': 'tree,form', 'res_model': 'purchase.order', 'target': 'current', 'type': 'ir.actions.act_window', 'domain': [('id', 'in', po_ids)], } def create_po_by_vendor(self, vendor_id): current_time = datetime.now() PRODUCT_PER_PO = 20 stock_move = self.env['stock.move'] param_header = { 'partner_id': vendor_id, # 'partner_ref': self.sale_order_id.name, 'currency_id': 12, 'user_id': self.env.user.id, 'company_id': 1, # indoteknik dotcom gemilang 'picking_type_id': 28, # indoteknik bandengan receipts 'date_order': current_time, 'product_bom_id': self.product_id.id, 'sale_order_id': self.sale_order.id, 'manufacturing_id': self.id, 'note_description': 'from Manufacturing Order', } domain = [ ('raw_material_production_id', '=', self.id), ('vendor_id', '=', vendor_id), ('state', 'in', ['waiting','confirmed','partially_available']) ] products_len = stock_move.search_count(domain) page = math.ceil(products_len / PRODUCT_PER_PO) po_ids = [] # i start from zero (0) for i in range(page): new_po = self.env['purchase.order'].create([param_header]) new_po.name = new_po.name + "/MO/" + str(i + 1) po_ids.append(new_po.id) lines = stock_move.search( domain, offset=i * PRODUCT_PER_PO, limit=PRODUCT_PER_PO ) tax = [22] for line in lines: product = line.product_id price, taxes, vendor = self._get_purchase_price(product) param_line = { 'order_id' : new_po.id, 'product_id': product.id, 'product_qty': line.product_uom_qty if line.state in ['confirmed', 'waiting'] else line.product_uom_qty - line.forecast_availability, 'product_uom_qty': line.product_uom_qty if line.state in ['confirmed', 'waiting'] else line.product_uom_qty - line.forecast_availability, 'name': product.display_name, 'price_unit': price if price else 0.0, 'taxes_id': [taxes] if taxes else [], } new_po_line = self.env['purchase.order.line'].create([param_line]) self.env['production.purchase.match'].create([{ 'production_id': self.id, 'order_id': new_po.id }]) # new_po.button_confirm() self.is_po = True return po_ids def _get_purchase_price(self, product_id): override_vendor = product_id.x_manufacture.override_vendor_id query = [('product_id', '=', product_id.id), ('vendor_id', '=', override_vendor.id)] purchase_price = self.env['purchase.pricelist'].search(query, limit=1) if purchase_price: return self._get_valid_purchase_price(purchase_price) else: purchase_price = self.env['purchase.pricelist'].search( [('product_id', '=', product_id.id), ('is_winner', '=', True)], limit=1) return self._get_valid_purchase_price(purchase_price) def _get_valid_purchase_price(self, purchase_price): current_time = datetime.now() delta_time = current_time - timedelta(days=365) # delta_time = delta_time.strftime('%Y-%m-%d %H:%M:%S') price = 0 taxes = 24 vendor_id = '' human_last_update = purchase_price.human_last_update or datetime.min system_last_update = purchase_price.system_last_update or datetime.min #if purchase_price.taxes_product_id.type_tax_use == 'purchase': price = purchase_price.product_price taxes = purchase_price.taxes_product_id.id or 24 vendor_id = purchase_price.vendor_id.id if delta_time > human_last_update: price = 0 taxes = '' vendor_id = '' if system_last_update > human_last_update: #if purchase_price.taxes_system_id.type_tax_use == 'purchase': price = purchase_price.system_price taxes = purchase_price.taxes_system_id.id or 24 vendor_id = purchase_price.vendor_id.id if delta_time > system_last_update: price = 0 taxes = 24 vendor_id = '' return price, taxes, vendor_id class CheckBomProduct(models.Model): _name = 'check.bom.product' _description = 'Check Product' _order = 'production_id, id' production_id = fields.Many2one( 'mrp.production', string='Bom Reference', required=True, ondelete='cascade', index=True, copy=False, ) product_id = fields.Many2one('product.product', string='Product') quantity = fields.Float(string='Quantity') status = fields.Char(string='Status', compute='_compute_status') code_product = fields.Char(string='Code Product') @api.constrains('production_id') def _check_missing_components(self): for mo in self: required = mo.production_id.move_raw_ids.mapped('product_id') entered = mo.production_id.check_bom_product_lines.mapped('product_id') missing = required - entered # Jika HTML tidak bekerja sama sekali, gunakan format text biasa yang rapi if missing: product_list = "\n- " + "\n- ".join(p.display_name for p in missing) raise UserError( "⚠️ Komponen Wajib Diisi\n\n" "Produk berikut harus ditambahkan:\n" f"{product_list}\n\n" "Silakan lengkapi terlebih dahulu." ) @api.constrains('production_id', 'product_id') def _check_product_bom_validation(self): for record in self: if record.production_id.sale_order and record.production_id.sale_order.state not in ['sale', 'done']: raise UserError(( "SO harus diconfirm terlebih dahulu." )) if not record.production_id or not record.product_id: continue moves = record.production_id.move_raw_ids.filtered( lambda move: move.product_id.id == record.product_id.id ) if not moves: raise UserError(( "The product '%s' tidak ada di operations. " ) % record.product_id.display_name) total_qty_in_moves = sum(moves.mapped('quantity_done')) # Find existing lines for the same product, excluding the current line existing_lines = record.production_id.check_bom_product_lines.filtered( lambda line: line.product_id == record.product_id ) if existing_lines: total_quantity = sum(existing_lines.mapped('quantity')) if total_quantity != total_qty_in_moves: raise UserError(( "Quantity Product '%s' harus sama dengan quantity consumed." ) % (record.product_id.display_name)) else: # Check if the quantity exceeds the allowed total if record.quantity != total_qty_in_moves: raise UserError(( "Quantity Product '%s' harus sama dengan quantity consumed." ) % (record.product_id.display_name)) # Set the quantity to the entered value record.quantity = record.quantity @api.onchange('code_product') def _onchange_code_product(self): if not self.code_product: return if self.production_id.qty_producing == 0: raise UserError("Isi dan Save dahulu Quantity To Produce yang diinginkan!") # Cari product berdasarkan default_code, barcode, atau barcode_box product = self.env['product.product'].search([ '|', ('default_code', '=', self.code_product), '|', ('barcode', '=', self.code_product), ('barcode_box', '=', self.code_product) ], limit=1) if not product: raise UserError("Product tidak ditemukan") # Jika scan barcode_box, set quantity sesuai qty_pcs_box if product.barcode_box == self.code_product: self.product_id = product.id self.quantity = product.qty_pcs_box self.code_product = product.default_code or product.barcode # return { # 'warning': { # 'title': 'Info',8994175025871 # 'message': f'Product box terdeteksi. Quantity di-set ke {product.qty_pcs_box}' # } # } else: # Jika scan biasa self.product_id = product.id self.code_product = product.default_code or product.barcode self.quantity = 1 def unlink(self): # Get all affected pickings before deletion productions = self.mapped('production_id') # Store product_ids that will be deleted deleted_product_ids = self.mapped('product_id') # Perform the deletion result = super(CheckBomProduct, self).unlink() # After deletion, update moves for affected pickings for production in productions: # For products that were completely removed (no remaining check.bom.product lines) remaining_product_ids = production.check_bom_product_lines.mapped('product_id') removed_product_ids = deleted_product_ids - remaining_product_ids # Set quantity_done to 0 for moves of completely removed products moves_to_reset = production.move_raw_ids.filtered( lambda move: move.product_id in removed_product_ids ) for move in moves_to_reset: move.quantity_done = 0.0 production.qty_producing = 0 # Also sync remaining products in case their totals changed self._sync_check_product_to_moves(production) return result @api.depends('quantity') def _compute_status(self): for record in self: moves = record.production_id.move_raw_ids.filtered( lambda move: move.product_id.id == record.product_id.id ) total_qty_in_moves = sum(moves.mapped('product_uom_qty')) if record.quantity < total_qty_in_moves: record.status = 'Pending' else: record.status = 'Done' def create(self, vals): # Create the record record = super(CheckBomProduct, self).create(vals) # Ensure uniqueness after creation if not self.env.context.get('skip_consolidate'): record.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return record def write(self, vals): # Write changes to the record result = super(CheckBomProduct, self).write(vals) # Ensure uniqueness after writing if not self.env.context.get('skip_consolidate'): self.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return result def _sync_check_product_to_moves(self, production): """ Sinkronisasi quantity_done di move_raw_ids dengan total quantity dari check.bom.product berdasarkan product_id. """ for product_id in production.check_bom_product_lines.mapped('product_id'): # Totalkan quantity dari semua baris check.bom.product untuk product_id ini total_quantity = sum( line.quantity for line in production.check_bom_product_lines.filtered(lambda line: line.product_id == product_id) ) # Update quantity_done di move yang relevan moves = production.move_raw_ids.filtered(lambda move: move.product_id == product_id) for move in moves: move.quantity_done = total_quantity def _consolidate_duplicate_lines(self): """ Consolidate duplicate lines with the same product_id under the same production_id and sync the total quantity to related moves. """ for production in self.mapped('production_id'): lines_to_remove = self.env['check.bom.product'] # Recordset untuk menyimpan baris yang akan dihapus product_lines = production.check_bom_product_lines.filtered(lambda line: line.product_id) # Group lines by product_id product_groups = {} for line in product_lines: product_groups.setdefault(line.product_id.id, []).append(line) for product_id, lines in product_groups.items(): if len(lines) > 1: # Consolidate duplicate lines first_line = lines[0] total_quantity = sum(line.quantity for line in lines) # Update the first line's quantity first_line.with_context(skip_consolidate=True).write({'quantity': total_quantity}) # Add the remaining lines to the lines_to_remove recordset lines_to_remove |= self.env['check.bom.product'].browse([line.id for line in lines[1:]]) # Perform unlink after consolidation if lines_to_remove: lines_to_remove.unlink() # Sync total quantities to moves self._sync_check_product_to_moves(production) @api.onchange('product_id', 'quantity') def check_product_validity(self): for record in self: if not record.production_id or not record.product_id: continue # Filter moves related to the selected product moves = record.production_id.move_raw_ids.filtered( lambda move: move.product_id.id == record.product_id.id ) if not moves: raise UserError(( "The product '%s' tidak ada di operations. " ) % record.product_id.display_name) total_qty_in_moves = sum(moves.mapped('quantity_done')) # Find existing lines for the same product, excluding the current line existing_lines = record.production_id.check_bom_product_lines.filtered( lambda line: line.product_id == record.product_id ) if existing_lines: # Get the first existing line first_line = existing_lines[0] # Calculate the total quantity after addition total_quantity = sum(existing_lines.mapped('quantity')) if total_quantity > total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity consumed." ) % (record.product_id.display_name)) else: # Check if the quantity exceeds the allowed total if record.quantity == total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity consumed." ) % (record.product_id.display_name)) # Set the quantity to the entered value record.quantity = record.quantity class ProductionPurchaseMatch(models.Model): _name = 'production.purchase.match' _order = 'production_id, id' production_id = fields.Many2one('mrp.production', string='Ref', required=True, ondelete='cascade', index=True, copy=False) order_id = fields.Many2one('purchase.order', string='Purchase Order') vendor = fields.Char(string='Vendor', compute='_compute_info_po') total = fields.Float(string='Total', compute='_compute_info_po') def _compute_info_po(self): for match in self: match.vendor = match.order_id.partner_id.name match.total = match.order_id.amount_total