From b7887bdd87d0ea156b3f678f5e54eb82f18c17e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Sun, 4 Oct 2015 17:55:39 +0200 Subject: [PATCH] Split moves into supplier and customer moves issue4371 review15641002 --- CHANGELOG | 2 + __init__.py | 3 +- doc/index.rst | 7 +- purchase.py | 137 ++++++++--- purchase.xml | 6 + sale.py | 196 +++------------ sale.xml | 6 + stock.py | 241 ++++++++++++++++--- stock.xml | 31 +++ tests/scenario_sale_supply_drop_shipment.rst | 122 +++------- view/purchase_configuration_form.xml | 11 + view/purchase_form.xml | 5 + view/sale_configuration_form.xml | 9 + view/sale_form.xml | 5 + view/shipment_drop_form.xml | 10 +- 15 files changed, 474 insertions(+), 317 deletions(-) create mode 100644 view/purchase_configuration_form.xml create mode 100644 view/sale_configuration_form.xml diff --git a/CHANGELOG b/CHANGELOG index 58b5c1ddf8..319442d101 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Split moves into supplier and customer moves + Version 3.6.0 - 2015-04-20 * Bug fixes (see mercurial logs for details) * Add support for PyPy diff --git a/__init__.py b/__init__.py index 94aaef5fa5..59e356c638 100644 --- a/__init__.py +++ b/__init__.py @@ -12,9 +12,11 @@ def register(): Configuration, ShipmentDrop, Move, + SaleConfig, Sale, SaleLine, PurchaseRequest, + PurchaseConfig, Purchase, PurchaseLine, ProductSupplier, @@ -22,5 +24,4 @@ def register(): Pool.register( CreatePurchase, PurchaseHandleShipmentException, - SaleHandleShipmentException, module='sale_supply_drop_shipment', type_='wizard') diff --git a/doc/index.rst b/doc/index.rst index b88091e529..833332796a 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -22,7 +22,12 @@ The drop shipment can be in one of this states: * Waiting - All moves are in draft but the shipment is in readonly. + All moves are in draft, the synchronization between the moves of the supplier + and the moves to the customer has occured. + +* Shipped + + All moves from the supplier are done. * Done diff --git a/purchase.py b/purchase.py index c95fd52ed6..6092e138a3 100644 --- a/purchase.py +++ b/purchase.py @@ -1,13 +1,14 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.model import fields +from trytond.model import fields, ModelView, Workflow from trytond.pyson import Eval from trytond.pool import Pool, PoolMeta from trytond.transaction import Transaction +from trytond.tools import grouped_slice -__all__ = ['PurchaseRequest', 'Purchase', 'PurchaseLine', 'ProductSupplier', - 'CreatePurchase', 'PurchaseHandleShipmentException'] +__all__ = ['PurchaseRequest', 'PurchaseConfig', 'Purchase', 'PurchaseLine', + 'ProductSupplier', 'CreatePurchase', 'PurchaseHandleShipmentException'] __metaclass__ = PoolMeta @@ -27,6 +28,14 @@ class PurchaseRequest: depends=['customer', 'state']) +class PurchaseConfig: + __name__ = 'purchase.configuration' + + purchase_drop_location = fields.Property( + fields.Many2One('stock.location', 'Purchase Drop Location', + domain=[('type', '=', 'drop')])) + + class Purchase: __name__ = 'purchase.purchase' @@ -48,6 +57,13 @@ class Purchase: }, depends=['customer']), 'get_drop_shipments') + drop_location = fields.Many2One('stock.location', 'Drop Location', + domain=[('type', '=', 'drop')], + states={ + 'invisible': ~Eval('customer', False), + 'required': Eval('customer', False), + }, + depends=['customer']) @classmethod def __setup__(cls): @@ -57,6 +73,14 @@ class Purchase: 'defined for quotation of purchase "%s".'), }) + @staticmethod + def default_drop_location(): + pool = Pool() + PurchaseConfig = pool.get('purchase.configuration') + + config = PurchaseConfig(1) + return config.purchase_drop_location.id + @fields.depends('customer', 'delivery_address') def on_change_customer(self): self.delivery_address = None @@ -73,6 +97,49 @@ class Purchase: if self.customer and not self.delivery_address: self.raise_user_error('delivery_address_required', self.rec_name) + def create_move(self, move_type): + pool = Pool() + DropShipment = pool.get('stock.shipment.drop') + + moves = super(Purchase, self).create_move(move_type) + if moves and self.customer and move_type == 'in': + drop_shipment = self.create_drop_shipment() + drop_shipment.supplier_moves = moves + drop_shipment.save() + DropShipment.wait([drop_shipment]) + + return moves + + def create_drop_shipment(self): + pool = Pool() + DropShipment = pool.get('stock.shipment.drop') + + return DropShipment( + company=self.company, + supplier=self.party, + contact_address=self.party.address_get(), + customer=self.customer, + delivery_address=self.delivery_address, + ) + + @classmethod + @ModelView.button + @Workflow.transition('cancel') + def cancel(cls, purchases): + pool = Pool() + SaleLine = pool.get('sale.line') + Move = pool.get('stock.move') + + super(Purchase, cls).cancel(purchases) + sale_lines = [] + for sub_purchases in grouped_slice(purchases): + sale_lines += SaleLine.search([ + ('purchase_request.purchase_line.purchase', 'in', + [p.id for p in sub_purchases]), + ]) + Move.cancel([m for sl in sale_lines for m in sl.moves + if m.state != 'done' and m.from_location.type == 'drop']) + class PurchaseLine: __name__ = 'purchase.line' @@ -80,7 +147,7 @@ class PurchaseLine: def get_to_location(self, name): result = super(PurchaseLine, self).get_to_location(name) if self.purchase.customer: - return self.purchase.customer.customer_location.id + return self.purchase.drop_location.id return result def get_move(self): @@ -122,36 +189,44 @@ class PurchaseHandleShipmentException: pool = Pool() Sale = pool.get('sale.sale') SaleLine = pool.get('sale.line') + Purchase = pool.get('purchase.purchase') + PurchaseRequest = pool.get('purchase.request') + Move = pool.get('stock.move') super(PurchaseHandleShipmentException, self).transition_handle() - to_recreate = self.ask.recreate_moves - domain_moves = self.ask.domain_moves - sales, saleline_write = set(), [] - sale_lines = SaleLine.search([ - ('purchase_request.purchase_line.purchase', '=', - Transaction().context['active_id']), - ]) - saleline_write = [] - for sale_line in sale_lines: - moves_ignored = [] - moves_recreated = [] - skip = set(sale_line.moves_ignored) - skip.update(sale_line.moves_recreated) - for move in sale_line.moves: - if move not in domain_moves or move in skip: - continue - if move in to_recreate: - moves_recreated.append(move.id) - else: - moves_ignored.append(move.id) - sales.add(sale_line.sale) - saleline_write.append([sale_line]) - saleline_write.append({ - 'moves_ignored': [('add', moves_ignored)], - 'moves_recreated': [('add', moves_recreated)], - }) + sales = set() + moves = set() + to_recreate = set(self.ask.recreate_moves) + domain_moves = set(self.ask.domain_moves) + purchase = Purchase(Transaction().context['active_id']) - SaleLine.write(*saleline_write) + requests = [] + for sub_lines in grouped_slice([pl.id for pl in purchase.lines]): + requests += PurchaseRequest.search([ + ('purchase_line', 'in', list(sub_lines)), + ]) + pline2request = {r.purchase_line: r for r in requests} + request2sline = {} + for sub_requests in grouped_slice(requests): + sale_lines = SaleLine.search([ + ('purchase_request', 'in', [r.id for r in sub_requests]), + ]) + request2sline.update({sl.purchase_request: sl + for sl in sale_lines}) + + for line in purchase.lines: + if not set(line.moves) & domain_moves: + continue + if not any(m in to_recreate for m in line.moves): + sale_line = request2sline[pline2request[line]] + moves.update({m for m in sale_line.moves + if (m.state != 'done' + and m.from_location.type == 'drop')}) + sales.add(sale_line.sale) + + if moves: + Move.cancel(list(moves)) + if sales: Sale.process(list(sales)) return 'end' diff --git a/purchase.xml b/purchase.xml index 7f8eda9c6a..0c6c05e60b 100644 --- a/purchase.xml +++ b/purchase.xml @@ -8,6 +8,12 @@ this repository contains the full copyright notices and license terms. --> purchase_form + + purchase.configuration + + purchase_configuration_form + Drop Shipments diff --git a/sale.py b/sale.py index 276a0adf4b..4fad8a1126 100644 --- a/sale.py +++ b/sale.py @@ -1,17 +1,22 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from itertools import groupby, chain -from functools import partial from trytond.model import fields from trytond.pyson import Eval from trytond.pool import Pool, PoolMeta from trytond.transaction import Transaction -from trytond.const import OPERATORS -__all__ = ['Sale', 'SaleLine', 'SaleHandleShipmentException'] +__all__ = ['SaleConfig', 'Sale', 'SaleLine'] __metaclass__ = PoolMeta +class SaleConfig: + __name__ = 'sale.configuration' + + sale_drop_location = fields.Property( + fields.Many2One('stock.location', 'Sale Drop Location', + domain=[('type', '=', 'drop')])) + + class Sale: __name__ = 'sale.sale' @@ -21,6 +26,16 @@ class Sale: 'invisible': ~Eval('drop_shipments'), }), 'get_drop_shipments') + drop_location = fields.Many2One('stock.location', 'Drop Location', + domain=[('type', '=', 'drop')]) + + @staticmethod + def default_drop_location(): + pool = Pool() + Config = pool.get('sale.configuration') + + config = Config(1) + return config.sale_drop_location.id def get_drop_shipments(self, name): DropShipment = Pool().get('stock.shipment.drop') @@ -30,64 +45,34 @@ class Sale: def create_shipment(self, shipment_type): shipments = super(Sale, self).create_shipment(shipment_type) if shipment_type == 'out': - self.create_drop_shipment() + with Transaction().set_context(_drop_shipment=True): + self.create_drop_shipment_moves() return shipments - def _get_drop_move_sale_line(self): - ''' - Return a line of moves - ''' + def create_drop_shipment_moves(self): + pool = Pool() + Move = pool.get('stock.move') + moves = [] for line in self.lines: moves += line.get_drop_moves() - return moves - - def _group_drop_shipment_key(self, moves, move): - ''' - The key to group moves by shipments - ''' - planned_date = max(m.planned_date for m in moves) - return ( - ('planned_date', planned_date), - ('supplier', move.purchase.party), - ) - - def create_drop_shipment(self): - ''' - Create a drop shipment for the sale - ''' - pool = Pool() - Shipment = pool.get('stock.shipment.drop') - - moves = self._get_drop_move_sale_line() - if not moves: - return [] - keyfunc = partial(self._group_drop_shipment_key, moves) - moves = sorted(moves, key=keyfunc) - - shipments = [] - for key, grouped_moves in groupby(moves, key=keyfunc): - values = { - 'customer': self.party.id, - 'delivery_address': self.shipment_address.id, - 'reference': self.reference, - 'company': self.company.id, - } - values.update(dict(key)) - shipment = Shipment(**values) - shipment.moves = [m for m in grouped_moves] - shipment.save() - shipments.append(shipment) - Shipment.wait(shipments) - return shipments + Move.save(moves) class SaleLine: __name__ = 'sale.line' + @property + def supply_on_sale(self): + supply_on_sale = super(SaleLine, self).supply_on_sale + return (supply_on_sale + or all(m.from_location.type == 'drop' + for m in self.moves)) + def get_move(self, shipment_type): result = super(SaleLine, self).get_move(shipment_type) if (shipment_type == 'out' + and not Transaction().context.get('_drop_shipment') and self.supply_on_sale): if (self.purchase_request and self.purchase_request.customer and self.purchase_request_state != 'cancel'): @@ -116,112 +101,9 @@ class SaleLine: return [] moves = [] if self.purchase_request and self.purchase_request.customer: - if self.purchase_request.purchase_line: - moves = [m - for m in self.purchase_request.purchase_line.moves - if m.state == 'draft' and not m.shipment] + move = self.get_move('out') + if move is not None: + move.from_location = self.sale.drop_location + move.cost_price = move.unit_price + moves.append(move) return moves - - @classmethod - def read(cls, ids, fields_names=None): - # Add moves from purchase_request as they can have only one origin - PurchaseRequest = Pool().get('purchase.request') - added = False - if 'moves' in fields_names or []: - if 'purchase_request' not in fields_names: - fields_names = fields_names[:] - fields_names.append('purchase_request') - added = True - values = super(SaleLine, cls).read(ids, fields_names=fields_names) - if 'moves' in fields_names or []: - with Transaction().set_context(_check_access=False): - purchase_requests = PurchaseRequest.browse( - list(set(v['purchase_request'] - for v in values if v['purchase_request']))) - id2purchase_requests = dict((p.id, p) - for p in purchase_requests) - for value in values: - if value['purchase_request']: - purchase_request = id2purchase_requests[ - value['purchase_request']] - if (purchase_request.customer - and purchase_request.purchase_line): - move_ids = tuple(m.id - for m in purchase_request.purchase_line.moves) - if value['moves'] is None: - value['moves'] = move_ids - else: - value['moves'] += move_ids - if added: - del value['purchase_request'] - return values - - @classmethod - def search(cls, domain, *args, **kwargs): - def process(domain): - domain = domain[:] - i = 0 - while i < len(domain): - arg = domain[i] - if (isinstance(arg, tuple) - or (isinstance(arg, list) - and len(arg) > 2 - and arg[1] in OPERATORS)): - if arg[0] == 'moves': - domain[i] = ['OR', - arg, - [ - ('purchase_request.purchase_line.moves',) + - tuple(arg[1:]), - ('purchase_request.customer', '!=', None), - ] - ] - elif isinstance(arg, list): - domain[i] = process(arg) - i += 1 - return domain - return super(SaleLine, cls).search(process(domain), *args, **kwargs) - - -class SaleHandleShipmentException: - __name__ = 'sale.handle.shipment.exception' - - def transition_handle(self): - pool = Pool() - Purchase = pool.get('purchase.purchase') - PurchaseLine = pool.get('purchase.line') - Sale = pool.get('sale.sale') - - super(SaleHandleShipmentException, self).transition_handle() - - to_recreate = self.ask.recreate_moves - domain_moves = self.ask.domain_moves - purchases, purchaseline_write = set(), [] - sale = Sale(Transaction().context['active_id']) - for line in sale.lines: - if not line.purchase_request: - continue - purchase_line = line.purchase_request.purchase_line - if not purchase_line: - continue - - moves_ignored = [] - moves_recreated = [] - skip = set(purchase_line.moves_ignored) - skip.update(purchase_line.moves_recreated) - for move in purchase_line.moves: - if move not in domain_moves or move in skip: - continue - if move in to_recreate: - moves_recreated.append(move.id) - else: - moves_ignored.append(move.id) - purchases.add(purchase_line.purchase) - purchaseline_write.append(([purchase_line], { - 'moves_ignored': [('add', moves_ignored)], - 'moves_recreated': [('add', moves_recreated)], - })) - - PurchaseLine.write(*chain(*purchaseline_write)) - Purchase.process(list(purchases)) - return 'end' diff --git a/sale.xml b/sale.xml index 8728d0b373..9a01f2298b 100644 --- a/sale.xml +++ b/sale.xml @@ -9,6 +9,12 @@ this repository contains the full copyright notices and license terms. --> sale_form + + sale.configuration + + sale_configuration_form + + Drop Shipments stock.shipment.drop diff --git a/stock.py b/stock.py index 4dde6fb656..35f2d5305a 100644 --- a/stock.py +++ b/stock.py @@ -1,9 +1,15 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +from collections import defaultdict + +from sql.operators import Concat +from sql.aggregate import Count + from trytond.model import Workflow, ModelView, ModelSQL, fields from trytond.pyson import Eval, If from trytond.pool import Pool, PoolMeta from trytond.transaction import Transaction +from trytond.tools import grouped_slice __all__ = ['Configuration', 'ShipmentDrop', 'Move'] @@ -44,7 +50,8 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): }, depends=['state']) supplier = fields.Many2One('party.party', 'Supplier', required=True, states={ - 'readonly': (((Eval('state') != 'draft') | Eval('moves', [0])) + 'readonly': (((Eval('state') != 'draft') + | Eval('supplier_moves', [0])) & Eval('supplier')), }, depends=['state', 'supplier']) @@ -56,7 +63,8 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): depends=['state', 'supplier']) customer = fields.Many2One('party.party', 'Customer', required=True, states={ - 'readonly': (((Eval('state') != 'draft') | Eval('moves', [0])) + 'readonly': (((Eval('state') != 'draft') + | Eval('customer_moves', [0])) & Eval('customer')), }, depends=['state']) @@ -68,29 +76,101 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): domain=[('party', '=', Eval('customer'))], depends=['state', 'customer']) moves = fields.One2Many('stock.move', 'shipment', 'Moves', - add_remove=[ - ('shipment', '=', None), - ('state', '=', 'draft'), - ('supplier', '=', Eval('supplier')), - ('customer_drop', '=', Eval('customer')), - ], domain=[ ('company', '=', Eval('company')), - ('from_location.type', '=', 'supplier'), - ('to_location.type', '=', 'customer'), + ['OR', + [ + ('from_location.type', '=', 'supplier'), + ('to_location.type', '=', 'drop'), + ], + [ + ('from_location.type', '=', 'drop'), + ('to_location.type', '=', 'customer'), + ], + ], ], - states={ - 'readonly': Eval('state').in_(['waiting', 'done', 'cancel']), - }, - depends=['state', 'company', 'supplier', 'customer']) + depends=['company'], readonly=True) + supplier_moves = fields.Function(fields.One2Many('stock.move', None, + 'Supplier Moves', + states={ + 'readonly': Eval('state').in_(['shipped', 'done', 'cancel']), + }, + depends=['state', 'supplier']), 'get_moves', 'set_moves') + customer_moves = fields.Function(fields.One2Many('stock.move', None, + 'Customer Moves', + states={ + 'readonly': Eval('state') != 'shipped', + }, + depends=['state', 'customer']), 'get_moves', 'set_moves') code = fields.Char('Code', select=1, readonly=True) state = fields.Selection([ ('draft', 'Draft'), ('waiting', 'Waiting'), + ('shipped', 'Shipped'), ('done', 'Done'), ('cancel', 'Canceled'), ], 'State', readonly=True) + @classmethod + def __register__(cls, module_name): + pool = Pool() + Move = pool.get('stock.move') + PurchaseLine = pool.get('purchase.line') + PurchaseRequest = pool.get('purchase.request') + SaleLine = pool.get('sale.line') + Location = pool.get('stock.location') + move = Move.__table__() + purchase_line = PurchaseLine.__table__() + purchase_request = PurchaseRequest.__table__() + sale_line = SaleLine.__table__() + location = Location.__table__() + cursor = Transaction().cursor + + super(ShipmentDrop, cls).__register__(module_name) + + # Migration from 3.6 + cursor.execute(*location.select(Count(location.id), + where=(location.type == 'drop'))) + has_drop_shipment, = cursor.fetchone() + + if not has_drop_shipment: + drop_shipment = Location(name='Migration Drop Shipment', + type='drop', active=False) + drop_shipment.save() + drop_shipment_location = drop_shipment.id + + move_sale_query = move.join(purchase_line, + condition=move.origin == Concat('purchase.line,', + purchase_line.id) + ).join(purchase_request, + condition=purchase_request.purchase_line == purchase_line.id + ).join(sale_line, + condition=sale_line.purchase_request == purchase_request.id + ).select( + move.id, move.to_location, sale_line.id, + where=move.shipment.like('stock.shipment.drop,%')) + cursor.execute(*move_sale_query) + move_sales = cursor.fetchall() + + for sub_move in grouped_slice(move_sales): + sub_ids = [s[0] for s in sub_move] + cursor.execute(*move.update( + columns=[move.to_location], + values=[drop_shipment_location], + where=move.id.in_(sub_ids))) + + create_move = move.insert(values=move.select( + where=move.shipment.like('stock.shipment.drop,%'))) + cursor.execute(*create_move) + + for move_id, customer_location, line_id in move_sales: + cursor.execute(move.update( + columns=[move.origin, move.from_location, + move.to_location], + values=[Concat('sale.line,', str(line_id)), + drop_shipment_location, customer_location], + where=(move.id == move_id))) + @classmethod def __setup__(cls): super(ShipmentDrop, cls).__setup__() @@ -99,11 +179,13 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): }) cls._transitions |= set(( ('draft', 'waiting'), - ('waiting', 'done'), + ('waiting', 'shipped'), ('draft', 'cancel'), ('waiting', 'cancel'), ('waiting', 'draft'), ('cancel', 'draft'), + ('shipped', 'done'), + ('shipped', 'cancel'), )) cls._buttons.update({ 'cancel': { @@ -118,9 +200,12 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): 'wait': { 'invisible': Eval('state') != 'draft', }, - 'done': { + 'ship': { 'invisible': Eval('state') != 'waiting', }, + 'done': { + 'invisible': Eval('state') != 'shipped', + }, }) cls._error_messages.update({ 'reset_move': ('You cannot reset to draft move "%s" which was ' @@ -168,6 +253,18 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): 'planned_date': planned_date, }) + def get_moves(self, name): + if name == 'supplier_moves': + return [m.id for m in self.moves if m.to_location.type == 'drop'] + elif name == 'customer_moves': + return [m.id for m in self.moves if m.from_location.type == 'drop'] + + @classmethod + def set_moves(cls, shipments, name, values): + if not values: + return + cls.write(shipments, {'moves': values}) + @classmethod def create(cls, vlist): pool = Pool() @@ -198,20 +295,24 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): if values.get('state', '') not in ('done', 'cancel'): continue with Transaction().set_context(_check_access=False): - purchases = set() - move_ids = [m.id for s in shipments for m in s.moves] - purchase_lines = PurchaseLine.search([ - ('moves', 'in', move_ids), - ]) - purchases = list(set(l.purchase for l in purchase_lines or [])) - Purchase.process(purchases) - - sale_lines = SaleLine.search([ - ('moves', 'in', move_ids), - ]) + move_ids = [m.id for s in shipments for m in s.customer_moves] + sale_lines = [] + for sub_moves in grouped_slice(move_ids): + sale_lines += SaleLine.search([ + ('moves', 'in', list(sub_moves)), + ]) sales = list(set(l.sale for l in sale_lines or [])) Sale.process(sales) + move_ids = [m.id for s in shipments for m in s.supplier_moves] + purchase_lines = [] + for sub_moves in grouped_slice(move_ids): + purchase_lines += PurchaseLine.search([ + ('moves', 'in', move_ids), + ]) + purchases = list(set(l.purchase for l in purchase_lines or [])) + Purchase.process(purchases) + @classmethod def copy(cls, shipments, default=None): if default is None: @@ -225,7 +326,9 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): @Workflow.transition('cancel') def cancel(cls, shipments): Move = Pool().get('stock.move') - Move.cancel([m for s in shipments for m in s.moves]) + Move.cancel([m for s in shipments for m in s.supplier_moves]) + Move.write([m for s in shipments for m in s.customer_moves], + {'shipment': None}) @classmethod @ModelView.button @@ -242,11 +345,72 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): cls.raise_user_error('reset_move', (move.rec_name,)) Move.draft([m for s in shipments for m in s.moves]) + @classmethod + def _synchronize_moves(cls, shipments): + pool = Pool() + UoM = pool.get('product.uom') + Move = pool.get('stock.move') + + to_save = [] + for shipment in shipments: + product_qty = defaultdict(lambda: 0) + for s_move in shipment.supplier_moves: + product_qty[s_move.product] += UoM.compute_qty(s_move.uom, + s_move.quantity, s_move.product.default_uom) + for c_move in shipment.customer_moves: + if product_qty[c_move.product] <= 0: + continue + move_qty = UoM.compute_qty(c_move.uom, c_move.quantity, + c_move.product.default_uom) + qty = min(product_qty[c_move.product], move_qty) + c_move.quantity = UoM.compute_qty(c_move.product.default_uom, + qty, c_move.uom) + product_qty[c_move.product] -= qty + to_save.append(c_move) + if to_save: + Move.save(to_save) + @classmethod @ModelView.button @Workflow.transition('waiting') def wait(cls, shipments): - pass + pool = Pool() + PurchaseRequest = pool.get('purchase.request') + SaleLine = pool.get('sale.line') + Move = pool.get('stock.move') + + requests = [] + for sub_lines in grouped_slice([m.origin.id for s in shipments + for m in s.supplier_moves]): + requests += PurchaseRequest.search([ + ('purchase_line', 'in', list(sub_lines)), + ]) + pline2request = {r.purchase_line: r for r in requests} + sale_lines = SaleLine.search([ + ('purchase_request', 'in', [r.id for r in requests]), + ]) + request2sline = {sl.purchase_request: sl for sl in sale_lines} + + to_save = [] + for shipment in shipments: + for move in shipment.supplier_moves: + sale_line = request2sline[pline2request[move.origin]] + for move in sale_line.moves: + if (move.state not in ('cancel', 'done') + and move.from_location.type == 'drop'): + move.shipment = shipment + to_save.append(move) + Move.save(to_save) + cls._synchronize_moves(shipments) + + @classmethod + @ModelView.button + @Workflow.transition('shipped') + def ship(cls, shipments): + pool = Pool() + Move = pool.get('stock.move') + Move.do([m for s in shipments for m in s.supplier_moves]) + cls._synchronize_moves(shipments) @classmethod @ModelView.button @@ -255,7 +419,7 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): pool = Pool() Move = pool.get('stock.move') Date = pool.get('ir.date') - Move.do([m for s in shipments for m in s.moves]) + Move.do([m for s in shipments for m in s.customer_moves]) cls.write(shipments, { 'effective_date': Date.today(), }) @@ -275,12 +439,19 @@ class Move: return models def get_customer_drop(self, name): - PurchaseLine = Pool().get('purchase.line') - if (isinstance(self.origin, PurchaseLine) - and self.origin.purchase.customer): + pool = Pool() + SaleLine = pool.get('sale.line') + PurchaseLine = pool.get('purchase.line') + + if isinstance(self.origin, SaleLine): + return self.origin.sale.party.id + elif isinstance(self.origin, PurchaseLine): return self.origin.purchase.customer.id @classmethod def search_customer_drop(cls, name, clause): - return [('origin.purchase.customer',) + tuple(clause[1:]) - + ('purchase.line',)] + return ['OR', + ('origin.sale.party',) + tuple(clause[1:]) + ('sale.line',), + (('origin.purchase.customer',) + tuple(clause[1:]) + + ('purchase.line',)), + ] diff --git a/stock.xml b/stock.xml index 1bfdb64701..4f2c277a72 100644 --- a/stock.xml +++ b/stock.xml @@ -77,6 +77,13 @@ this repository contains the full copyright notices and license terms. --> + + Shipped + + + + All @@ -188,4 +195,28 @@ this repository contains the full copyright notices and license terms. --> + + + + + + Drop + DROP + drop + + + + + + + + + + + + diff --git a/tests/scenario_sale_supply_drop_shipment.rst b/tests/scenario_sale_supply_drop_shipment.rst index 43d7f94033..5ed8aac818 100644 --- a/tests/scenario_sale_supply_drop_shipment.rst +++ b/tests/scenario_sale_supply_drop_shipment.rst @@ -183,19 +183,28 @@ Create Purchase from Request:: >>> purchase.click('process') >>> purchase.state u'processing' + >>> config.user = sale_user.id >>> sale.reload() >>> sale.shipments [] >>> shipment, = sale.drop_shipments -Receive 100 products:: +Receiving only 100 products:: >>> config.user = stock_user.id - >>> move, = shipment.moves + >>> move, = shipment.supplier_moves >>> move.unit_price == move.cost_price True >>> move.quantity = 100 + >>> shipment.click('ship') + >>> config.user = sale_user.id + >>> sale.reload() + >>> sale.shipments + [] + >>> shipment, = sale.drop_shipments + + >>> config.user = stock_user.id >>> shipment.click('done') >>> shipment.state u'done' @@ -203,22 +212,28 @@ Receive 100 products:: >>> sale.reload() >>> sale.shipments [] - >>> _, shipment = sale.drop_shipments - >>> move, = shipment.moves - >>> move.quantity - 150.0 - >>> move.unit_price == move.cost_price - True -The purchase is now waiting for this new drop shipment:: +The purchase is now waiting for his new drop shipment:: >>> config.user = purchase_user.id >>> purchase.reload() >>> purchase.shipment_state u'waiting' + >>> shipment, = [s for s in purchase.drop_shipments + ... if s.state == 'waiting'] + >>> move, = shipment.customer_moves + >>> move.quantity + 150.0 + >>> move.unit_price == move.cost_price + True + >>> move, = shipment.supplier_moves + >>> move.quantity + 150.0 + >>> move.unit_price == move.cost_price + True -Let's cancel the shipment and handle the issue from the purchase. -As a consequence the sale order is over:: +Let's cancel the shipment and handle the issue on the purchase. +As a consequence the sale order is now in exception:: >>> config.user = stock_user.id >>> shipment.click('cancel') @@ -238,7 +253,7 @@ As a consequence the sale order is over:: >>> config.user = sale_user.id >>> sale.reload() >>> sale.shipment_state - u'sent' + u'exception' Cancelling the workflow on the purchase step:: @@ -268,49 +283,21 @@ Cancelling the workflow on the purchase step:: >>> purchase, = Purchase.find([('state', '=', 'draft')]) >>> purchase.click('cancel') -The sale just created a new outgoing shipment for the sale:: +The sale is then in exception:: >>> config.user = sale_user.id >>> sale.reload() - >>> shipment, = sale.shipments - -If the shipment is cancelled then the shipement state of the sale will be -considered as 'sent':: - - >>> config.user = stock_user.id - >>> shipment.click('cancel') - >>> config.user = sale_user.id >>> sale.shipment_state u'exception' - >>> handle_exception = Wizard('sale.handle.shipment.exception', [sale]) - >>> _ = handle_exception.form.recreate_moves.pop() >>> handle_exception.execute('handle') >>> sale.reload() >>> sale.shipment_state - u'sent' + u'waiting' -If we opt not to cancel the shipment and deliver from stock:: +The sale just created a new outgoing shipment for the sale and we can deliver +from stock:: - >>> config.user = sale_user.id - >>> sale = Sale() - >>> sale.party = customer - >>> sale.payment_term = payment_term - >>> sale_line = sale.lines.new() - >>> sale_line.product = product - >>> sale_line.quantity = 125 - >>> sale.click('quote') - >>> sale.click('confirm') - >>> sale.click('process') - >>> config.user = purchase_user.id - >>> purchase_request, = PurchaseRequest.find([('purchase_line', '=', None)]) - >>> create_purchase = Wizard('purchase.request.create_purchase', - ... [purchase_request]) - >>> purchase, = Purchase.find([('state', '=', 'draft')]) - >>> purchase.click('cancel') - - >>> config.user = sale_user.id - >>> sale.reload() >>> shipment, = sale.shipments >>> config.user = stock_user.id @@ -319,53 +306,6 @@ If we opt not to cancel the shipment and deliver from stock:: >>> shipment.click('done') >>> config.user = sale_user.id - >>> sale.shipment_state - u'sent' - -Now let's handle the exception on the sale side:: - - >>> config.user = sale_user.id - >>> sale = Sale() - >>> sale.party = customer - >>> sale.payment_term = payment_term - >>> sale_line = sale.lines.new() - >>> sale_line.product = product - >>> sale_line.quantity = 375 - >>> sale.save() - >>> sale.click('quote') - >>> sale.click('confirm') - >>> sale.click('process') - - >>> config.user = purchase_user.id - >>> purchase_request, = PurchaseRequest.find([('purchase_line', '=', None)]) - >>> purchase_request.quantity - 375.0 - >>> create_purchase = Wizard('purchase.request.create_purchase', - ... [purchase_request]) - >>> purchase, = Purchase.find([('state', '=', 'draft')]) - >>> purchase.payment_term = payment_term - >>> purchase.click('quote') - >>> purchase.click('confirm') - >>> purchase.click('process') - >>> purchase.shipment_state - u'waiting' - >>> config.user = sale_user.id - >>> sale.reload() - >>> shipment, = sale.drop_shipments - >>> config.user = stock_user.id - >>> shipment.click('cancel') - - >>> config.user = sale_user.id - >>> sale.shipment_state - u'exception' - >>> handle_exception = Wizard('sale.handle.shipment.exception', [sale]) - >>> _ = handle_exception.form.recreate_moves.pop() - >>> handle_exception.execute('handle') >>> sale.reload() >>> sale.shipment_state u'sent' - - >>> config.user = purchase_user.id - >>> purchase.reload() - >>> purchase.shipment_state - u'received' diff --git a/view/purchase_configuration_form.xml b/view/purchase_configuration_form.xml new file mode 100644 index 0000000000..8763dc5a03 --- /dev/null +++ b/view/purchase_configuration_form.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/view/purchase_form.xml b/view/purchase_form.xml index eb0f48c059..4fa2dc8539 100644 --- a/view/purchase_form.xml +++ b/view/purchase_form.xml @@ -9,6 +9,11 @@ this repository contains the full copyright notices and license terms. --> + + diff --git a/view/sale_configuration_form.xml b/view/sale_configuration_form.xml new file mode 100644 index 0000000000..214e7f8123 --- /dev/null +++ b/view/sale_configuration_form.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/view/sale_form.xml b/view/sale_form.xml index 45237edde3..6d24cdf268 100644 --- a/view/sale_form.xml +++ b/view/sale_form.xml @@ -7,4 +7,9 @@ this repository contains the full copyright notices and license terms. --> position="after"> + + diff --git a/view/shipment_drop_form.xml b/view/shipment_drop_form.xml index 2df0d250af..22319711ec 100644 --- a/view/shipment_drop_form.xml +++ b/view/shipment_drop_form.xml @@ -20,13 +20,21 @@ this repository contains the full copyright notices and license terms. -->