Files
2026-03-14 09:42:12 +00:00

424 lines
15 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import json
from trytond.exceptions import UserError
from trytond.i18n import gettext
from trytond.model import Model, ModelStorage, ModelView, dualmethod, fields
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.transaction import Transaction
from trytond.wizard import Button, StateTransition, StateView, Wizard
class ShipmentUnassignMixin:
'''Mixin to unassign quantity from assigned shipment moves'''
__slots__ = ()
@dualmethod
def unassign(cls, shipments, moves, quantities):
'''
Unassign the quantity from the corresponding move of the shipments.
'''
pool = Pool()
Move = pool.get('stock.move')
to_unassign = []
if not all(m.state == 'assigned' for m in moves):
raise ValueError("Not assigned move")
Move.draft(moves)
for move, unassign_quantity in zip(moves, quantities):
if not unassign_quantity:
continue
if unassign_quantity > move.quantity:
raise ValueError(
"Unassigned quantity greater than move quantity")
if unassign_quantity == move.quantity:
to_unassign.append(move)
else:
with Transaction().set_context(_stock_move_split=True):
to_unassign.extend(Move.copy(
[move],
{'quantity': unassign_quantity}))
move.quantity -= unassign_quantity
Move.save(moves)
Move.assign(moves)
if to_unassign:
cls.wait(shipments, to_unassign)
class ShipmentInReturn(ShipmentUnassignMixin, metaclass=PoolMeta):
__name__ = 'stock.shipment.in.return'
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'assign_manual_wizard': {
'invisible': Eval('state') != 'waiting',
'depends': ['state'],
},
})
@classmethod
@ModelView.button_action(
'stock_assign_manual.wizard_shipment_in_return_assign_manual')
def assign_manual_wizard(cls, shipments):
pass
class ShipmentOut(ShipmentUnassignMixin, metaclass=PoolMeta):
__name__ = 'stock.shipment.out'
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'assign_manual_wizard': {
'invisible': ((Eval('state') != 'waiting')
| (Eval('warehouse_storage')
== Eval('warehouse_output'))),
'depends': [
'state', 'warehouse_storage', 'warehouse_output'],
},
})
@classmethod
@ModelView.button_action(
'stock_assign_manual.wizard_shipment_out_assign_manual')
def assign_manual_wizard(cls, shipments):
pass
class ShipmentInternal(ShipmentUnassignMixin, metaclass=PoolMeta):
__name__ = 'stock.shipment.internal'
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'assign_manual_wizard': {
'invisible': Eval('state') != 'waiting',
'depends': ['state'],
},
})
@classmethod
@ModelView.button_action(
'stock_assign_manual.wizard_shipment_internal_assign_manual')
def assign_manual_wizard(cls, shipments):
pass
class ShipmentAssignManual(Wizard):
__name__ = 'stock.shipment.assign.manual'
start_state = 'next_'
next_ = StateTransition()
show = StateView('stock.shipment.assign.manual.show',
'stock_assign_manual.shipment_assign_manual_show_view_form', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("Skip", 'skip', 'tryton-forward', validate=False),
Button("Assign", 'assign', 'tryton-ok', default=True),
])
skip = StateTransition()
assign = StateTransition()
def transition_next_(self):
def next_move():
for move in self.record.assign_moves:
if move.state == 'draft' and move not in self.show.skipped:
self.show.move = move
return move
if self.show.skipped is None:
self.show.skipped = []
if not next_move():
if all(m.state == 'assigned' for m in self.record.assign_moves):
self.model.assign([self.record])
return 'end'
return 'show'
def default_show(self, fields):
defaults = {}
if 'skipped' in fields:
defaults['skipped'] = [m.id for m in self.show.skipped]
if 'move' in fields:
defaults['move'] = self.show.move.id
if 'unit' in fields:
defaults['unit'] = self.show.move.unit.id
if 'move_quantity' in fields:
defaults['move_quantity'] = self.show.move.quantity
return defaults
def transition_skip(self):
moves = list(self.show.skipped)
moves.append(self.show.move)
self.show.skipped = moves
return 'next_'
def transition_assign(self):
self.show.assign()
return 'next_'
class ShipmentAssignManualShow(ModelView):
__name__ = 'stock.shipment.assign.manual.show'
skipped = fields.Many2Many(
'stock.move', None, None, "Skipped", readonly=True)
move = fields.Many2One('stock.move', "Move", readonly=True)
quantity = fields.Float(
"Quantity", digits='unit',
domain=['OR',
('quantity', '=', None),
[
('quantity', '>', 0),
('quantity', '<=', Eval('move_quantity', 0)),
],
],
help="The maximum quantity to assign from the place.\n"
"Leave empty for the full quantity of the move.")
unit = fields.Many2One('product.uom', "Unit", readonly=True)
move_quantity = fields.Float("Move Quantity", readonly=True)
place = fields.Selection('get_places', "Place", required=True, sort=False)
place_string = place.translated('place')
@fields.depends('move')
def get_places(self, with_childs=True, grouping=('product',)):
pool = Pool()
Date = pool.get('ir.date')
Location = pool.get('stock.location')
Move = pool.get('stock.move')
Product = pool.get('product.product')
Lang = pool.get('ir.lang')
lang = Lang.get()
if not self.move:
return []
if with_childs:
locations = Location.search([
('parent', 'child_of', [self.move.from_location.id]),
])
else:
locations = [self.move.from_location]
location_ids = [loc.id for loc in locations]
product_ids = [self.move.product.id]
with Transaction().set_context(company=self.move.company.id):
stock_date_end = Date.today()
with Transaction().set_context(
stock_date_end=stock_date_end,
stock_assign=True,
forecast=False,
company=self.move.company.id):
pbl = Product.products_by_location(
location_ids, with_childs=False,
grouping=grouping, grouping_filter=(product_ids,))
def get_key(move, location_id):
key = (location_id,)
for field in grouping:
value = getattr(move, field)
if isinstance(value, Model):
value = value.id
key += (value,)
return key
def match(key, pattern):
for k, p in zip(key, pattern):
if p is None or k == p:
continue
else:
return False
else:
return True
def get_name(key):
move = Move()
parts = [Location(key[0]).rec_name]
for field, value in zip(grouping, key[1:]):
setattr(move, field, value)
value = getattr(move, field)
if isinstance(value, ModelStorage):
parts.append(value.rec_name)
elif value:
parts.append(str(value))
return ' - '.join(parts)
# Prevent picking from the destination location
try:
locations.remove(self.move.to_location)
except ValueError:
pass
# Try first to pick from source location
locations.remove(self.move.from_location)
locations.insert(0, self.move.from_location)
places = [(None, '')]
quantities = self.move.sort_quantities(
pbl.items(), locations, grouping)
for key, qty in quantities:
move_key = get_key(self.move, key[0])
if qty > 0 and match(key, move_key):
uom = self.move.product.default_uom
quantity = lang.format_number_symbol(
pbl[key], uom, digits=uom.digits)
name = '%(name)s (%(quantity)s)' % {
'name': get_name(key),
'quantity': quantity
}
places.append((json.dumps(key), name))
return places
def assign(self, grouping=('product',)):
pool = Pool()
Move = pool.get('stock.move')
Lang = pool.get('ir.lang')
lang = Lang.get()
if self.quantity is not None:
if not (0 <= self.quantity <= self.move.quantity):
uom = self.move.product.default_uom
raise ValidationError(gettext(
'stock_assign_manual.msg_invalid_quantity',
quantity=lang.format_number(
self.move_quantity, uom.digits)))
quantity = self.move.unit.round(self.quantity)
remainder = self.move.unit.round(self.move.quantity - quantity)
self.move.quantity = quantity
self.move.save()
if remainder:
Move.copy([self.move], {'quantity': remainder})
key = json.loads(self.place)
values = self._apply(key, grouping)
quantity = self.move.quantity
Move.assign_try([self.move], with_childs=False, grouping=grouping)
if self.move.state != 'assigned':
# Restore initial values as assign_try may have saved the move
for field, value in values.items():
setattr(self.move, field, value)
self.move.save()
if self.move.quantity == quantity:
raise UserError(gettext(
'stock_assign_manual.msg_assign_failed',
move=self.move.rec_name,
place=self.place_string))
def _apply(self, key, grouping):
"""Update the move according to the key
and return a dictionary with the initial values."""
values = {'from_location': self.move.from_location.id}
location_id = key[0]
self.move.from_location = location_id
for field, value in zip(grouping, key[1:]):
if value is not None and '.' not in field:
values[field] = getattr(self.move, field)
setattr(self.move, field, value)
return values
class ShipmentUnassignManual(Wizard):
__name__ = 'stock.shipment.unassign.manual'
start = StateTransition()
show = StateView('stock.shipment.unassign.manual.show',
'stock_assign_manual.shipment_unassign_manual_show_view_form', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("Unassign", 'unassign', 'tryton-ok', default=True),
])
unassign = StateTransition()
def transition_start(self):
moves = self.record.assign_moves
if any(m.state == 'assigned' for m in moves):
return 'show'
return 'end'
def default_show(self, fields):
moves = self.record.assign_moves
move_ids = [m.id for m in moves if m.state == 'assigned']
return {
'assigned_moves': move_ids,
}
def transition_unassign(self):
moves = []
quantities = []
for m in self.show.moves:
moves.append(m.move)
quantities.append(m.unassigned_quantity)
self.record.unassign(moves, quantities)
return 'end'
class ShipmentAssignedMove(ModelView):
__name__ = 'stock.shipment.assigned.move'
move = fields.Many2One('stock.move', "Move", required=True)
unassigned_quantity = fields.Float(
"Unassigned Quantity", digits='unit',
domain=['OR',
('unassigned_quantity', '=', None),
[
('unassigned_quantity', '>=', 0),
('unassigned_quantity', '<=', Eval('move_quantity', 0)),
],
],
help="The quantity to unassign")
assigned_quantity = fields.Float(
"Assigned Quantity", digits='unit',
domain=['OR',
('assigned_quantity', '=', None),
[
('assigned_quantity', '>=', 0),
('assigned_quantity', '<=', Eval('move_quantity', 0)),
],
],
help="The quantity left assigned")
unit = fields.Function(
fields.Many2One('product.uom', "Unit"), 'on_change_with_unit')
move_quantity = fields.Function(
fields.Float("Move Quantity"), 'on_change_with_move_quantity')
@staticmethod
def default_unassigned_quantity():
return 0.0
@fields.depends('move', 'unassigned_quantity', 'assigned_quantity')
def on_change_move(self, name=None):
if self.move:
self.assigned_quantity = self.move.quantity
self.unassigned_quantity = 0.0
@fields.depends('assigned_quantity', 'move', 'unassigned_quantity', 'unit')
def on_change_unassigned_quantity(self, name=None):
if self.move and self.unassigned_quantity:
self.assigned_quantity = self.unit.round(
self.move.quantity - self.unassigned_quantity)
@fields.depends('unassigned_quantity', 'move', 'assigned_quantity', 'unit')
def on_change_assigned_quantity(self, name=None):
if self.move and self.assigned_quantity:
self.unassigned_quantity = self.unit.round(
self.move.quantity - self.assigned_quantity)
@fields.depends('move')
def on_change_with_unit(self, name=None):
return self.move.unit if self.move else None
@fields.depends('move')
def on_change_with_move_quantity(self, name=None):
if self.move:
return self.move.quantity
class ShipmentUnassignManualShow(ModelView):
__name__ = 'stock.shipment.unassign.manual.show'
moves = fields.One2Many(
'stock.shipment.assigned.move', None, "Moves",
domain=[('move.id', 'in', Eval('assigned_moves'))],
help="The moves to unassign.")
assigned_moves = fields.Many2Many(
'stock.move', None, None, "Assigned Moves")