Split moves into supplier and customer moves
issue4371 review15641002
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-1
@@ -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')
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
|
||||
+106
-31
@@ -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'
|
||||
|
||||
@@ -8,6 +8,12 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="inherit" ref="purchase.purchase_view_form"/>
|
||||
<field name="name">purchase_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="purchase_configuration_view_form">
|
||||
<field name="model">purchase.configuration</field>
|
||||
<field name="inherit"
|
||||
ref="purchase.purchase_configuration_view_form"/>
|
||||
<field name="name">purchase_configuration_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_purchase_drop_shipment_form">
|
||||
<field name="name">Drop Shipments</field>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -9,6 +9,12 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="name">sale_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="sale_configuration_view_form">
|
||||
<field name="model">sale.configuration</field>
|
||||
<field name="inherit" ref="sale.sale_configuration_view_form"/>
|
||||
<field name="name">sale_configuration_form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_sale_drop_shipment_form">
|
||||
<field name="name">Drop Shipments</field>
|
||||
<field name="res_model">stock.shipment.drop</field>
|
||||
|
||||
@@ -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',)),
|
||||
]
|
||||
|
||||
@@ -77,6 +77,13 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="domain" eval="[('state', '=', 'waiting')]" pyson="1"/>
|
||||
<field name="act_window" ref="act_shipment_drop_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.domain"
|
||||
id="act_shipment_drop_form_domain_shipped">
|
||||
<field name="name">Shipped</field>
|
||||
<field name="sequence" eval="30"/>
|
||||
<field name="domain" eval="[('state', '=', 'shipped')]" pyson="1"/>
|
||||
<field name="act_window" ref="act_shipment_drop_form"/>
|
||||
</record>
|
||||
<record model="ir.action.act_window.domain" id="act_shipment_drop_form_domain_all">
|
||||
<field name="name">All</field>
|
||||
<field name="sequence" eval="9999"/>
|
||||
@@ -188,4 +195,28 @@ this repository contains the full copyright notices and license terms. -->
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Default locations -->
|
||||
<record model="stock.location" id="location_drop">
|
||||
<field name="name">Drop</field>
|
||||
<field name="code">DROP</field>
|
||||
<field name="type">drop</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.property" id="default_sale_location_drop">
|
||||
<field name="field"
|
||||
search="[('model.model', '=', 'sale.configuration'), ('name', '=', 'sale_drop_location')]"/>
|
||||
<field name="value"
|
||||
eval="'stock.location,' + str(ref('location_drop'))"/>
|
||||
</record>
|
||||
<record model="ir.property" id="default_purchase_location_drop">
|
||||
<field name="field"
|
||||
search="[('model.model', '=', 'purchase.configuration'), ('name', '=', 'purchase_drop_location')]"/>
|
||||
<field name="value"
|
||||
eval="'stock.location,' + str(ref('location_drop'))"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<data>
|
||||
<xpath expr="/form/field[@name='purchase_invoice_method']"
|
||||
position="after">
|
||||
<newline/>
|
||||
<label name="purchase_drop_location"/>
|
||||
<field name="purchase_drop_location"/>
|
||||
</xpath>
|
||||
</data>
|
||||
@@ -9,6 +9,11 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="delivery_address"/>
|
||||
<newline/>
|
||||
</xpath>
|
||||
<xpath expr="/form/notebook/page[@id='info']/field[@name='invoice_method']"
|
||||
position="after">
|
||||
<label name="drop_location"/>
|
||||
<field name="drop_location"/>
|
||||
</xpath>
|
||||
<xpath
|
||||
expr="/form/notebook/page[@name='shipments']/field[@name='shipments']"
|
||||
position="after">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<data>
|
||||
<xpath expr="/form/field[@name='sale_shipment_method']" position="after">
|
||||
<label name="sale_drop_location"/>
|
||||
<field name="sale_drop_location"/>
|
||||
</xpath>
|
||||
</data>
|
||||
@@ -7,4 +7,9 @@ this repository contains the full copyright notices and license terms. -->
|
||||
position="after">
|
||||
<field name="drop_shipments" colspan="4"/>
|
||||
</xpath>
|
||||
<xpath expr="/form/notebook/page[@id='other']/field[@name='shipment_method']"
|
||||
position="after">
|
||||
<label name="drop_location"/>
|
||||
<field name="drop_location"/>
|
||||
</xpath>
|
||||
</data>
|
||||
|
||||
@@ -20,13 +20,21 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="effective_date"/>
|
||||
<label name="company"/>
|
||||
<field name="company"/>
|
||||
<field name="moves" colspan="4"/>
|
||||
<notebook>
|
||||
<page name="supplier_moves">
|
||||
<field name="supplier_moves" colspan="4"/>
|
||||
</page>
|
||||
<page name="customer_moves">
|
||||
<field name="customer_moves" colspan="4"/>
|
||||
</page>
|
||||
</notebook>
|
||||
<label name="state"/>
|
||||
<field name="state"/>
|
||||
<group col="5" colspan="2" id="buttons">
|
||||
<button string="Cancel" name="cancel" icon="tryton-cancel"/>
|
||||
<button string="Draft" name="draft"/>
|
||||
<button string="Wait" name="wait" icon="tryton-go-next"/>
|
||||
<button string="Ship" name="ship" icon="tryton-go-next"/>
|
||||
<button string="Done" name="done" icon="tryton-ok"/>
|
||||
</group>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user