diff options
Diffstat (limited to 'indoteknik_custom/models/tukar_guling_po.py')
| -rw-r--r-- | indoteknik_custom/models/tukar_guling_po.py | 662 |
1 files changed, 662 insertions, 0 deletions
diff --git a/indoteknik_custom/models/tukar_guling_po.py b/indoteknik_custom/models/tukar_guling_po.py new file mode 100644 index 00000000..7c9680f8 --- /dev/null +++ b/indoteknik_custom/models/tukar_guling_po.py @@ -0,0 +1,662 @@ +from email.policy import default + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +import logging +from datetime import datetime + +_logger = logging.getLogger(__name__) + + +class TukarGulingPO(models.Model): + _name = 'tukar.guling.po' + _description = 'Tukar Guling PO' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + vendor_id = fields.Many2one('res.partner', string='Vendor Name', readonly=True) + origin = fields.Char(string='Origin PO') + is_po = fields.Boolean('Is PO', default=True) + is_so = fields.Boolean('Is SO', default=False) + name = fields.Char(string='Name', required=True) + po_picking_ids = fields.One2many( + 'stock.picking', + 'tukar_guling_po_id', + string='Picking Reference', + ) + name = fields.Char('Number', required=True, copy=False, readonly=True, default='New') + date = fields.Datetime('Date', default=fields.Datetime.now, required=True) + date_purchase = fields.Datetime('Date Approve Purchase', readonly=True) + date_finance = fields.Datetime('Date Approve Finance', readonly=True) + date_logistic = fields.Datetime('Date Approve Logistic', readonly=True) + operations = fields.Many2one( + 'stock.picking', + string='Operations', + domain=[ + ('picking_type_id.id', 'in', [75, 28]), + ('state', '=', 'done') + ], help='Nomor BU INPUT atau BU PUT', tracking=3 + ) + ba_num = fields.Char('Nomor BA', tracking=3) + return_type = fields.Selection([ + ('revisi_po', 'Revisi PO'), + ('tukar_guling', 'Tukar Guling'), + ], string='Return Type', required=True, tracking=3) + notes = fields.Text('Notes', tracking=3) + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade') + line_ids = fields.One2many('tukar.guling.line.po', 'tukar_guling_po_id', string='Product Lines', tracking=3) + state = fields.Selection([ + ('draft', 'Draft'), + ('approval_purchase', 'Approval Purchasing'), + ('approval_finance', 'Approval Finance'), + ('approval_logistic', 'Approval Logistic'), + ('done', 'Done'), + ('cancel', 'Cancel'), + ], string='Status', default='draft', tracking=3) + + @api.model + def create(self, vals): + # Generate sequence number + # ven_name = self.origin.search([('name', 'ilike', vals['origin'])]) + if not vals.get('name') or vals['name'] == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po') + + # Auto-fill origin from operations + if not vals.get('origin') and vals.get('operations'): + picking = self.env['stock.picking'].browse(vals['operations']) + if picking.origin: + vals['origin'] = picking.origin + if picking.group_id.id: + vals['vendor_id'] = picking.group_id.partner_id.id + + res = super(TukarGulingPO, self).create(vals) + res.message_post(body=_("VCM Created By %s") % self.env.user.name) + + return res + + @api.constrains('return_type', 'operations') + def _check_bill_on_revisi_po(self): + for record in self: + if record.return_type == 'revisi_po' and record.origin: + bills = self.env['account.move'].search([ + ('invoice_origin', 'ilike', record.origin), + ('move_type', '=', 'in_invoice'), # hanya vendor bill + ('state', 'not in', ['draft', 'cancel']) + ]) + if bills: + raise ValidationError( + _("Tidak bisa memilih Return Type 'Revisi PO' karena PO %s sudah dibuat vendor bill.") % record.origin + ) + + @api.onchange('operations') + def _onchange_operations(self): + """Auto-populate lines ketika operations dipilih""" + if self.operations.picking_type_id.id not in [75, 28]: + raise UserError("❌ Picking type harus BU/INPUT atau BU/PUT") + + 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 + if self.operations.origin: + self.origin = self.operations.origin + return + + if from_return_picking: + # Gunakan qty dari context (stock return wizard) + default_lines = self.env.context.get('default_line_ids', []) + parsed_lines = [] + sequence = 10 + for line_data in default_lines: + if isinstance(line_data, (list, tuple)) and len(line_data) == 3: + vals = line_data[2] + parsed_lines.append((0, 0, { + 'sequence': sequence, + 'product_id': vals.get('product_id'), + 'product_uom_qty': vals.get('quantity'), + 'product_uom': self.env['product.product'].browse(vals.get('product_id')).uom_id.id, + 'name': self.env['product.product'].browse(vals.get('product_id')).display_name, + })) + sequence += 10 + + self.line_ids = parsed_lines + return + else: + self.line_ids = [(5, 0, 0)] + + # Set origin dari operations + if self.operations.origin: + self.origin = self.operations.origin + + # 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 = [] + + # 1. move_ids_without_package (standard di Odoo 14) + if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package: + moves_to_check = self.operations.move_ids_without_package + # 2. move_lines (backup untuk versi lama) + elif hasattr(self.operations, 'move_lines') and self.operations.move_lines: + moves_to_check = self.operations.move_lines + + for move in moves_to_check: + _logger.info( + f"Move: {move.name}, Product: {move.product_id.name if move.product_id else 'No Product'}, Qty: {move.product_uom_qty}, State: {move.state}") + + # Ambil semua move yang ada quantity + if move.product_id and 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 + _logger.info(f"Created {len(lines_data)} lines") + else: + _logger.info("No lines created - no valid moves found") + else: + # Clear lines jika operations dikosongkan, kecuali dari return picking + 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.origin = False + + def _check_not_allow_tukar_guling_on_bu_input(self, return_type=None): + operasi = self.operations.picking_type_id.id + tipe = return_type or self.return_type + + if operasi == 28 and self.operations.linked_manual_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah done") + if operasi == 28 and tipe == 'tukar_guling': + raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling") + + 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 ['revisi_po', '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_purchase', 'approval_finance', 'approval_logistic', + '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), + # ('returned_from_id', '=', picking.id), + ('state', 'not in', ['cancel', 'draft']), + ]) > 0 + + def copy(self, default=None): + if default is None: + default = {} + + # Generate new sequence untuk duplicate + sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling.po')], limit=1) + if sequence: + default['name'] = sequence.next_by_id() + else: + default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po') or 'copy' + + default.update({ + 'state': 'draft', + 'date': fields.Datetime.now(), + }) + + new_record = super(TukarGulingPO, 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): + if self.operations.picking_type_id.id not in [75, 28]: + raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!") + self._check_bill_on_revisi_po() + tipe = vals.get('return_type', self.return_type) + + if self.operations and self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling': + group = self.operations.group_id + if group: + # Cari BU/PUT dalam group yang sama + bu_put = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('picking_type_id.id', '=', 75), # 75 = ID BU/PUT + ('state', '=', 'done') + ], limit=1) + + if bu_put: + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!") + + if self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling': + raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling") + + # if self.operations.picking_type_id.id != 28: + # if self._is_already_returned(self.operations): + # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + 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(TukarGulingPO, self).write(vals) + + def unlink(self): + for record in self: + if record.state == 'done': + raise UserError("Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu") + ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + return super(TukarGulingPO, self).unlink() + + def action_view_picking(self): + self.ensure_one() + action = self.env.ref('stock.action_picking_tree_all').read()[0] + pickings = self.po_picking_ids + if len(pickings) > 1: + action['domain'] = [('id', 'in', pickings.ids)] + elif pickings: + action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] + action['res_id'] = pickings.id + return action + + 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 action_submit(self): + self.ensure_one() + self._check_bill_on_revisi_po() + self._validate_product_lines() + self._check_not_allow_tukar_guling_on_bu_input() + + if self.operations.picking_type_id.id == 28: + group = self.operations.group_id + if group: + # Cari BU/PUT dalam group yang sama + bu_put = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('picking_type_id.id', '=', 75), + ('state', '=', 'done') + ], limit=1) + + if bu_put: + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!") + + picking = self.operations + pick_id = self.operations.picking_type_id.id + if pick_id == 75: + if picking.state != 'done': + raise UserError("BU/PUT belum Done!") + + if pick_id not in [75, 28]: + raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!") + + if self._is_already_returned(self.operations): + raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + + if self.state != 'draft': + raise UserError("Submit hanya bisa dilakukan dari Draft.") + self.state = 'approval_purchase' + + def action_approve(self): + self.ensure_one() + self._validate_product_lines() + self._check_bill_on_revisi_po() + self._check_not_allow_tukar_guling_on_bu_input() + + 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_purchase': + 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_purchase = 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.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 = 'done' + rec._create_pickings() + rec.date_logistic = now + else: + raise UserError("Status ini tidak bisa di-approve.") + + def action_cancel(self): + self.ensure_one() + # if self.state == 'done': + # raise UserError("Tidak bisa cancel jika sudah done") + + 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.po_picking_ids.filtered(lambda p: p.state == 'done') + if bu_done: + raise UserError("Dokuemn BU sudah Done, tidak bisa di cancel") + ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + self.state = 'cancel' + + def _create_pickings(self): + for record in self: + if not record.operations: + raise UserError("BU Operations belum dipilih.") + + created_returns = self.env['stock.picking'] + + group = record.operations.group_id + bu_inputs = bu_puts = self.env['stock.picking'] + + # Buat qty map awal dari line_ids + bu_input_qty_map = { + line.product_id.id: line.product_uom_qty + for line in record.line_ids + if line.product_id and line.product_uom_qty > 0 + } + bu_put_qty_map = bu_input_qty_map.copy() + + if group: + po_pickings = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('state', '=', 'done') + ]) + bu_inputs = po_pickings.filtered(lambda p: p.picking_type_id.id == 28) + bu_puts = po_pickings.filtered(lambda p: p.picking_type_id.id == 75) + else: + raise UserError("Group ID tidak ditemukan pada BU Operations.") + + def _create_return_from_picking(picking, qty_map): + if not picking: + return self.env['stock.picking'] + + grup = record.operations.group_id + + # Tentukan lokasi + PARTNER_LOCATION_ID = 4 + BU_INPUT_LOCATION_ID = 58 + BU_STOCK_LOCATION_ID = 57 + + picking_type = picking.picking_type_id.id + if picking_type == 28: + default_location_id = BU_INPUT_LOCATION_ID + default_location_dest_id = PARTNER_LOCATION_ID + elif picking_type == 75: + default_location_id = BU_STOCK_LOCATION_ID + default_location_dest_id = BU_INPUT_LOCATION_ID + elif picking_type == 77: + default_location_id = BU_INPUT_LOCATION_ID + default_location_dest_id = BU_STOCK_LOCATION_ID + elif picking_type == 76: + default_location_id = PARTNER_LOCATION_ID + default_location_dest_id = BU_INPUT_LOCATION_ID + else: + return self.env['stock.picking'] + + return_context = dict(self.env.context) + return_context.update({ + 'active_id': picking.id, + 'default_location_id': default_location_id, + 'default_location_dest_id': default_location_dest_id, + 'from_ui': False, + }) + + return_wizard = self.env['stock.return.picking'].with_context(return_context).create({ + 'picking_id': picking.id, + 'location_id': default_location_dest_id, + 'original_location_id': default_location_id + }) + + return_lines = [] + moves = getattr(picking, 'move_ids_without_package', False) or picking.move_lines + + for move in moves: + product = move.product_id + if not product: + continue + + pid = product.id + available_qty = qty_map.get(pid, 0.0) + move_qty = move.product_uom_qty + allocate_qty = min(available_qty, move_qty) + + if allocate_qty <= 0: + continue + + return_lines.append((0, 0, { + 'product_id': pid, + 'quantity': allocate_qty, + 'move_id': move.id, + })) + qty_map[pid] -= allocate_qty + + _logger.info(f"📦 Alokasi {allocate_qty} untuk {product.display_name} | Sisa: {qty_map[pid]}") + + if not return_lines: + # Tukar Guling lanjut dari PRT/VRT + if picking.picking_type_id.id in [76, 77]: + for move in moves: + 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"🔁 TG lanjutan: Alokasi {move.product_uom_qty} untuk {move.product_id.display_name}") + else: + _logger.warning( + f"⏭️ Skipped return picking {picking.name}, tidak ada qty yang bisa dialokasikan.") + return self.env['stock.picking'] + + return_wizard.product_return_moves = return_lines + return_vals = return_wizard.create_returns() + return_picking = self.env['stock.picking'].browse(return_vals.get('res_id')) + + return_picking.write({ + 'location_id': default_location_id, + 'location_dest_id': default_location_dest_id, + 'group_id': grup.id, + 'tukar_guling_po_id': record.id, + }) + record.message_post( + body=f"📦 <b>{return_picking.name}</b> " + f"<b>{return_picking.picking_type_id.display_name}</b> " + f"Created by <b>{self.env.user.name}</b> " + f"status <b>{return_picking.state}</b> " + f"at <b>{fields.Datetime.now().strftime('%d/%m/%Y %H:%M')}</b>", + message_type="comment", + subtype_id=self.env.ref("mail.mt_note").id, + ) + + return return_picking + + # ============================ + # Eksekusi utama return logic + # ============================ + + if record.operations.picking_type_id.id == 28: + # Dari BU INPUT langsung buat PRT + prt = _create_return_from_picking(record.operations, bu_input_qty_map) + if prt: + created_returns |= prt + else: + # ✅ Pairing BU PUT ↔ BU INPUT + # Temukan index dari BU PUT yang dipilih user + try: + bu_put_index = sorted(bu_puts, key=lambda p: p.name).index(record.operations) + except ValueError: + raise UserError("Dokumen BU PUT yang dipilih tidak ditemukan dalam daftar BU PUT.") + + # Ambil pasangannya di BU INPUT (asumsi urutan sejajar) + sorted_bu_puts = sorted(bu_puts, key=lambda p: p.name) + sorted_bu_inputs = sorted(bu_inputs, key=lambda p: p.name) + + if bu_put_index >= len(sorted_bu_inputs): + raise UserError("Tidak ditemukan pasangan BU INPUT untuk BU PUT yang dipilih.") + + paired = [(sorted_bu_puts[bu_put_index], sorted_bu_inputs[bu_put_index])] + + for bu_put, bu_input in paired: + vrt = _create_return_from_picking(bu_put, bu_put_qty_map) + if vrt: + created_returns |= vrt + + prt = _create_return_from_picking(bu_input, bu_input_qty_map) + if prt: + created_returns |= prt + + # 🌀 Tukar Guling: buat dokumen baru dari PRT & VRT + if record.return_type == 'tukar_guling': + for prt in created_returns.filtered(lambda p: p.picking_type_id.id == 76): + bu_input = _create_return_from_picking(prt, bu_input_qty_map) + if bu_input: + created_returns |= bu_input + + for vrt in created_returns.filtered(lambda p: p.picking_type_id.id == 77): + bu_put = _create_return_from_picking(vrt, bu_put_qty_map) + if bu_put: + created_returns |= bu_put + + if not created_returns: + raise UserError("Tidak ada dokumen retur yang berhasil dibuat.") + + +class TukarGulingLinePO(models.Model): + _name = 'tukar.guling.line.po' + _description = 'Tukar Guling PO Line' + + sequence = fields.Integer('Sequence', default=10, copy=False) + product_id = fields.Many2one('product.product', string='Product', required=True) + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade') + 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_po_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_po_id and rec.tukar_guling_po_id.state not in ['draft', 'cancel']: + raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.") + return super(TukarGulingLinePO, self).unlink() + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO Ref') + + + def button_validate(self): + res = super(StockPicking, self).button_validate() + for picking in self: + if picking.tukar_guling_po_id: + message = _( + "📦 <b>%s</b> Validated by <b>%s</b> Status Changed <b>%s</b> at <b>%s</b>." + ) % ( + 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_po_id.message_post(body=message) + + return res
\ No newline at end of file |
