summaryrefslogtreecommitdiff
path: root/indoteknik_custom/models/tukar_guling.py
diff options
context:
space:
mode:
Diffstat (limited to 'indoteknik_custom/models/tukar_guling.py')
-rw-r--r--indoteknik_custom/models/tukar_guling.py843
1 files changed, 843 insertions, 0 deletions
diff --git a/indoteknik_custom/models/tukar_guling.py b/indoteknik_custom/models/tukar_guling.py
new file mode 100644
index 00000000..7253afb7
--- /dev/null
+++ b/indoteknik_custom/models/tukar_guling.py
@@ -0,0 +1,843 @@
+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 = 'Tukar Guling'
+ _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')
+ picking_ids = fields.One2many(
+ 'stock.picking',
+ 'tukar_guling_id',
+ string='Transfers'
+ )
+ # origin_so = fields.Many2one('sale.order', string='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
+ ('revisi_so', 'Revisi SO')], required=True, tracking=3)
+ state = fields.Selection(string='Status', selection=[
+ ('draft', 'Draft'),
+ ('approval_sales', ' Approval Sales'),
+ ('approval_finance', 'Approval Finance'),
+ ('approval_logistic', 'Approval Logistic'),
+ ('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)
+
+ # @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 = 'revisi_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
+ if self.operations.origin:
+ self.origin = self.operations.origin
+ _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
+
+ # 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 ['revisi_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',
+ '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
+
+ @api.constrains('return_type', 'operations')
+ def _check_invoice_on_revisi_so(self):
+ for record in self:
+ if record.return_type == 'revisi_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 'Revisi SO' karena dokumen %s sudah dibuat invoice.") % record.origin
+ )
+
+ @api.model
+ def create(self, vals):
+ # Generate sequence number
+ if not vals.get('name') or vals['name'] == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling')
+
+ # 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.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_revisi_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 == 'done':
+ raise UserError(
+ "Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu jika ingin menghapus")
+ ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done')
+ for picking in ongoing_bu:
+ picking.action_cancel()
+ return super(TukarGuling, self).unlink()
+
+ def action_view_picking(self):
+ self.ensure_one()
+ action = self.env.ref('stock.action_picking_tree_all').read()[0]
+ pickings = self.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 _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:
+ 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_revisi_so()
+ self._validate_product_lines()
+
+ if self.state != 'draft':
+ raise UserError("Submit hanya bisa dilakukan dari Draft.")
+ self.state = 'approval_sales'
+
+ def action_approve(self):
+ self.ensure_one()
+ self._validate_product_lines()
+ self._check_invoice_on_revisi_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:
+ 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.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()
+ # 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()")
+ 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 = []
+ 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}")
+
+ srt_picking = None
+ if srt_return_lines:
+ srt_wizard = self.env['stock.return.picking'].with_context({
+ 'active_id': bu_out.id,
+ 'default_location_id': PARTNER_LOCATION_ID,
+ 'default_location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'from_ui': False,
+ }).create({
+ 'picking_id': bu_out.id,
+ 'location_id': PARTNER_LOCATION_ID,
+ 'original_location_id': BU_OUTPUT_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'])
+ srt_picking.write({
+ 'location_id': PARTNER_LOCATION_ID,
+ 'location_dest_id': BU_OUTPUT_LOCATION_ID,
+ '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"📦 <b>{srt_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{srt_picking.state}</b>)")
+
+ ### ======== 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') or line.product_uom_qty
+
+ for pick in picks_to_return:
+ ort_return_lines = []
+ if is_retur_from_bu_pick:
+ # Ambil dari tukar.guling.line
+ 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:
+ # Ambil dari mapping koli
+ 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,
+ 'original_location_id': BU_STOCK_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'])
+ ort_picking.write({
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'location_dest_id': BU_STOCK_LOCATION_ID,
+ '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"📦 <b>{ort_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{ort_picking.state}</b>)")
+
+ ### ======== Tukar Guling: BU/OUT dan BU/PICK baru ========
+ 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,
+ 'original_location_id': BU_OUTPUT_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'])
+ new_pick.write({
+ 'location_id': BU_STOCK_LOCATION_ID,
+ 'location_dest_id': BU_OUTPUT_LOCATION_ID,
+ 'group_id': bu_out.group_id.id,
+ 'tukar_guling_id': record.id,
+ 'sale_order': record.origin
+ })
+ new_pick.action_assign() # Penting agar bisa trigger check koli
+ 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"📦 <b>{new_pick.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_pick.state}</b>)")
+
+ # 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,
+ 'original_location_id': PARTNER_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'])
+ new_out.write({
+ 'location_id': BU_OUTPUT_LOCATION_ID,
+ 'location_dest_id': PARTNER_LOCATION_ID,
+ '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"📦 <b>{new_out.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_out.state}</b>)")
+
+ 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 = _(
+ "📦 <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_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() \ No newline at end of file