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()