trytond-stock_shipment_reco.../stock.py

769 lines
27 KiB
Python

# The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import csv
from io import BytesIO
from sql import Null, Column, Literal
from sql.conditionals import Coalesce
from sql.functions import CharLength
from datetime import datetime, timedelta
from sql.operators import Concat
from trytond.model import ModelSQL, ModelView, fields
from trytond.model import Workflow
from trytond.pool import PoolMeta, Pool
from trytond.pyson import Eval, If, Id
from trytond.tools import grouped_slice
from trytond.transaction import Transaction
from trytond.wizard import Wizard, StateTransition, StateView, Button
from trytond import backend
__all__ = ['Configuration', 'ConfigurationSequence', 'Move',
'ShipmentReconciliation', 'ShipmentReconciliationLine',
'ShipmentReconcile', 'ShipmentReconcileStart']
class Configuration(metaclass=PoolMeta):
__name__ = 'stock.configuration'
reconcile_sequence = fields.MultiValue(
fields.Many2One('ir.sequence', 'Shipment reconcile Sequence',
domain=[
('company', 'in',
[Eval('context', {}).get('company', -1), None]),
('sequence_type', '=', Id('stock_shipment_reconcile',
'sequence_type_reconcile'))
], required=True)
)
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field == 'reconcile_sequence':
return pool.get('stock.configuration.sequence')
return super(Configuration, cls).multivalue_model(field)
@classmethod
def default_reconcile_sequence(cls, **pattern):
return cls.multivalue_model(
'reconcile_sequence').default_reconcile_sequence()
class ConfigurationSequence(metaclass=PoolMeta):
__name__ = 'stock.configuration.sequence'
reconcile_sequence = fields.Many2One('ir.sequence',
'Shipment reconcile Sequence',
domain=[
('company', 'in',
[Eval('company', -1), None]),
('sequence_type', '=', Id('stock_shipment_reconcile',
'sequence_type_reconcile'))
],
depends=['company'],
required=True)
@classmethod
def __register__(cls, module_name):
TableHandler = backend.get('TableHandler')
exist = TableHandler.table_exist(cls._table)
if exist:
table = TableHandler(cls, module_name)
exist &= table.column_exist('reconcile_sequence')
super(ConfigurationSequence, cls).__register__(module_name)
if not exist:
# Re-migration
cls._migrate_property([], [], [])
@classmethod
def default_reconcile_sequence(cls):
pool = Pool()
ModelData = pool.get('ir.model.data')
try:
return ModelData.get_id('stock_shipment_reconcile',
'sequence_reconcile')
except KeyError:
return None
@classmethod
def _migrate_property(cls, field_names, value_names, fields):
field_names.append('reconcile_sequence')
value_names.append('reconcile_sequence')
super(ConfigurationSequence, cls)._migrate_property(field_names,
value_names, fields)
class Move(metaclass=PoolMeta):
__name__ = 'stock.move'
reconciled = fields.Boolean('Reconciled', readonly=True)
reconcile_line = fields.Many2One('stock.shipment.reconciliation.line',
'Reconcile line', select=True)
@staticmethod
def default_reconciled():
return False
@classmethod
def copy(cls, records, default=None):
if default is None:
default = {}
default = default.copy()
default['reconcile_line'] = None
default['reconciled'] = False
return super(Move, cls).copy(records, default=default)
class ShipmentReconciliation(Workflow, ModelSQL, ModelView):
"""Stock Shipment Reconciliation"""
__name__ = 'stock.shipment.reconciliation'
_rec_name = 'code'
date_ = fields.DateTime('Date', required=True,
states={'readonly': (Eval('state') != 'draft')},
depends=['state'])
code = fields.Char('Code', select=True, readonly=True)
type_ = fields.Selection([
('in', 'Supplier Shipment')], 'Shipment type', required=True,
states={'readonly': (Eval('state') != 'draft') | Eval('lines')},
depends=['state', 'lines'])
company = fields.Many2One('company.company', 'Company', required=True,
states={'readonly': (Eval('state') != 'draft') | Eval('lines')},
depends=['state', 'lines'])
lines = fields.One2Many('stock.shipment.reconciliation.line', 'reconcile',
'Lines', states={'readonly': (Eval('state') != 'draft')},
depends=['state'])
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done')], 'State', readonly=True)
discrimination_days = fields.Integer('Discrimination Days', required=True,
states={'readonly': (Eval('state') != 'draft')},
depends=['state'])
@classmethod
def __setup__(cls):
super(ShipmentReconciliation, cls).__setup__()
cls._transitions |= set((('draft', 'done'),
('done', 'draft')))
cls._buttons.update({
'draft': {
'icon': 'tryton-back',
'invisible': (Eval('state') == 'draft'),
'depends': ['state'],
},
'do': {
'icon': 'tryton-forward',
'invisible': (Eval('state') != 'draft'),
'depends': ['state'],
},
'import_file': {
'icon': 'tryton-save',
'invisible': (Eval('state') != 'draft'),
'depends': ['state'],
},
'clear_lines': {
'icon': 'tryton-undo',
'invisible': (Eval('state') != 'draft'),
'depends': ['state'],
},
'validate_lines': {
'icon': 'tryton-launch',
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
})
cls._error_messages.update({
'cannot_delete': ('Reconciliation "%s" cannot be deleted because '
'it has lines.'),
'unreconciled_lines': 'Reconciliation "%s" has unreconcilied lines.'
})
@staticmethod
def order_code(tables):
table, _ = tables[None]
return [CharLength(table.code), table.code]
@staticmethod
def default_state():
return 'draft'
@staticmethod
def default_date_():
return datetime.now()
@staticmethod
def default_type_():
return 'in'
@staticmethod
def default_company():
return Transaction().context.get('company', None)
@staticmethod
def default_discrimination_days():
return 2
@classmethod
def create(cls, vlist):
pool = Pool()
Config = pool.get('stock.configuration')
vlist = [x.copy() for x in vlist]
config = Config(1)
default_company = cls.default_company()
for values in vlist:
if not values.get('code'):
values['code'] = config.get_multivalue(
'reconcile_sequence',
company=values.get('company', default_company)).get()
return super(ShipmentReconciliation, cls).create(vlist)
def _get_shipment_type(self):
res = {
'in': ('supplier', 'storage')
}
return res[self.type_]
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, records):
Line = Pool().get('stock.shipment.reconciliation.line')
for record in records:
Line.draft([l for l in record.lines if not l.moves])
@classmethod
@ModelView.button
@Workflow.transition('done')
def do(cls, records):
cls.validate_lines(records)
_force_do = False
for record in records:
if not _force_do and any(l.state == 'failed' for l in record.lines):
cls.raise_user_warning('unreconciled_lines_%s' % record.id,
'unreconciled_lines', record.rec_name)
_force_do = True
@classmethod
@ModelView.button
def validate_lines(cls, records):
Line = Pool().get('stock.shipment.reconciliation.line')
for record in records:
Line.confirm_try(list(record.lines))
@classmethod
def delete(cls, records):
for record in records:
if record.lines:
cls.raise_user_error('cannot_delete', record.rec_name)
super(ShipmentReconciliation, cls).delete(records)
@classmethod
@ModelView.button_action('stock_shipment_reconcile.wizard_reconcile')
def import_file(cls, records):
pass
@classmethod
@ModelView.button
def clear_lines(cls, records):
Line = Pool().get('stock.shipment.reconciliation.line')
for record in records:
Line.delete(list(record.lines))
def get_limit_dates(self, effective_date):
return {
'min_date': effective_date - timedelta(
days=self.discrimination_days),
'max_date': effective_date + timedelta(
days=self.discrimination_days)
}
class ShipmentReconciliationLine(Workflow, ModelSQL, ModelView):
"""Stock Shipment Reconciliation line"""
__name__ = 'stock.shipment.reconciliation.line'
effective_date = fields.Date('Effective Date', required=True, readonly=True)
product = fields.Many2One('product.product', 'Product', required=True,
readonly=True)
uom = fields.Many2One('product.uom', 'Uom', required=True,
readonly=True)
unit_digits = fields.Function(fields.Integer('Unit Digits'),
'on_change_with_unit_digits')
quantity = fields.Float('Quantity', required=True, readonly=True,
digits=(16, Eval('unit_digits', 2)),
depends=['unit_digits'])
company = fields.Many2One('company.company', 'Company', required=True,
readonly=True)
party = fields.Many2One('party.party', 'Party', required=True, readonly=True)
from_location = fields.Function(fields.Many2One('stock.location',
'From Location', depends=['party']), 'on_change_with_from_location')
shipment_reference = fields.Char('Shipment Reference', readonly=True)
reconcile = fields.Many2One('stock.shipment.reconciliation',
'Reconcile', select=True, required=True, readonly=True)
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('failed', 'Failed')], 'State', readonly=True)
shipment_type = fields.Function(
fields.Char('Shipment type'), 'on_change_with_shipment_type')
from_location_type = fields.Function(
fields.Char('From location type'), 'on_change_with_from_location_type')
to_location_type = fields.Function(
fields.Char('To location type'), 'on_change_with_to_location_type')
min_date = fields.Function(
fields.Date('Mininum date'), 'get_date_limit')
max_date = fields.Function(
fields.Date('Maximum date'), 'get_date_limit')
moves = fields.One2Many('stock.move', 'reconcile_line', 'Moves',
domain=[If(Eval('state') == 'draft',
[('reconciled', '=', False),
('reconcile_line', 'in', (None, Eval('id'))),
('company', '=', Eval('company')),
('shipment', 'like', Eval('shipment_type')),
('effective_date', '>=', Eval('min_date')),
('effective_date', '<=', Eval('max_date')),
('state', '=', 'done'),
('from_location.type', '=', Eval('from_location_type')),
('to_location.type', '=', Eval('to_location_type')),
('product', '=', Eval('product')),
If(Eval('shipment_type') == 'stock.shipment.in,%',
('shipment.supplier', '=',
Eval('party'), 'stock.shipment.in'),
())],
[])],
states={'readonly': (Eval('state') != 'draft')},
depends=['company', 'shipment_type', 'from_location_type', 'id',
'to_location_type', 'party', 'state', 'min_date', 'max_date',
'product'])
move_quantity = fields.Function(
fields.Float('Moves Qty.', digits=(16, Eval('unit_digits', 2)),
depends=['unit_digits']),
'get_move_quantity')
diff_quantity = fields.Function(
fields.Float('Diff. Qty.', digits=(16, Eval('unit_digits', 2)),
depends=['unit_digits']),
'get_diff_quantity')
@classmethod
def __setup__(cls):
super(ShipmentReconciliationLine, cls).__setup__()
cls._transitions |= set((('draft', 'confirmed'),
('confirmed', 'draft'),
('draft', 'failed'),
('failed', 'draft')))
cls._buttons.update({
'draft': {
'icon': 'tryton-back',
'invisible': (Eval('state') == 'draft'),
'depends': ['state'],
},
'confirm_try': {
'icon': 'tryton-forward',
'invisible': (Eval('state') != 'draft'),
'depends': ['state'],
},
})
cls._error_messages.update({
'delete_state': 'Lines must be in "Draft" state to be deleted.',
'delete_reconciled': ('Cannot delete reconciliation line "%s" ' +
'with stock moves reconciled.')
})
@staticmethod
def default_state():
return 'draft'
@fields.depends('uom')
def on_change_with_unit_digits(self, name=None):
if self.uom:
return self.uom.digits
return 2
@fields.depends('party')
def on_change_with_from_location(self):
if self.party:
return self.party.supplier_location.id
@fields.depends('reconcile')
def on_change_with_shipment_type(self, name=None):
if self.reconcile:
return ('stock.shipment.%s' % self.reconcile.type_) + ',%'
@fields.depends('reconcile')
def on_change_with_from_location_type(self, name=None):
if self.reconcile:
return self.reconcile._get_shipment_type()[0]
@fields.depends('reconcile')
def on_change_with_to_location_type(self, name=None):
if self.reconcile:
return self.reconcile._get_shipment_type()[1]
def get_date_limit(self, name=None):
res = self.reconcile.get_limit_dates(self.effective_date)
return res[name]
@classmethod
def get_move_quantity(cls, records, name=None):
res = {r.id: 0 for r in records}
for record in records:
if not record.moves:
continue
res[record.id] = record.uom.round(
sum(m.internal_quantity for m in record.moves))
return res
@classmethod
def get_diff_quantity(cls, records, name=None):
return {r.id: r.quantity - r.move_quantity for r in records}
@classmethod
def delete(cls, records):
for record in records:
if record.state != 'draft':
cls.raise_user_error('delete_state')
if any(m.reconciled for m in record.moves):
cls.raise_user_error('delete_reconciled', record.rec_name)
super(ShipmentReconciliationLine, cls).delete(records)
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, records):
Move = Pool().get('stock.move')
Move.write([m for r in records for m in r.moves], {'reconciled': False})
@classmethod
@Workflow.transition('confirmed')
def confirm(cls, records):
Move = Pool().get('stock.move')
Move.write([m for r in records for m in r.moves], {'reconciled': True})
@classmethod
@Workflow.transition('failed')
def fail(cls, records):
pass
@classmethod
@ModelView.button
def confirm_try(cls, records):
to_confirm = []
to_fail = []
for record in records:
if not record.moves:
to_fail.append(record)
else:
to_confirm.append(record)
if to_confirm:
cls.confirm(to_confirm)
if to_fail:
cls.fail(to_fail)
class ShipmentReconcile(Wizard):
"""Stock Shipment Reconcile"""
__name__ = 'stock.shipment.reconcile'
start = StateView('stock.shipment.reconcile.start',
'stock_shipment_reconcile.reconcile_start_view_form',
[Button('Cancel', 'end', 'tryton-cancel'),
Button('OK', 'finish_', 'tryton-ok', default=True)])
finish_ = StateTransition()
@classmethod
def __setup__(cls):
super(ShipmentReconcile, cls).__setup__()
cls._error_messages.update({
'data_not_found': 'Cannot find data with value "%s".',
})
def _process_file(self):
pool = Pool()
Reconcile = pool.get('stock.shipment.reconciliation')
csv_file = self.start.file
separator = self.start.separator
if separator == 'tab':
separator = '\t'
csv_file = BytesIO(csv_file)
csv_file = csv.reader(csv_file, delimiter=str(separator))
def_values = self._get_file_defaults()
headers = self._get_file_headers()
data_fields = self._get_file_m2o_fields()
reconcile = Reconcile(Transaction().context['active_id'])
csv_data = []
for i, line in enumerate(csv_file):
if i < 1 or not line:
continue
csv_data.append([
x.decode(self.start.character_encoding).encode('utf-8')
for x in line])
data = []
values = []
# read file
for row in csv_data:
new_row = {}
for i, column in enumerate(headers):
if column in def_values:
if row[i]:
def_values[column] = row[i]
else:
row[i] = def_values[column]
new_row[column] = row[i]
if column in data_fields:
data_fields[column][1].setdefault(row[i], None)
data.append(new_row)
# find values m2o
for field_name, field_values in data_fields.items():
_model = field_values[0]
if not _model:
continue
_Model = pool.get(_model)
for val in field_values[1].keys():
if not val:
continue
self._get_m2o_value(reconcile, _Model, val, field_name,
data_fields)
used_moves = []
tables = self.get_move_query_tables()
for sub_data in grouped_slice(data, count=100):
where = Literal(False)
min_date, max_date = None, None
sub_values = []
sub_data = list(sub_data)
for row_data in sub_data:
res = self._get_temp_line(reconcile, row_data, data_fields)
sub_values.append(res)
where |= (self._get_where_clause(reconcile, tables, res))
if min_date is None or res['effective_date'] < min_date:
min_date = res['effective_date']
if max_date is None or res['effective_date'] > max_date:
max_date = res['effective_date']
if sub_values:
values.extend(sub_values)
moves = self._get_moves(tables, where)
for move in moves:
for row_data in sub_values:
if (self._move_matches(reconcile, row_data, move) and
move.id not in used_moves):
row_data['move'] = move.id
used_moves.append(move.id)
break
return values
@classmethod
def _get_file_defaults(cls):
return {
'effective_date': None,
'shipment': None,
'party': None,
'product': None
}
@classmethod
def _get_file_headers(cls):
return ['effective_date', 'shipment', 'party', 'product', 'quantity']
@classmethod
def _get_file_m2o_fields(cls):
return {
'party': ['party.party', {}],
'product': ['product.product', {}]
}
def _get_m2o_value(self, reconcile, Model, value, field_name, data_fields):
res = Model.search([('name', '=', value)], limit=1)
if not res:
self.raise_user_error('data_not_found', value.decode(
self.start.character_encoding))
res, = res
data_fields[field_name][1][value] = res
def _get_temp_line(self, reconcile, row, data_fields):
return {
'effective_date': datetime.strptime(
row['effective_date'], '%Y-%m-%d').date(),
'company': reconcile.company.id,
'party': data_fields['party'][1][row['party']].id,
'shipment_reference': row['shipment'],
'product': data_fields['product'][1][row['product']].id,
'quantity': float(row['quantity']),
'uom': data_fields['product'][1][row['product']].default_uom.id,
'move': None,
}
def _get_where_clause(self, reconcile, tables, data):
_dates = reconcile.get_limit_dates(data['effective_date'])
party_field = {
'in': 'supplier',
}
return (
(tables['move'].product == data['product']) &
(Column(tables['shipment'], party_field[reconcile.type_]) == data['party']) &
(tables['shipment'].effective_date >= _dates['min_date']) &
(tables['shipment'].effective_date <= _dates['max_date']) &
(tables['shipment'].reference == data['shipment_reference'])
)
def get_move_query_tables(self):
pool = Pool()
Move = pool.get('stock.move')
Location = pool.get('stock.location')
Reconcile = pool.get('stock.shipment.reconciliation')
move = Move.__table__()
loc1 = Location.__table__()
loc2 = Location.__table__()
reconcile = Reconcile(Transaction().context['active_id'])
Shipment = pool.get('stock.shipment.%s' % reconcile.type_)
shipment = Shipment.__table__()
return {
'move': move,
'from_location': loc1,
'to_location': loc2,
'shipment': shipment
}
def _get_moves(self, tables, where):
pool = Pool()
Move = pool.get('stock.move')
Reconcile = pool.get('stock.shipment.reconciliation')
reconcile = Reconcile(Transaction().context['active_id'])
Shipment = pool.get('stock.shipment.%s' % reconcile.type_)
company_id = Transaction().context.get('company')
locs = reconcile._get_shipment_type()
cursor = Transaction().cursor
query = tables['move'].join(
tables['from_location'], condition=(
tables['from_location'].id == tables['move'].from_location)
).join(tables['to_location'], condition=(
tables['to_location'].id == tables['move'].to_location)
).join(tables['shipment'], condition=(
Concat('%s,' % Shipment.__name__, tables['shipment'].id) == tables['move'].shipment)
).select(tables['move'].id,
where=(
(tables['move'].company == company_id) &
(tables['move'].state == 'done') &
(Coalesce(tables['move'].reconciled, False) == False) &
(tables['move'].reconcile_line == Null) &
(tables['from_location'].type == locs[0]) &
(tables['to_location'].type == locs[1]) &
(where))
)
cursor.execute(*query)
move_ids = cursor.fetchall()
if move_ids:
return Move.browse([m[0] for m in move_ids])
return []
def _move_matches(self, reconcile, data, move):
data_res, move_res = self._get_move_match(data, move)
if data_res == move_res:
if move.effective_date == data['effective_date']:
return True
_dates = reconcile.get_limit_dates(data['effective_date'])
if (move.effective_date <= _dates['max_date'] and
move.effective_date >= _dates['min_date']):
return True
return False
def _get_move_match(self, data, move):
data_res = {
'product': data['product'],
'party': data['party'],
'shipment_reference': data['shipment_reference'],
}
move_res = {
'product': move.product.id,
'party': move.party.id,
'shipment_reference': move.shipment.reference,
}
return data_res, move_res
def transition_finish_(self):
pool = Pool()
ReconcileLine = pool.get('stock.shipment.reconciliation.line')
Reconcile = pool.get('stock.shipment.reconciliation')
Attachment = pool.get('ir.attachment')
reconciliation = Reconcile(Transaction().context['active_id'])
to_create = []
moves = self._process_file()
for move in moves:
line = self._get_reconcile_line(
ReconcileLine, reconciliation, move)
to_create.append(line)
if to_create:
with Transaction().set_context(_check_access=False):
ReconcileLine.save(to_create)
if self.start.attach_file:
attach = Attachment(
name=datetime.now().strftime("%y/%m/%d %H:%M:%S"),
type='data',
data=self.start.file,
resource=str(reconciliation))
attach.save()
return 'end'
def _get_reconcile_line(self, Reconcile, reconciliation, move):
res = Reconcile(
reconcile=reconciliation,
effective_date=move['effective_date'],
company=move['company'],
party=move['party'],
shipment_reference=move['shipment_reference'],
product=move['product'],
quantity=move['quantity'],
uom=move['uom'],
state='draft')
if move['move']:
res.moves = [move['move']]
return res
class ShipmentReconcileStart(ModelView):
"""Stock Shipment Reconcile Start"""
__name__ = 'stock.shipment.reconcile.start'
file = fields.Binary('File', required=True)
attach_file = fields.Boolean('Attach file')
separator = fields.Selection([
(',', 'Comma'),
(';', 'Semicolon'),
('tab', 'Tabulator'),
('|', '|'),
], 'CSV Separator', required=True,
help='Field separator in CSV lines.')
character_encoding = fields.Selection([
('utf-8', 'UTF-8'),
('latin-1', 'Latin-1'),
], 'CSV Character Encoding', required=True)
@staticmethod
def default_attach_file():
return True
@staticmethod
def default_separator():
return ','
@staticmethod
def default_character_encoding():
return 'utf-8'