Files
tryton/modules/production_work/work.py
T

422 lines
14 KiB
Python
Raw Normal View History

2016-03-21 23:27:20 +01:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
import random
2023-05-03 14:29:57 +02:00
from collections import defaultdict
2016-03-21 23:27:20 +01:00
from decimal import Decimal
from functools import wraps
2023-05-03 14:29:57 +02:00
from sql.aggregate import Sum
from sql.conditionals import Coalesce
from trytond import backend
2018-12-29 14:20:29 +01:00
from trytond.i18n import gettext
2018-03-26 14:12:27 +02:00
from trytond.model import (
2025-12-02 16:01:39 +01:00
ChatMixin, DeactivableMixin, Index, ModelSQL, ModelView, Workflow, fields,
sequence_ordered, tree)
2018-12-29 14:20:29 +01:00
from trytond.model.exceptions import AccessError
from trytond.modules.company.model import employee_field, set_employee
2021-12-11 17:58:43 +01:00
from trytond.modules.product import price_digits, round_price
2016-03-21 23:27:20 +01:00
from trytond.pool import Pool
2022-11-24 19:16:51 +01:00
from trytond.pyson import Bool, Eval, If, TimeDelta
2026-03-22 21:05:26 +01:00
from trytond.tools import sqlite_apply_types
2016-03-21 23:27:20 +01:00
from trytond.transaction import Transaction
2018-12-29 14:20:29 +01:00
from .exceptions import PickerError
2016-03-21 23:27:20 +01:00
class WorkCenterCategory(ModelSQL, ModelView):
__name__ = 'production.work.center.category'
name = fields.Char('Name', required=True, translate=True)
2018-05-12 00:52:30 +02:00
class WorkCenter(DeactivableMixin, tree(separator=' / '), ModelSQL, ModelView):
2016-03-21 23:27:20 +01:00
__name__ = 'production.work.center'
name = fields.Char('Name', required=True, translate=True)
parent = fields.Many2One('production.work.center', 'Parent',
2016-03-21 23:27:20 +01:00
domain=[
('company', '=', Eval('company', -1)),
('warehouse', '=', Eval('warehouse', -1)),
])
2016-03-21 23:27:20 +01:00
children = fields.One2Many('production.work.center', 'parent', 'Children',
domain=[
('company', '=', Eval('company', -1)),
('warehouse', '=', Eval('warehouse', -1)),
])
2016-03-21 23:27:20 +01:00
category = fields.Many2One('production.work.center.category', 'Category')
cost_price = fields.Numeric('Cost Price', digits=price_digits,
states={
'required': Bool(Eval('cost_method')),
})
2016-03-21 23:27:20 +01:00
cost_method = fields.Selection([
('', ''),
('cycle', 'Per Cycle'),
('hour', 'Per Hour'),
], 'Cost Method',
states={
'required': Bool(Eval('cost_price')),
})
company = fields.Many2One('company.company', "Company", required=True)
2016-03-21 23:27:20 +01:00
warehouse = fields.Many2One('stock.location', 'Warehouse', required=True,
domain=[
('type', '=', 'warehouse'),
])
@classmethod
def __setup__(cls):
2025-04-04 00:45:18 +02:00
super().__setup__()
2016-03-21 23:27:20 +01:00
cls._order.insert(0, ('name', 'ASC'))
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_warehouse(cls):
Location = Pool().get('stock.location')
return Location.get_default_warehouse()
2016-03-21 23:27:20 +01:00
@classmethod
def get_picker(cls):
"""Return a method that picks a work center
for the category and the parent"""
cache = {}
def picker(parent, category):
key = (parent, category)
if key not in cache:
work_centers = cls.search([
('parent', 'child_of', [parent.id]),
('category', '=', category.id),
])
if not work_centers:
2018-12-29 14:20:29 +01:00
raise PickerError(
gettext('production_work.msg_missing_work_center',
category=category.rec_name,
parent=parent.rec_name))
2016-03-21 23:27:20 +01:00
cache[key] = work_centers
return random.choice(cache[key])
return picker
2025-12-02 16:01:39 +01:00
class Work(sequence_ordered(), ModelSQL, ModelView, ChatMixin):
2016-03-21 23:27:20 +01:00
__name__ = 'production.work'
operation = fields.Many2One('production.routing.operation', 'Operation',
required=True)
production = fields.Many2One(
'production', "Production", required=True, ondelete='CASCADE',
2016-03-21 23:27:20 +01:00
domain=[
('company', '=', Eval('company', -1)),
])
2016-03-21 23:27:20 +01:00
work_center_category = fields.Function(fields.Many2One(
'production.work.center.category', 'Work Center Category'),
'on_change_with_work_center_category')
work_center = fields.Many2One('production.work.center', 'Work Center',
domain=[
If(~Eval('work_center_category'),
(),
('category', '=', Eval('work_center_category'))),
('company', '=', Eval('company', -1)),
('warehouse', '=', Eval('warehouse', -1)),
],
states={
'required': ~Eval('state').in_(['request', 'draft']),
})
2016-03-21 23:27:20 +01:00
cycles = fields.One2Many('production.work.cycle', 'work', 'Cycles',
states={
'readonly': Eval('state').in_(['request', 'done']),
})
2020-10-12 19:24:42 +02:00
active_cycles = fields.One2Many(
'production.work.cycle', 'work', "Active Cycles",
readonly=True,
filter=[
('state', '=', 'running'),
])
2023-05-03 14:29:57 +02:00
cost = fields.Function(fields.Numeric(
"Cost", digits=price_digits), 'get_cost')
company = fields.Many2One('company.company', "Company", required=True)
2016-03-21 23:27:20 +01:00
warehouse = fields.Function(fields.Many2One('stock.location', 'Warehouse'),
'on_change_with_warehouse')
state = fields.Selection([
('request', 'Request'),
('draft', 'Draft'),
('waiting', 'Waiting'),
('running', 'Running'),
('finished', 'Finished'),
('done', 'Done'),
], "State", readonly=True, sort=False)
2016-03-21 23:27:20 +01:00
2020-10-12 19:24:42 +02:00
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
2024-07-27 18:36:14 +02:00
t, (t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['request', 'draft', 'waiting', 'running'])))
2020-10-12 19:24:42 +02:00
cls._buttons.update({
'start': {
'invisible': Bool(Eval('active_cycles', [])),
'readonly': Eval('state').in_(['request', 'done']),
'depends': ['active_cycles'],
},
'stop': {
'invisible': ~Bool(Eval('active_cycles', [])),
'depends': ['active_cycles'],
},
})
2016-03-21 23:27:20 +01:00
@fields.depends('operation')
def on_change_with_work_center_category(self, name=None):
2023-03-26 09:49:00 +02:00
return self.operation.work_center_category if self.operation else None
2016-03-21 23:27:20 +01:00
@classmethod
def default_company(cls):
return Transaction().context.get('company')
2019-10-16 21:20:14 +02:00
@fields.depends('production', '_parent_production.warehouse')
2016-03-21 23:27:20 +01:00
def on_change_with_warehouse(self, name=None):
2023-03-26 09:49:00 +02:00
return self.production.warehouse if self.production else None
2016-03-21 23:27:20 +01:00
@classmethod
def default_state(cls):
return 'request'
2020-10-12 19:24:42 +02:00
@classmethod
@ModelView.button
def start(cls, works):
pool = Pool()
Cycle = pool.get('production.work.cycle')
cycles = [Cycle(work=w) for w in works]
Cycle.save(cycles)
Cycle.run(cycles)
@classmethod
@ModelView.button
def stop(cls, works):
pool = Pool()
Cycle = pool.get('production.work.cycle')
to_do = []
for work in works:
for cycle in work.active_cycles:
to_do.append(cycle)
Cycle.do(to_do)
2016-03-21 23:27:20 +01:00
@property
def _state(self):
2022-11-24 19:12:19 +01:00
if self.production.state == 'waiting' and not self.cycles:
2016-03-21 23:27:20 +01:00
return 'request'
elif self.production.state == 'done':
return 'done'
elif (not self.cycles
or all(c.state == 'cancelled' for c in self.cycles)):
return 'draft'
elif all(c.state in ['done', 'cancelled'] for c in self.cycles):
return 'finished'
elif any(c.state == 'running' for c in self.cycles):
return 'running'
else:
return 'waiting'
@classmethod
def set_state(cls, works):
for work in works:
2022-10-28 00:35:20 +02:00
state = work._state
if work.state != state:
work.state = state
2016-03-21 23:27:20 +01:00
cls.save(works)
def get_rec_name(self, name):
2016-06-18 12:37:04 +02:00
return '%s @ %s' % (self.operation.rec_name, self.production.rec_name)
@classmethod
def search_rec_name(cls, name, clause):
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('operation.rec_name',) + tuple(clause[1:]),
('production.rec_name',) + tuple(clause[1:]),
]
2016-03-21 23:27:20 +01:00
2023-05-03 14:29:57 +02:00
@classmethod
def _get_cost(cls, works):
2023-05-03 14:29:57 +02:00
pool = Pool()
Cycle = pool.get('production.work.cycle')
cycle = Cycle.__table__()
cursor = Transaction().connection.cursor()
2026-03-22 21:05:26 +01:00
query = cycle.select(
cycle.work, Sum(Coalesce(cycle.cost, 0)).as_('cost'),
where=fields.SQL_OPERATORS['in'](cycle.work, map(int, works))
& (cycle.state == 'done'),
group_by=cycle.work)
if backend.name == 'sqlite':
sqlite_apply_types(query, [None, 'NUMERIC'])
cursor.execute(*query)
return defaultdict(Decimal, cursor)
@classmethod
def get_cost(cls, works, name):
costs = defaultdict(Decimal)
for work_id, cost in cls._get_cost(works).items():
2026-03-22 21:05:26 +01:00
costs[work_id] = round_price(cost)
2023-05-03 14:29:57 +02:00
return costs
2017-10-18 14:58:52 +02:00
@classmethod
def on_modification(cls, mode, works, field_names=None):
super().on_modification(mode, works, field_names=field_names)
if mode in {'create', 'write'}:
cls.set_state(works)
2017-10-18 14:58:52 +02:00
2022-10-28 00:35:20 +02:00
@classmethod
def check_modification(cls, mode, works, values=None, external=False):
super().check_modification(
mode, works, values=values, external=external)
if mode == 'delete':
for work in works:
if work.state not in {'request', 'draft'}:
raise AccessError(
gettext('production_work.msg_delete_request',
work=work.rec_name))
2016-03-21 23:27:20 +01:00
def set_work_state(func):
@wraps(func)
def wrapper(cls, cycles):
pool = Pool()
Work = pool.get('production.work')
result = func(cls, cycles)
2016-03-21 23:27:20 +01:00
Work.set_state(Work.browse({c.work.id for c in cycles}))
return result
2016-03-21 23:27:20 +01:00
return wrapper
class WorkCycle(Workflow, ModelSQL, ModelView):
__name__ = 'production.work.cycle'
work = fields.Many2One(
'production.work', "Work", required=True, ondelete='CASCADE')
2022-11-24 19:16:51 +01:00
duration = fields.TimeDelta(
"Duration",
domain=['OR',
('duration', '=', None),
('duration', '>=', TimeDelta()),
],
2016-03-21 23:27:20 +01:00
states={
'required': Eval('state') == 'done',
'readonly': Eval('state').in_(['done', 'draft', 'cancelled']),
})
2016-03-21 23:27:20 +01:00
cost = fields.Numeric('Cost', digits=price_digits, readonly=True)
company = fields.Function(
fields.Many2One('company.company', "Company"),
'on_change_with_company', searcher='search_company')
run_by = employee_field("Run By", states={
'readonly': Eval('state') != 'draft',
})
done_by = employee_field("Done By", states={
'readonly': Eval('state').in_(['draft', 'running']),
})
cancelled_by = employee_field("Cancelled By", states={
'readonly': Eval('state').in_(['draft', 'running']),
})
2016-03-21 23:27:20 +01:00
state = fields.Selection([
('draft', 'Draft'),
('running', 'Running'),
('done', 'Done'),
('cancelled', 'Cancelled'),
2022-02-15 00:08:47 +01:00
], "State", required=True, readonly=True, sort=False)
2016-03-21 23:27:20 +01:00
@classmethod
def __setup__(cls):
2025-04-04 00:45:18 +02:00
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
2024-07-27 18:36:14 +02:00
t, (t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['draft', 'running'])))
2016-03-21 23:27:20 +01:00
cls._transitions |= set((
('draft', 'running'),
('running', 'done'),
('draft', 'cancelled'),
('running', 'cancelled'),
))
cls._buttons.update({
'cancel': {
'invisible': Eval('state').in_(['done', 'cancelled']),
2018-02-19 11:39:16 +01:00
'depends': ['state'],
2016-03-21 23:27:20 +01:00
},
'run': {
'invisible': Eval('state') != 'draft',
2018-02-19 11:39:16 +01:00
'depends': ['state'],
2016-03-21 23:27:20 +01:00
},
'do': {
'invisible': Eval('state') != 'running',
2018-02-19 11:39:16 +01:00
'depends': ['state'],
2016-03-21 23:27:20 +01:00
},
})
@classmethod
def default_state(cls):
return 'draft'
@fields.depends('work', '_parent_work.company')
def on_change_with_company(self, name=None):
if self.work and self.work.company:
return self.work.company.id
@classmethod
def search_company(cls, name, clause):
return [('work.' + clause[0], *clause[1:])]
@classmethod
def copy(cls, cycles, default=None):
default = default.copy() if default is not None else {}
default.setdefault('run_by')
default.setdefault('done_by')
default.setdefault('cancelled_by')
return super().copy(cycles, default=default)
2016-03-21 23:27:20 +01:00
@classmethod
@ModelView.button
@set_work_state
@Workflow.transition('cancelled')
@set_employee('cancelled_by')
2016-03-21 23:27:20 +01:00
def cancel(cls, cycles):
pass
@classmethod
@ModelView.button
@set_work_state
@Workflow.transition('running')
@set_employee('run_by')
2016-03-21 23:27:20 +01:00
def run(cls, cycles):
pass
@classmethod
@ModelView.button
@set_work_state
@Workflow.transition('done')
@set_employee('done_by')
2016-03-21 23:27:20 +01:00
def do(cls, cycles):
now = datetime.datetime.now()
for cycle in cycles:
cycle.set_duration(now)
cycle.set_cost()
cls.save(cycles)
def set_duration(self, now):
if self.duration is None:
self.duration = now - self.last_modified_at
2016-03-21 23:27:20 +01:00
def set_cost(self):
if self.cost is None:
center = self.work.work_center
if center.cost_method == 'cycle':
self.cost = center.cost_price
elif center.cost_method == 'hour':
hours = self.duration.total_seconds() / (60 * 60)
self.cost = center.cost_price * Decimal(str(hours))
2020-04-23 21:44:00 +02:00
self.cost = round_price(self.cost)