diff options
| -rw-r--r-- | indoteknik_api/controllers/api_v1/stock_picking.py | 137 | ||||
| -rwxr-xr-x | indoteknik_custom/models/__init__.py | 2 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_location.py | 40 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_move.py | 71 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_picking.py | 81 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_quant.py | 6 | ||||
| -rwxr-xr-x | indoteknik_custom/security/ir.model.access.csv | 3 | ||||
| -rw-r--r-- | indoteknik_custom/views/stock_location.xml | 25 | ||||
| -rw-r--r-- | indoteknik_custom/views/stock_quant.xml | 14 |
9 files changed, 353 insertions, 26 deletions
diff --git a/indoteknik_api/controllers/api_v1/stock_picking.py b/indoteknik_api/controllers/api_v1/stock_picking.py index c19812f5..94d0035f 100644 --- a/indoteknik_api/controllers/api_v1/stock_picking.py +++ b/indoteknik_api/controllers/api_v1/stock_picking.py @@ -307,3 +307,140 @@ class StockPicking(controller.Controller): } ) + + @http.route(prefix + 'locator/picking', auth='public', methods=['GET', 'OPTIONS']) + @controller.Controller.must_authorized() + def get_picking_by_name(self, **kw): + name = str(kw.get('name')) + if not name: + return self.response({'status': 'error', 'message': 'Picking name is required'}) + + picking = request.env['stock.picking'].search([('name', 'ilike', name)], limit=1) + if 'BU/INPUT/' in name: + if picking.state != 'done': + return self.response({'status': 'error', 'message': 'BU Input nya belum done'}) + move_dest = picking.move_ids_without_package[0] + picking = move_dest.move_dest_ids.filtered(lambda m: m.state not in ['done', 'cancel']).picking_id + + if not picking: + return self.response({'status': 'error', 'message': 'Picking not found'}) + + lines = [] + for move in picking.move_line_ids_without_package: + lines.append({ + 'move_id': move.id, + 'product_id': move.product_id.id, + 'product_name': move.product_id.display_name, + 'product_uom_qty': move.product_uom_qty, + 'qty_done': move.qty_done, + 'uom_name': move.product_uom_id.name, + 'source_location': move.location_id.complete_name, + 'dest_location': move.location_dest_id.complete_name, + }) + + data = { + 'status': 'success', + 'picking': { + 'id': picking.id, + 'name': picking.name, + 'type_code': picking.picking_type_id.code, + 'source_location': picking.location_id.complete_name, + 'dest_location': picking.location_dest_id.complete_name, + 'state': picking.state, + 'lines': lines, + } + } + + return self.response(data) + + @http.route('/api/v1/locator/picking/update', auth='public', methods=['POST', 'OPTIONS'], csrf=False) + @controller.Controller.must_authorized() + def update_picking_lines(self, **kw): + picking_id = int(kw.get('picking_id')) + lines_str = kw.get('lines', '[]') + try: + updates = json.loads(lines_str) + except: + updates = [] + + picking = request.env['stock.picking'].sudo().browse(picking_id) + if not picking.exists(): + return self.response({'status': 'error', 'message': 'Picking not found'}) + + for line in updates: + move = request.env['stock.move.line'].sudo().browse(line['move_id']) + if not move.exists(): + continue + + location_id, location_dest_id = self.get_location_locator(line) + + for move_line in move: + move_line.qty_done = line.get('qty_done', move_line.qty_done) + if 'source_location' in line: + move_line.location_id = location_id + if 'dest_location' in line: + move_line.location_dest_id = location_dest_id + + return self.response({'status': 'success', 'message': 'Picking updated'}) + + @http.route('/api/v1/locator/picking/validate', auth='public', methods=['POST', 'OPTIONS'], csrf=False) + @controller.Controller.must_authorized() + def validate_picking(self, **kw): + try: + picking_id = int(kw.get('picking_id')) + picking = request.env['stock.picking'].sudo().browse(picking_id) + + if not picking.exists(): + return self.response({ + 'status': 'error', + 'message': 'Picking not found' + }) + + action = picking.button_validate() + backorder = None + + if isinstance(action, dict) and action.get('res_model') == 'stock.backorder.confirmation': + + ctx = action.get('context', {}) or {} + + pick_ids = ctx.get('default_pick_ids') or [] + + if pick_ids and isinstance(pick_ids[0], (tuple, list)): + pick_ids = [p[1] for p in pick_ids] + + Wizard = request.env['stock.backorder.confirmation'].with_context({ + **ctx, + "default_pick_ids": pick_ids, + "button_validate_picking_ids": pick_ids, + }).sudo() + + wizard = Wizard.create({}) + + # --- Step 4: Jalankan wizard.process() → Odoo create backorder --- + wizard.process() + + # --- Step 5: Ambil backorder --- + backorder = request.env['stock.picking'].sudo().search([ + ('backorder_id', '=', picking.id) + ], limit=1) + + # --- FINAL RESPONSE --- + return self.response({ + 'status': 'success', + 'picking_name': picking.name, + 'validated': True, + 'backorder_created': bool(backorder), + 'backorder_name': backorder.name if backorder else None, + }) + + except Exception as e: + return self.response({ + 'status': 'error', + 'message': str(e) + }) + + + def get_location_locator(self, line): + location_id = request.env['stock.location'].sudo().search([('complete_name', '=', line.get('source_location'))], limit=1).id + location_dest_id = request.env['stock.location'].sudo().search([('complete_name', '=', line.get('dest_location'))], limit=1).id + return location_id, location_dest_id
\ No newline at end of file diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py index a14c766e..adc80b20 100755 --- a/indoteknik_custom/models/__init__.py +++ b/indoteknik_custom/models/__init__.py @@ -163,5 +163,7 @@ from . import letter_receivable from . import sj_tele from . import partial_delivery from . import domain_apo +from . import stock_location +from . import stock_quant from . import uom_uom from . import commission_internal diff --git a/indoteknik_custom/models/stock_location.py b/indoteknik_custom/models/stock_location.py new file mode 100644 index 00000000..d075ff2a --- /dev/null +++ b/indoteknik_custom/models/stock_location.py @@ -0,0 +1,40 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + +class StockLocation(models.Model): + _inherit = 'stock.location' + + rack_level = fields.Integer( + string='Sequence', + default=1, + help='Indicates the vertical rack level (1 = lowest, 4 = highest).' + ) + + # level = fields.Integer( + # string='Rack Level', + # default=1, + # help='Indicates the vertical rack level (1 = lowest, 4 = highest).' + # ) + + is_locked = fields.Boolean( + string="Locked", + default=False, + help="Jika dicentang, lokasi ini tidak dapat digunakan untuk reservasi atau penerimaan barang." + ) + + @api.constrains('rack_level') + def _check_rack_level(self): + for rec in self: + if rec.rack_level < 1 or rec.rack_level > 4: + raise ValidationError(_("Rack level harus antara 1 sampai 4.")) + + @api.constrains('is_locked') + def _sync_locked_quant(self): + Quant = self.env['stock.quant'] + for rec in self: + quants = Quant.search([('location_id', '=', rec.id)]) + if quants: + if rec.is_locked: + quants.write({'note': 'Locked'}) + else: + quants.write({'note': False}) diff --git a/indoteknik_custom/models/stock_move.py b/indoteknik_custom/models/stock_move.py index cac88287..320c175f 100644 --- a/indoteknik_custom/models/stock_move.py +++ b/indoteknik_custom/models/stock_move.py @@ -2,6 +2,8 @@ from odoo import fields, models, api from odoo.tools.misc import format_date, OrderedSet from odoo.exceptions import UserError import logging +from odoo.tools.float_utils import float_compare, float_is_zero, float_round + _logger = logging.getLogger(__name__) @@ -20,7 +22,6 @@ class StockMove(models.Model): hold_outgoingg = fields.Boolean('Hold Outgoing', default=False) product_image = fields.Binary(related="product_id.image_128", string="Product Image", readonly=True) partial = fields.Boolean('Partial?', default=False) - # Ambil product uom dari SO line @api.model def create(self, vals): @@ -28,7 +29,66 @@ class StockMove(models.Model): sale_line = self.env['sale.order.line'].browse(vals['sale_line_id']) vals['product_uom'] = sale_line.product_uom.id return super().create(vals) + + def _update_reserved_quantity( + self, need, available_quantity, location_id, + lot_id=None, package_id=None, owner_id=None, strict=True + ): + self.ensure_one() + picking = self.picking_id + if picking and 'BU/PICK' in (picking.name or ''): + _logger.info(f"[LocatorLogic] Running custom locator logic for {picking.name}") + + # Ambil semua lokasi anak dari source location (ex: BU/Stock) + locations = self.env['stock.location'].search([ + ('id', 'child_of', self.location_id.id), + ('usage', '=', 'internal'), + ('is_locked', '=', False), + # ('id', '!=', 57), + ], order='rack_level asc') + + total_reserved = 0.0 + remaining_need = need + + for loc in locations: + if remaining_need <= 0: + break + + quants = self.env['stock.quant']._gather(self.product_id, loc) + for quant in quants: + if quant.available_quantity <= 0: + continue + + qty_to_take = min(quant.available_quantity, remaining_need) + _logger.info( + f"[LocatorLogic] Reserving {qty_to_take}/{remaining_need} " + f"from {loc.display_name} (avail={quant.available_quantity})" + ) + + reserved_now = super(StockMove, self)._update_reserved_quantity( + qty_to_take, quant.available_quantity, quant.location_id, + lot_id, package_id, owner_id, strict + ) + + total_reserved += reserved_now + remaining_need -= reserved_now + + if remaining_need <= 0: + break + + if total_reserved > 0: + _logger.info(f"[LocatorLogic] Total reserved: {total_reserved} / {need}") + return total_reserved + else: + _logger.info("[LocatorLogic] No available stock found in unlocked locations by level order.") + return 0 + + return super(StockMove, self)._update_reserved_quantity( + need, available_quantity, location_id, lot_id, package_id, owner_id, strict + ) + + # @api.model_create_multi # def create(self, vals_list): # moves = super(StockMove, self).create(vals_list) @@ -288,4 +348,11 @@ class StockMoveLine(models.Model): move = self.env['stock.move'].browse(vals['move_id']) if move.product_uom: vals['product_uom_id'] = move.product_uom.id - return super().create(vals)
\ No newline at end of file + return super().create(vals) + def _action_done(self): + for line in self: + if line.location_dest_id and line.location_dest_id.is_locked: + raise UserError(f"Lokasi '{line.location_dest_id.display_name}' sedang dikunci dan tidak bisa menerima barang.") + if line.location_id and line.location_id.is_locked: + raise UserError(f"Lokasi '{line.location_id.display_name}' sedang dikunci dan tidak bisa reserve barang.") + return super(StockMoveLine, self)._action_done() diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py index e7686b75..5a793382 100644 --- a/indoteknik_custom/models/stock_picking.py +++ b/indoteknik_custom/models/stock_picking.py @@ -2349,33 +2349,44 @@ class CheckProduct(models.Model): self.quantity = 1 def unlink(self): - # Get all affected pickings before deletion + """ + Override unlink untuk: + 1. Simpan picking dan product_id yang terdampak sebelum delete. + 2. Hapus record check.product. + 3. Reset quantity_done untuk product yang udah gak ada di check_product_lines. + 4. Sync ulang product yang masih ada. + """ + # Step 1: ambil data sebelum hapus pickings = self.mapped('picking_id') - - # Store product_ids that will be deleted deleted_product_ids = self.mapped('product_id') - # Perform the deletion + # Step 2: hapus record result = super(CheckProduct, self).unlink() - # After deletion, update moves for affected pickings + # Step 3: update masing-masing picking for picking in pickings: - # For products that were completely removed (no remaining check.product lines) + # pastikan picking masih valid (kadang record udah kehapus bareng cascade) + if not picking.exists(): + continue + remaining_product_ids = picking.check_product_lines.mapped('product_id') removed_product_ids = deleted_product_ids - remaining_product_ids - # Set quantity_done to 0 for moves of completely removed products - moves_to_reset = picking.move_ids_without_package.filtered( - lambda move: move.product_id in removed_product_ids - ) - for move in moves_to_reset: - move.quantity_done = 0.0 + # Reset quantity_done untuk produk yang udah gak ada + if removed_product_ids: + moves_to_reset = picking.move_line_ids_without_package.filtered( + lambda m: m.product_id in removed_product_ids + ) + for move_line in moves_to_reset: + move_line.qty_done = 0.0 - # Also sync remaining products in case their totals changed - self._sync_check_product_to_moves(picking) + # Step 4: sync ulang product yang masih ada + if remaining_product_ids: + self._sync_check_product_to_moves(picking) return result + @api.depends('quantity') def _compute_status(self): for record in self: @@ -2407,19 +2418,41 @@ class CheckProduct(models.Model): def _sync_check_product_to_moves(self, picking): """ - Sinkronisasi quantity_done di move_ids_without_package - dengan total quantity dari check.product berdasarkan product_id. + Sinkronisasi qty_done di move_line_ids_without_package + berdasarkan total quantity dari check_product_lines per product_id, + dan distribusikan ke move_line berdasarkan urutan rack_level ascending. """ for product_id in picking.check_product_lines.mapped('product_id'): - # Totalkan quantity dari semua baris check.product untuk product_id ini - total_quantity = sum( - line.quantity for line in - picking.check_product_lines.filtered(lambda line: line.product_id == product_id) + # Hitung total quantity dari check_product_lines untuk produk ini + remaining_qty = sum( + line.quantity + for line in picking.check_product_lines.filtered(lambda l: l.product_id == product_id) ) - # Update quantity_done di move yang relevan - moves = picking.move_ids_without_package.filtered(lambda move: move.product_id == product_id) - for move in moves: - move.quantity_done = total_quantity + + # Ambil move_line untuk product_id ini dan urutkan berdasarkan rack_level ASC + move_lines = picking.move_line_ids_without_package.filtered( + lambda m: m.product_id == product_id + ) + move_lines = sorted( + move_lines, + key=lambda m: m.location_id.rack_level or 0 # kalau rack_level kosong, dianggap 0 + ) + + for move_line in move_lines: + if remaining_qty <= 0: + move_line.qty_done = 0.0 + continue + + needed = move_line.product_uom_qty + assigned_qty = min(needed, remaining_qty) + + move_line.qty_done = assigned_qty + remaining_qty -= assigned_qty + + # (Opsional) Log biar bisa dilacak + # _logger.info(f"[SYNC] {picking.name} - {product_id.display_name}: Sisa {remaining_qty}") + + def _consolidate_duplicate_lines(self): """ diff --git a/indoteknik_custom/models/stock_quant.py b/indoteknik_custom/models/stock_quant.py new file mode 100644 index 00000000..05335115 --- /dev/null +++ b/indoteknik_custom/models/stock_quant.py @@ -0,0 +1,6 @@ +from odoo import _, api, fields, models + +class StockQuant(models.Model): + _inherit = 'stock.quant' + + note = fields.Char(string="Note")
\ No newline at end of file diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv index bc8dc2a4..f6dcd4fc 100755 --- a/indoteknik_custom/security/ir.model.access.csv +++ b/indoteknik_custom/security/ir.model.access.csv @@ -213,4 +213,7 @@ access_unpaid_invoice_view,access.unpaid.invoice.view,model_unpaid_invoice_view, access_surat_piutang_user,surat.piutang user,model_surat_piutang,,1,1,1,1 access_surat_piutang_line_user,surat.piutang.line user,model_surat_piutang_line,,1,1,1,1 access_sj_tele,access.sj.tele,model_sj_tele,base.group_system,1,1,1,1 + +access_stock_location,access.stock.location,model_stock_location,,1,1,1,1 +access_stock_quant,access.stock.quant,model_stock_quant,,1,1,1,1 access_stock_picking_sj_document,stock.picking.sj.document,model_stock_picking_sj_document,base.group_user,1,1,1,1 diff --git a/indoteknik_custom/views/stock_location.xml b/indoteknik_custom/views/stock_location.xml index 82ab2bc5..af6706e9 100644 --- a/indoteknik_custom/views/stock_location.xml +++ b/indoteknik_custom/views/stock_location.xml @@ -5,4 +5,29 @@ <field name="location_id">3</field> <field name="usage">inventory</field> </record> + + <record id="view_location_tree_inherit_rack_level" model="ir.ui.view"> + <field name="name">stock.location.tree.rack_level</field> + <field name="model">stock.location</field> + <field name="inherit_id" ref="stock.view_location_tree2"/> + <field name="arch" type="xml"> + <field name="complete_name" position="after"> + <field name="rack_level"/> + <field name="is_locked"/> + </field> + </field> + </record> + + <!-- Tambahin field rack_level di Form View --> + <record id="view_location_form_inherit_rack_level" model="ir.ui.view"> + <field name="name">stock.location.form.rack_level</field> + <field name="model">stock.location</field> + <field name="inherit_id" ref="stock.view_location_form"/> + <field name="arch" type="xml"> + <field name="usage" position="after"> + <field name="rack_level"/> + <field name="is_locked" widget="boolean_toggle"/> + </field> + </field> + </record> </odoo>
\ No newline at end of file diff --git a/indoteknik_custom/views/stock_quant.xml b/indoteknik_custom/views/stock_quant.xml index 107f75f3..a665529e 100644 --- a/indoteknik_custom/views/stock_quant.xml +++ b/indoteknik_custom/views/stock_quant.xml @@ -9,6 +9,20 @@ <field name="inventory_quantity" position="after"> <field name="reserved_quantity" readonly="1" /> </field> + <field name="value" position="after"> + <field name="note" readonly="1" /> + </field> + </field> + </record> + + <record id="stock_view_stock_quant_tree_inherited" model="ir.ui.view"> + <field name="name">stock.view_stock_quant_tree_inherited</field> + <field name="model">stock.quant</field> + <field name="inherit_id" ref="stock.view_stock_quant_tree"/> + <field name="arch" type="xml"> + <field name="value" position="after"> + <field name="note" readonly="1" /> + </field> </field> </record> </data> |
