from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError import logging from datetime import datetime _logger = logging.getLogger(__name__) # TODO # 1. tracking status dokumen BU [X] # 2. ganti nama dokumen # 3. Tracking ketika create dokumen [X] # 4. Tracking ketika ganti field operations, date approval (sales, finance, logistic) [X] # 5. Ganti proses approval ke Sales, Finance, Logistic [X] # 6. Make sure bu pick dan out tidak bisa diedit ketika ort dan srt blm done # 7. change approval class TukarGuling(models.Model): _name = 'tukar.guling' _description = 'Pengajuan Retur SO' _order = 'date desc, id desc' _rec_name = 'name' _inherit = ['mail.thread', 'mail.activity.mixin'] partner_id = fields.Many2one('res.partner', string='Customer', readonly=True) origin = fields.Char(string='Origin SO') if_so = fields.Boolean('Is SO', default=True) if_po = fields.Boolean('Is PO', default=False) real_shipping_id = fields.Many2one('res.partner', string='Shipping Address') refund_id = fields.Many2one( 'refund.sale.order', string="Refund Ref" ) picking_ids = fields.One2many( 'stock.picking', 'tukar_guling_id', string='Transfers' ) origin_so = fields.Many2one('sale.order', string='Origin SO', compute='_compute_origin_so') name = fields.Char('Number', required=True, copy=False, readonly=True, default='New') date = fields.Datetime('Date', default=fields.Datetime.now, required=True) operations = fields.Many2one( 'stock.picking', string='Operations', domain=[ '|', # BU/OUT '&', ('picking_type_id.id', '=', 29), ('state', '=', 'done'), '&', '&', ('picking_type_id.id', '=', 30), ('state', '=', 'done'), ('linked_manual_bu_out', '!=', 'done'), ], help='Nomor BU/OUT atau BU/PICK', tracking=3, required=True ) ba_num = fields.Text('Nomor BA') notes = fields.Text('Notes') return_type = fields.Selection(String='Return Type', selection=[ ('tukar_guling', 'Tukar Guling'), # -> barang yang sama ('retur_so', 'Retur SO')], required=True, tracking=3, help='Retur SO (ORT-SRT),\n Tukar Guling (ORT-SRT-PICK-OUT)') state = fields.Selection(string='Status', selection=[ ('draft', 'Draft'), ('approval_sales', ' Approval Sales'), ('approval_finance', 'Approval Finance'), ('approval_logistic', 'Approval Logistic'), ('approved', 'Waiting for Operations'), ('done', 'Done'), ('cancel', 'Canceled') ], default='draft', tracking=True, required=True) line_ids = fields.One2many('tukar.guling.line', 'tukar_guling_id', string='Product Lines') mapping_koli_ids = fields.One2many('tukar.guling.mapping.koli', 'tukar_guling_id', string='Mapping Koli') date_finance = fields.Datetime('Approved Date Finance', tracking=3, readonly=True) date_sales = fields.Datetime('Approved Date Sales', tracking=3, readonly=True) date_logistic = fields.Datetime('Approved Date Logistic', tracking=3, readonly=True) val_inv_opt = fields.Selection([ ('tanpa_cancel', 'Tanpa Cancel Invoice'), ('cancel_invoice', 'Cancel Invoice'), ], tracking=3, string='Invoice Option') is_has_invoice = fields.Boolean('Has Invoice?', compute='_compute_is_has_invoice', readonly=True, default=False) invoice_id = fields.Many2many('account.move', string='Invoice Ref', readonly=True) @api.depends('origin', 'operations') def _compute_origin_so(self): for rec in self: rec.origin_so = False origin_str = rec.origin or rec.operations.origin if origin_str: so = self.env['sale.order'].search([('name', '=', origin_str)], limit=1) rec.origin_so = so.id if so else False @api.depends('origin', 'origin_so', 'partner_id', 'line_ids.product_id', 'invoice_id', 'operations') def _compute_is_has_invoice(self): Move = self.env['account.move'] for rec in self: invoices = rec.invoice_id if not invoices: product_ids = rec.line_ids.mapped('product_id').ids if product_ids: domain = [ ('move_type', 'in', ['out_invoice', 'out_refund', 'in_invoice']), ('state', 'not in', ['draft', 'cancel']), ('invoice_line_ids.product_id', 'in', product_ids), ] # if rec.partner_id: # domain.append( # ('partner_id.commercial_partner_id', '=', rec.partner_id.commercial_partner_id.id) # ) extra = [] if rec.origin: extra.append(('invoice_origin', 'ilike', rec.origin)) if rec.origin_so: extra.append(('invoice_line_ids.sale_line_ids.order_id', '=', rec.origin_so.id)) if extra: domain += ['|'] * (len(extra) - 1) + extra invoices = Move.search(domain).with_context(active_test=False) if invoices: rec.invoice_id = [(6, 0, invoices.ids)] rec.is_has_invoice = bool(invoices) def set_opt(self): if not self.val_inv_opt and self.is_has_invoice == True: raise UserError("Kalau sudah ada invoice Return Invoice Option harus diisi!") for rec in self: if rec.val_inv_opt == 'cancel_invoice' and self.is_has_invoice == True and rec.invoice_id.state != 'cancel': raise UserError("Tidak bisa mengubah Return karena sudah ada invoice dan belum di cancel.") elif rec.val_inv_opt == 'tanpa_cancel' and self.is_has_invoice == True: continue # @api.onchange('operations') # def get_partner_id(self): # if self.operations and self.operations.partner_id and self.operations.partner_id.name: # self.partner_id == self.operations.partner_id.name def _check_mapping_koli(self): for record in self: if record.operations.picking_type_id.id == 29: # Only for BU/OUT if not record.mapping_koli_ids: raise UserError("❌ Mapping Koli belum diisi") # Calculate totals total_mapping_qty = sum(int(mapping.qty_return) for mapping in record.mapping_koli_ids) total_line_qty = sum(int(line.product_uom_qty) for line in record.line_ids) if total_mapping_qty != total_line_qty: raise UserError( "❌ Total quantity return di mapping koli (%d) tidak sama dengan quantity retur product lines (%d)" % (total_mapping_qty, total_line_qty) ) else: _logger.info("✅ Qty mapping koli sesuai dengan product lines") @api.onchange('operations') def _onchange_operations(self): """Auto-populate lines ketika operations dipilih""" if self.operations.picking_type_id.id not in [29, 30]: raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") for rec in self: if rec.operations and rec.operations.picking_type_id.id == 30: rec.return_type = 'retur_so' if self.operations: from_return_picking = self.env.context.get('from_return_picking', False) or \ self.env.context.get('default_line_ids', False) if self.line_ids and from_return_picking: # Hanya update origin, jangan ubah lines _logger.info("📌 Menggunakan product lines dari return wizard, tidak populate ulang.") # 🚀 Tapi tetap populate mapping koli jika BU/OUT if self.operations.picking_type_id.id == 29: mapping_koli_data = [] sequence = 10 tg_product_ids = self.line_ids.mapped('product_id.id') for koli_line in self.operations.konfirm_koli_lines: for move in koli_line.pick_id.move_line_ids_without_package: if move.product_id.id in tg_product_ids: mapping_koli_data.append((0, 0, { 'sequence': sequence, 'pick_id': koli_line.pick_id.id, 'product_id': move.product_id.id, 'qty_done': move.qty_done, 'qty_return': 0 })) sequence += 10 self.mapping_koli_ids = mapping_koli_data _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines (from return wizard)") return # keluar supaya tidak populate ulang lines # Clear existing lines hanya jika tidak dari return picking self.line_ids = [(5, 0, 0)] self.mapping_koli_ids = [(5, 0, 0)] # Clear existing mapping koli juga # Set origin dari operations if self.operations.origin: self.origin = self.operations.origin self.origin_so = self.operations.group_id.id # Auto-populate lines dari move_ids operations lines_data = [] sequence = 10 # Untuk Odoo 14, gunakan move_ids_without_package atau move_lines moves_to_check = [] if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package: moves_to_check = self.operations.move_ids_without_package elif hasattr(self.operations, 'move_lines') and self.operations.move_lines: moves_to_check = self.operations.move_lines # Collect product data product_data = {} for move in moves_to_check: if move.product_id and move.product_uom_qty > 0: product_id = move.product_id.id if product_id not in product_data: product_data[product_id] = { 'product': move.product_id, 'qty': move.product_uom_qty, 'uom': move.product_uom.id, 'name': move.name or move.product_id.display_name } # Buat lines_data for product_id, data in product_data.items(): lines_data.append((0, 0, { 'sequence': sequence, 'product_id': product_id, 'product_uom_qty': data['qty'], 'product_uom': data['uom'], 'name': data['name'], })) sequence += 10 if lines_data: self.line_ids = lines_data _logger.info(f"✅ Created {len(lines_data)} product lines") # Prepare mapping koli jika BU/OUT mapping_koli_data = [] sequence = 10 if self.operations.picking_type_id.id == 29: tg_product_ids = [p for p in product_data] for koli_line in self.operations.konfirm_koli_lines: for move in koli_line.pick_id.move_line_ids_without_package: if move.product_id.id in tg_product_ids: mapping_koli_data.append((0, 0, { 'sequence': sequence, 'pick_id': koli_line.pick_id.id, 'product_id': move.product_id.id, 'qty_done': move.qty_done })) sequence += 10 if mapping_koli_data: self.mapping_koli_ids = mapping_koli_data _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines") else: _logger.info("⚠️ No mapping koli lines created") else: _logger.info("⚠️ No product lines created - no valid moves found") else: from_return_picking = self.env.context.get('from_return_picking', False) or \ self.env.context.get('default_line_ids', False) if not from_return_picking: self.line_ids = [(5, 0, 0)] self.mapping_koli_ids = [(5, 0, 0)] self.origin = False def action_populate_lines(self): """Manual button untuk populate lines - sebagai alternatif""" self.ensure_one() if not self.operations: raise UserError("Pilih BU/OUT atau BU/PICK terlebih dahulu!") # Clear existing lines self.line_ids = [(5, 0, 0)] lines_data = [] sequence = 10 # Ambil semua stock moves dari operations for move in self.operations.move_ids: if move.product_uom_qty > 0: lines_data.append((0, 0, { 'sequence': sequence, 'product_id': move.product_id.id, 'product_uom_qty': move.product_uom_qty, 'product_uom': move.product_uom.id, 'name': move.name or move.product_id.display_name, })) sequence += 10 if lines_data: self.line_ids = lines_data else: raise UserError("Tidak ditemukan barang di BU/OUT yang dipilih!") @api.constrains('return_type', 'operations') def _check_required_bu_fields(self): for record in self: if record.return_type in ['retur_so', 'tukar_guling'] and not record.operations: raise ValidationError("Operations harus diisi") @api.constrains('line_ids', 'state') def _check_product_lines(self): """Constraint: Product lines harus ada jika state bukan draft""" for record in self: if record.state in ('approval_sales', 'approval_logistic', 'approval_finance', 'approved', 'done') and not record.line_ids: raise ValidationError("Product lines harus diisi sebelum submit atau approve!") def _validate_product_lines(self): """Helper method untuk validasi product lines""" self.ensure_one() # Check ada product lines if not self.line_ids: raise UserError("Belum ada product lines yang ditambahkan!") # Check product sudah diisi empty_lines = self.line_ids.filtered(lambda line: not line.product_id) if empty_lines: raise UserError("Ada product lines yang belum diisi productnya!") # Check quantity > 0 zero_qty_lines = self.line_ids.filtered(lambda line: line.product_uom_qty <= 0) if zero_qty_lines: raise UserError("Quantity product tidak boleh kosong atau 0!") return True # def _is_already_returned(self, picking): # return self.env['stock.picking'].search_count([ # ('origin', '=', 'Return of %s' % picking.name), # ('state', '!=', 'cancel') # ]) > 0 # def _check_invoice_on_retur_so(self): # for record in self: # if record.return_type == 'retur_so' and record.origin: # invoices = self.env['account.move'].search([ # ('invoice_origin', 'ilike', record.origin), # ('state', 'not in', ['draft', 'cancel']) # ]) # if invoices: # raise ValidationError( # _("Tidak bisa memilih Return Type 'Retur SO' karena dokumen %s sudah dibuat invoice.") % record.origin # ) @api.model def create(self, vals): if not vals.get('name') or vals['name'] == 'New': vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling') if vals.get('operations'): picking = self.env['stock.picking'].browse(vals['operations']) if picking.origin: vals['origin'] = picking.origin # Find matching SO so = self.env['sale.order'].search([('name', '=', picking.origin)], limit=1) if so: vals['origin_so'] = so.id if picking.partner_id: vals['partner_id'] = picking.partner_id.id res = super(TukarGuling, self).create(vals) res.message_post(body=_("CCM Created By %s") % self.env.user.name) return res def copy(self, default=None): if default is None: default = {} # Generate new sequence untuk duplicate sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling')], limit=1) if sequence: default['name'] = sequence.next_by_id() else: default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling') or 'copy' default.update({ 'state': 'draft', 'date': fields.Datetime.now(), }) new_record = super(TukarGuling, self).copy(default) # Re-sequence lines if new_record.line_ids: for i, line in enumerate(new_record.line_ids): line.sequence = (i + 1) * 10 return new_record def write(self, vals): self.ensure_one() if self.operations.picking_type_id.id not in [29, 30]: raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") # self._check_invoice_on_retur_so() operasi = self.operations.picking_type_id.id tipe = self.return_type pp = vals.get('return_type', tipe) if not self.operations: raise UserError("Operations harus diisi!") if not self.return_type: raise UserError("Return Type harus diisi!") # if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': # raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") if operasi == 30 and pp == 'tukar_guling': raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") # else: # _logger.info("hehhe") if 'operations' in vals and not vals.get('origin'): picking = self.env['stock.picking'].browse(vals['operations']) if picking.origin: vals['origin'] = picking.origin return super(TukarGuling, self).write(vals) def unlink(self): # if self.state == 'done': # raise UserError ("Tidak Boleh delete ketika sudahh done") for record in self: if record.state in [ 'approved', 'done', 'approval_logistic', 'approval_finance', 'approval_sales']: raise UserError( "Tidak bisa hapus pengajuan jika sudah Proses Approval, set ke draft terlebih dahulu atau cancel jika ingin menghapus") ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'approved') for picking in ongoing_bu: picking.action_cancel() return super(TukarGuling, self).unlink() def action_view_picking(self): self.ensure_one() # picking_origin = f"Return of {self.operations.name}" returs = self.env['stock.picking'].search([ ('tukar_guling_id', '=', self.id), ]) if not returs: raise UserError("Doc Retrun Not Found") return { 'type': 'ir.actions.act_window', 'name': 'Delivery Pengajuan Retur SO', 'res_model': 'stock.picking', 'view_mode': 'tree,form', 'domain': [('id', 'in', returs.ids)], 'target': 'current', } def action_draft(self): """Reset to draft state""" for record in self: if record.state == 'cancel': record.write({'state': 'draft'}) else: raise UserError("Hanya record yang di-cancel yang bisa dikembalikan ke draft") def _check_not_allow_tukar_guling_on_bu_pick(self, return_type=None): operasi = self.operations.picking_type_id.id tipe = return_type or self.return_type if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") if operasi == 30 and tipe == 'tukar_guling': raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") def action_submit(self): self.ensure_one() self._check_not_allow_tukar_guling_on_bu_pick() # existing_tukar_guling = self.env['tukar.guling'].search([ # ('operations', '=', self.operations.id), # ('id', '!=', self.id), # ('state', '!=', 'cancel'), # ], limit=1) # # if existing_tukar_guling: # raise UserError("BU ini sudah pernah diretur oleh dokumen %s." % existing_tukar_guling.name) picking = self.operations if picking.picking_type_id.id == 30 and self.return_type == 'tukar_guling': raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") if picking.picking_type_id.id == 29: if picking.state != 'done': raise UserError("BU/OUT belum Done!") elif picking.picking_type_id.id == 30: linked_bu_out = picking.linked_manual_bu_out if linked_bu_out and linked_bu_out.state == 'done': raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT suda Done!") # if self._is_already_returned(self.operations): # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") if self.operations.picking_type_id.id == 29: # Cek apakah ada BU/PICK di origin origin = self.operations.origin has_bu_pick = self.env['stock.picking'].search_count([ ('origin', '=', origin), ('picking_type_id', '=', 30), ('state', '!=', 'cancel') ]) > 0 if has_bu_pick: for line in self.line_ids: mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id) total_qty = sum(l.qty_return for l in mapping_lines) if total_qty != line.product_uom_qty: raise UserError( _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name ) # self._check_invoice_on_retur_so() self._validate_product_lines() if self.state != 'draft': raise UserError("Submit hanya bisa dilakukan dari Draft.") self.state = 'approval_sales' def update_doc_state(self): bu_pick = self.env['stock.picking'].search([ ('origin', '=', self.operations.origin), ('name', 'ilike', 'BU/PICK'), ]) # OUT tukar guling if self.operations.picking_type_id.id == 29 and self.return_type == 'tukar_guling': total_out = self.env['stock.picking'].search_count([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 29), ]) done_out = self.env['stock.picking'].search_count([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 29), ('state', '=', 'done'), ]) if self.state == 'approved' and total_out > 0 and done_out == total_out: self.state = 'done' #SO Lama (gk ada bu pick) elif self.operations.picking_type_id.id == 29 and self.return_type == 'retur_so' and not bu_pick: # so_lama = self.env['sale.order'].search([ # ('name', '=', self.operations.origin), # ('state', '=', 'done'), # ('group_id.name', '=', self.operations.origin) # ]) total_ort = self.env['stock.picking'].search_count([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 74), ]) done_srt = self.env['stock.picking'].search([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 73), ('state', '=', 'done') ]) if self.state == 'approved' and total_ort == 0 and done_srt and not bu_pick: self.state = 'done' # OUT retur SO elif self.operations.picking_type_id.id == 29 and self.return_type == 'retur_so': total_ort = self.env['stock.picking'].search_count([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 74), ]) done_ort = self.env['stock.picking'].search_count([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 74), ('state', '=', 'done'), ]) if self.state == 'approved' and total_ort > 0 and done_ort == total_ort: self.state = 'done' # PICK revisi SO elif self.operations.picking_type_id.id == 30 and self.return_type == 'retur_so': done_ort = self.env['stock.picking'].search([ ('tukar_guling_id', '=', self.id), ('picking_type_id', '=', 74), ('state', '=', 'done'), ]) if self.state == 'approved' and done_ort: self.state = 'done' else: raise UserError("Tidak bisa menentukan jenis retur.") def action_approve(self): self.ensure_one() self._validate_product_lines() # self._check_invoice_on_retur_so() self._check_not_allow_tukar_guling_on_bu_pick() operasi = self.operations.picking_type_id.id tipe = self.return_type if self.operations.picking_type_id.id == 29: # Cek apakah ada BU/PICK di origin origin = self.operations.origin has_bu_pick = self.env['stock.picking'].search_count([ ('origin', '=', origin), ('picking_type_id', '=', 30), ('state', '!=', 'cancel') ]) > 0 if has_bu_pick: for line in self.line_ids: mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id) total_qty = sum(l.qty_return for l in mapping_lines) if total_qty != line.product_uom_qty: raise UserError( _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name ) if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") if operasi == 30 and tipe == 'tukar_guling': raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") # else: # _logger.info("hehhe") if not self.operations: raise UserError("Operations harus diisi!") if not self.return_type: raise UserError("Return Type harus diisi!") now = datetime.now() # Cek hak akses berdasarkan state for rec in self: if rec.state == 'approval_sales': if not rec.env.user.has_group('indoteknik_custom.group_role_sales'): raise UserError("Hanya Sales Manager yang boleh approve tahap ini.") rec.state = 'approval_finance' rec.date_sales = now elif rec.state == 'approval_finance': if not rec.env.user.has_group('indoteknik_custom.group_role_fat'): raise UserError("Hanya Finance Manager yang boleh approve tahap ini.") # rec._check_invoice_on_retur_so() rec.set_opt() rec.state = 'approval_logistic' rec.date_finance = now elif rec.state == 'approval_logistic': if not rec.env.user.has_group('indoteknik_custom.group_role_logistic'): raise UserError("Hanya Logistic Manager yang boleh approve tahap ini.") rec.state = 'approved' rec._create_pickings() rec.date_logistic = now else: raise UserError("Status ini tidak bisa di-approve.") def action_cancel(self): self.ensure_one() # picking = self.env['stock.picking'] user = self.env.user if not ( user.has_group('indoteknik_custom.group_role_sales') or user.has_group('indoteknik_custom.group_role_fat') or user.has_group('indoteknik_custom.group_role_logistic') ): raise UserWarning('Anda tidak memiliki Permission untuk cancel document') bu_done = self.picking_ids.filtered(lambda p: p.state == 'done') if bu_done: raise UserError("Dokuemen BU sudah Done, tidak bisa di cancel") ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done') for picking in ongoing_bu: picking.action_cancel() # if self.state == 'done': # raise UserError("Tidak bisa cancel jika sudah done") self.state = 'cancel' def _create_pickings(self): _logger.info("🛠 Starting _create_pickings()") def _force_locations(picking, from_loc, to_loc): picking.write({ 'location_id': from_loc, 'location_dest_id': to_loc, }) for move in picking.move_lines: move.write({ 'location_id': from_loc, 'location_dest_id': to_loc, }) for move_line in move.move_line_ids: move_line.write({ 'location_id': from_loc, 'location_dest_id': to_loc, }) for record in self: if not record.operations: raise UserError("BU/OUT dari field operations tidak ditemukan.") bu_out = record.operations mapping_koli = record.mapping_koli_ids # Constants PARTNER_LOCATION_ID = 5 BU_OUTPUT_LOCATION_ID = 60 BU_STOCK_LOCATION_ID = 57 # Picking Types srt_type = self.env['stock.picking.type'].browse(73) ort_type = self.env['stock.picking.type'].browse(74) bu_pick_type = self.env['stock.picking.type'].browse(30) bu_out_type = self.env['stock.picking.type'].browse(29) created_returns = [] ### ======== SRT dari BU/OUT ========= srt_return_lines = [] if mapping_koli and record.operations.picking_type_id.id == 29: for prod in mapping_koli.mapped('product_id'): qty_total = sum(mk.qty_return for mk in mapping_koli.filtered(lambda m: m.product_id == prod)) move = bu_out.move_lines.filtered(lambda m: m.product_id == prod) if not move: raise UserError(f"Move BU/OUT tidak ditemukan untuk produk {prod.display_name}") srt_return_lines.append((0, 0, { 'product_id': prod.id, 'quantity': qty_total, 'move_id': move.id, })) _logger.info(f"📟 SRT line: {prod.display_name} | qty={qty_total}") elif not mapping_koli and record.operations.picking_type_id.id == 29: for line in record.line_ids: move = bu_out.move_lines.filtered(lambda m: m.product_id == line.product_id) if not move: raise UserError(f"Move BU/OUT tidak ditemukan untuk produk {line.product_id.display_name}") srt_return_lines.append((0, 0, { 'product_id': line.product_id.id, 'quantity': line.product_uom_qty, 'move_id': move.id, })) _logger.info( f"📟 SRT line (fallback line_ids): {line.product_id.display_name} | qty={line.product_uom_qty}") srt_picking = None if srt_return_lines: # Tentukan tujuan lokasi berdasarkan ada/tidaknya mapping_koli dest_location_id = BU_OUTPUT_LOCATION_ID if mapping_koli else BU_STOCK_LOCATION_ID srt_wizard = self.env['stock.return.picking'].with_context({ 'active_id': bu_out.id, 'default_location_id': PARTNER_LOCATION_ID, 'default_location_dest_id': dest_location_id, 'from_ui': False, }).create({ 'picking_id': bu_out.id, 'location_id': PARTNER_LOCATION_ID, 'product_return_moves': srt_return_lines }) srt_vals = srt_wizard.create_returns() srt_picking = self.env['stock.picking'].browse(srt_vals['res_id']) _force_locations(srt_picking, PARTNER_LOCATION_ID, dest_location_id) srt_picking.write({ 'group_id': bu_out.group_id.id, 'tukar_guling_id': record.id, 'sale_order': record.origin }) created_returns.append(srt_picking) _logger.info(f"✅ SRT created: {srt_picking.name}") record.message_post( body=f"📦 {srt_picking.name} created by {self.env.user.name} (state: {srt_picking.state})") ### ======== ORT dari BU/PICK ========= ort_pickings = [] is_retur_from_bu_pick = record.operations.picking_type_id.id == 30 picks_to_return = [record.operations] if is_retur_from_bu_pick else mapping_koli.mapped('pick_id') for pick in picks_to_return: ort_return_lines = [] if is_retur_from_bu_pick: for line in record.line_ids: move = pick.move_lines.filtered(lambda m: m.product_id == line.product_id) if not move: raise UserError( f"Move tidak ditemukan di BU/PICK {pick.name} untuk {line.product_id.display_name}") ort_return_lines.append((0, 0, { 'product_id': line.product_id.id, 'quantity': line.product_uom_qty, 'move_id': move.id, })) _logger.info( f"📟 ORT (BU/PICK langsung) | {pick.name} | {line.product_id.display_name} | qty={line.product_uom_qty}") else: for mk in mapping_koli.filtered(lambda m: m.pick_id == pick): move = pick.move_lines.filtered(lambda m: m.product_id == mk.product_id) if not move: raise UserError( f"Move tidak ditemukan di BU/PICK {pick.name} untuk {mk.product_id.display_name}") ort_return_lines.append((0, 0, { 'product_id': mk.product_id.id, 'quantity': mk.qty_return, 'move_id': move.id, })) _logger.info( f"📟 ORT (mapping koli) | {pick.name} | {mk.product_id.display_name} | qty={mk.qty_return}") if ort_return_lines: ort_wizard = self.env['stock.return.picking'].with_context({ 'active_id': pick.id, 'default_location_id': BU_OUTPUT_LOCATION_ID, 'default_location_dest_id': BU_STOCK_LOCATION_ID, 'from_ui': False, }).create({ 'picking_id': pick.id, 'location_id': BU_OUTPUT_LOCATION_ID, 'product_return_moves': ort_return_lines }) ort_vals = ort_wizard.create_returns() ort_picking = self.env['stock.picking'].browse(ort_vals['res_id']) _force_locations(ort_picking, BU_OUTPUT_LOCATION_ID, BU_STOCK_LOCATION_ID) ort_picking.write({ 'group_id': bu_out.group_id.id, 'tukar_guling_id': record.id, 'sale_order': record.origin }) created_returns.append(ort_picking) ort_pickings.append(ort_picking) _logger.info(f"✅ ORT created: {ort_picking.name}") record.message_post( body=f"📦 {ort_picking.name} created by {self.env.user.name} (state: {ort_picking.state})") ### ======== BU/PICK & BU/OUT Baru dari SRT/ORT ======== if record.return_type == 'tukar_guling': # BU/PICK Baru dari ORT for ort_p in ort_pickings: return_lines = [] for move in ort_p.move_lines: if move.product_uom_qty > 0: return_lines.append((0, 0, { 'product_id': move.product_id.id, 'quantity': move.product_uom_qty, 'move_id': move.id })) _logger.info( f"🔁 BU/PICK baru dari ORT {ort_p.name} | {move.product_id.display_name} | qty={move.product_uom_qty}") if not return_lines: _logger.warning(f"❌ Tidak ada qty > 0 di ORT {ort_p.name}, dilewati.") continue bu_pick_wizard = self.env['stock.return.picking'].with_context({ 'active_id': ort_p.id, 'default_location_id': BU_STOCK_LOCATION_ID, 'default_location_dest_id': BU_OUTPUT_LOCATION_ID, 'from_ui': False, }).create({ 'picking_id': ort_p.id, 'location_id': BU_STOCK_LOCATION_ID, 'product_return_moves': return_lines }) bu_pick_vals = bu_pick_wizard.create_returns() new_pick = self.env['stock.picking'].browse(bu_pick_vals['res_id']) _force_locations(new_pick, BU_STOCK_LOCATION_ID, BU_OUTPUT_LOCATION_ID) new_pick.write({ 'group_id': bu_out.group_id.id, 'tukar_guling_id': record.id, 'sale_order': record.origin }) new_pick.action_assign() new_pick.action_confirm() created_returns.append(new_pick) _logger.info(f"✅ BU/PICK Baru dari ORT created: {new_pick.name}") record.message_post( body=f"📦 {new_pick.name} created by {self.env.user.name} (state: {new_pick.state})") # BU/OUT Baru dari SRT if srt_picking: return_lines = [] for move in srt_picking.move_lines: if move.product_uom_qty > 0: return_lines.append((0, 0, { 'product_id': move.product_id.id, 'quantity': move.product_uom_qty, 'move_id': move.id, })) _logger.info( f"🔁 BU/OUT baru dari SRT | {move.product_id.display_name} | qty={move.product_uom_qty}") bu_out_wizard = self.env['stock.return.picking'].with_context({ 'active_id': srt_picking.id, 'default_location_id': BU_OUTPUT_LOCATION_ID, 'default_location_dest_id': PARTNER_LOCATION_ID, 'from_ui': False, }).create({ 'picking_id': srt_picking.id, 'location_id': BU_OUTPUT_LOCATION_ID, 'product_return_moves': return_lines }) bu_out_vals = bu_out_wizard.create_returns() new_out = self.env['stock.picking'].browse(bu_out_vals['res_id']) _force_locations(new_out, BU_OUTPUT_LOCATION_ID, PARTNER_LOCATION_ID) new_out.write({ 'group_id': bu_out.group_id.id, 'tukar_guling_id': record.id, 'sale_order': record.origin }) created_returns.append(new_out) _logger.info(f"✅ BU/OUT Baru dari SRT created: {new_out.name}") record.message_post( body=f"📦 {new_out.name} created by {self.env.user.name} (state: {new_out.state})") if not created_returns: raise UserError("Tidak ada dokumen retur berhasil dibuat.") _logger.info("✅ Finished _create_pickings(). Created %s returns: %s", len(created_returns), ", ".join([p.name for p in created_returns])) class TukarGulingLine(models.Model): _name = 'tukar.guling.line' _description = 'Tukar Guling Line' _order = 'sequence, id' sequence = fields.Integer('Sequence', default=10, copy=False) tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling', required=True, ondelete='cascade') product_id = fields.Many2one('product.product', string='Product', required=True) product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0) product_uom = fields.Many2one('uom.uom', string='Unit of Measure') name = fields.Text('Description') @api.constrains('product_uom_qty') def _check_qty_change_allowed(self): for rec in self: if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: raise ValidationError("Tidak bisa mengubah Quantity karena status dokumen bukan Draft atau Cancel.") def unlink(self): for rec in self: if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.") return super(TukarGulingLine, self).unlink() @api.model_create_multi def create(self, vals_list): """Override create to auto-assign sequence""" for vals in vals_list: if 'sequence' not in vals or vals.get('sequence', 0) <= 0: # Get max sequence untuk tukar_guling yang sama tukar_guling_id = vals.get('tukar_guling_id') if tukar_guling_id: max_seq = self.search([ ('tukar_guling_id', '=', tukar_guling_id) ], order='sequence desc', limit=1) vals['sequence'] = (max_seq.sequence or 0) + 10 else: vals['sequence'] = 10 return super(TukarGulingLine, self).create(vals_list) @api.onchange('product_id') def _onchange_product_id(self): if self.product_id: self.name = self.product_id.display_name self.product_uom = self.product_id.uom_id class StockPicking(models.Model): _inherit = 'stock.picking' tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling Ref') def button_validate(self): res = super(StockPicking, self).button_validate() for picking in self: if picking.tukar_guling_id: message = _( "📦 %s Validated by %s Status Changed %s at %s." ) % ( picking.name, # picking.picking_type_id.name, picking.env.user.name, picking.state, fields.Datetime.now().strftime("%d/%m/%Y %H:%M") ) picking.tukar_guling_id.message_post(body=message) return res class TukarGulingMappingKoli(models.Model): _name = 'tukar.guling.mapping.koli' _description = 'Mapping Koli di Tukar Guling' tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling') pick_id = fields.Many2one('stock.picking', string='BU PICK') product_id = fields.Many2one('product.product', string='Product') qty_done = fields.Float(string='Qty Done BU PICK') qty_return = fields.Float(string='Qty diretur') sequence = fields.Integer(string='Sequence', default=10) @api.constrains('qty_return') def _check_qty_return_editable(self): for rec in self: if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: raise ValidationError("Tidak Bisa ubah qty retur jika status sudah approval atau done.") def unlink(self): for rec in self: if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: raise UserError("Tidak bisa menghapus Mapping Koli karena status Tukar Guling bukan Draft atau Cancel.") return super(TukarGulingMappingKoli, self).unlink()