From 26e104890cb7a0bf96b8e06db95c6f74284ba1ef Mon Sep 17 00:00:00 2001 From: Albert Cervera i Areny Date: Mon, 7 Mar 2016 09:18:46 +0100 Subject: [PATCH] Rename module from production_external to production_subcontract. Make basic workflow work. Missing parts: - Synchronize quantities between production and internal shipment - Improve internal shipment or change shipment type so multiple locations can be used in the same document. - Allow using produced quantities as supplier invoice quantity --- README | 2 +- __init__.py | 3 +- production.py | 208 +++++++++----- production.xml | 12 + setup.py | 2 +- tests/__init__.py | 2 +- tests/scenario_production.rst | 267 ++++++++++++++++++ ...rnal.py => test_production_subcontract.py} | 9 +- view/bom_form.xml | 10 + view/bom_list.xml | 9 + view/party_form.xml | 4 +- view/production_form.xml | 8 +- view/production_list.xml | 2 +- 13 files changed, 452 insertions(+), 86 deletions(-) create mode 100644 tests/scenario_production.rst rename tests/{test_production_external.py => test_production_subcontract.py} (59%) create mode 100644 view/bom_form.xml create mode 100644 view/bom_list.xml diff --git a/README b/README index 6a0e965..d7915fc 100644 --- a/README +++ b/README @@ -20,7 +20,7 @@ questions on the NaN·tic bug tracker, mailing list, wiki or IRC channel: * http://doc.tryton-erp.es/ -* http://bitbucket.org/nantic/trytond-production_external +* http://bitbucket.org/nantic/trytond-production_subcontract * http://groups.tryton.org/ * http://wiki.tryton.org/ * irc://irc.freenode.net/tryton diff --git a/__init__.py b/__init__.py index 802fa9a..69826ed 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,7 @@ def register(): Pool.register( Party, PurchaseRequest, + BOM, Production, Purchase, - module='production_external', type_='model') + module='production_subcontract', type_='model') diff --git a/production.py b/production.py index 57bae7a..7640015 100644 --- a/production.py +++ b/production.py @@ -1,29 +1,29 @@ # The COPYRIGHT file at the top level of this repository contains the full # copyright notices and license terms. from trytond.pool import Pool, PoolMeta -from trytond.model import ModelView, fields -from trytond.pyson import Eval +from trytond.model import ModelView, Workflow, fields +from trytond.pyson import Eval, Bool -__all__ = ['Party', 'PurchaseRequest', 'Production', 'Purchase'] -__metaclass__ = PoolMeta +__all__ = ['Party', 'PurchaseRequest', 'BOM', 'Production', 'Purchase'] class Party: __name__ = 'party.party' - # Should be a property - # Should probably be external_warehouse - external_location = fields.Many2One('stock.location', 'External Location', - domain=[ - ('type', '=', 'storage'), - ]) + __metaclass__ = PoolMeta + # TODO: Should be a property + production_warehouse = fields.Property(fields.Many2One('stock.location', + 'Production Warehouse', domain=[ + ('type', '=', 'warehouse'), + ])) class PurchaseRequest: __name__ = 'purchase.request' + __metaclass__ = PoolMeta @classmethod - def origin_get(cls): - res = super(PurchaseRequest, cls).origin_get() + def get_origin(cls): Model = Pool().get('ir.model') + res = super(PurchaseRequest, cls).get_origin() models = Model.search([ ('model', '=', 'production'), ]) @@ -32,17 +32,35 @@ class PurchaseRequest: return res +class BOM: + __name__ = 'production.bom' + __metaclass__ = PoolMeta + subcontract_product = fields.Many2One('product.product', + 'Subcontract Product', domain=[ + ('purchasable', '=', True), + ('type', '=', 'service'), + ]) + + class Production: __name__ = 'production' + __metaclass__ = PoolMeta subcontract_product = fields.Many2One('product.product', - 'Subcontract Product', domain=[('purchasable', '=', True)]) + 'Subcontract Product', domain=[ + ('purchasable', '=', True), + ('type', '=', 'service'), + ]) purchase_request = fields.Many2One('purchase.request', 'Purchase Request', readonly=True) - outgoing_shipment = fields.Many2One('stock.shipment.internal', - 'Outgoing Shipment', readonly=True) incoming_shipment = fields.Many2One('stock.shipment.internal', - 'Internal Shipment', readonly=True) + 'Incoming Shipment', readonly=True) + destination_warehouse = fields.Many2One('stock.location', + 'Destination Warehouse', domain=[ + ('type', '=', 'warehouse'), + ], readonly=True) + supplier = fields.Function(fields.Many2One('party.party', 'Supplier'), + 'get_supplier', searcher='search_supplier') @classmethod def __setup__(cls): @@ -51,114 +69,156 @@ class Production: # created but purchase order is not in processing state. cls._buttons.update({ 'create_purchase_request': { - 'invisible': ~Eval('state').in_(['draft', 'waiting']), + 'invisible': ~(Eval('state').in_(['draft', 'waiting']) & + Bool(Eval('subcontract_product')) & + ~Bool(Eval('purchase_request'))), 'icon': 'tryton-go-home', } }) + def get_supplier(self, name): + return (self.purchase_request.party.id if self.purchase_request and + self.purchase_request.party else None) + + @classmethod + def search_supplier(cls, name, clause): + return [('purchase_request.party',) + tuple(clause[1:])] + @classmethod def copy(cls, productions, default=None): if default is None: default = {} default['purchase_request'] = None - default['outgoing_shipment'] = None default['incoming_shipment'] = None return super(Production, cls).copy(productions, default) @classmethod @ModelView.button def create_purchase_request(cls, productions): - PurchaseRequest = Pool().get('purchase.request') for production in productions: if not production.subcontract_product: continue if not production.state in ('draft', 'waiting'): continue - request, = PurchaseRequest.create([{ - 'product': production.subcontract_product.id, - 'company': production.company.id, - 'uom': production.subcontract_product.default_uom.id, - 'quantity': production.quantity, - 'computed_quantity': production.quantity, - 'warehouse': production.warehouse.id, - 'origin': ('production', production.id), - }]) + request = production._get_purchase_request() + request.save() production.purchase_request = request production.save() + def on_change_product(self): + res = super(Production, self).on_change_product() + if self.bom: + res['subcontract_product'] = (self.bom.subcontract_product.id if + self.bom.subcontract_product else None) + return res + + def on_change_bom(self): + res = super(Production, self).on_change_bom() + if self.bom: + res['subcontract_product'] = (self.bom.subcontract_product.id if + self.bom.subcontract_product else None) + return res + + def _get_purchase_request(self): + PurchaseRequest = Pool().get('purchase.request') + return PurchaseRequest( + product=self.subcontract_product, + company=self.company, + uom=self.subcontract_product.default_uom, + quantity=self.quantity, + computed_quantity=self.quantity, + warehouse=self.warehouse, + origin=self, + ) + @classmethod def process_purchase_request(cls, productions): pool = Pool() ShipmentInternal = pool.get('stock.shipment.internal') - Move = pool.get('stock.move') for production in productions: - # Create outgoing internal shipment - shipment = ShipmentInternal() + if not (production.purchase_request and + production.purchase_request.purchase and + production.purchase_request.purchase.state == 'processing'): + continue + if production.destination_warehouse: + continue + subcontract_warehouse = production._get_subcontract_warehouse() + production.destination_warehouse = production.warehouse + production.warehouse = subcontract_warehouse + from_location = production.warehouse.storage_location - purchase = production.purchase_request.purchase_line.purchase - to_location = purchase.party.external_location - shipment.from_location = from_location - shipment.to_location = to_location - shipment.moves = [] - for input_ in production.inputs: - move = Move() - move.shipment = shipment - move.from_location = from_location - move.to_location = to_location - move.product = input_.product - # TODO: Support lots - move.quantity = input_.quantity - move.uom = input_.uom - shipment.moves.append(move) - shipment.save() - production.outgoing_shipment = shipment - - # Create incoming internal shipment - - # TODO: Production location should be taken from the destination - # warehouse - tmp = from_location - from_location = to_location - to_location = tmp + to_location = production.destination_warehouse.storage_location shipment = ShipmentInternal() shipment.from_location = from_location shipment.to_location = to_location shipment.moves = [] for output in production.outputs: - move = Move() - move.from_location = from_location - move.to_location = to_location - move.product = output.product - # TODO: Support lots - move.quantity = output.quantity - move.uom = output.uom + move = production._get_incoming_shipment_move(output, + from_location, to_location) shipment.moves.append(move) shipment.save() + ShipmentInternal.wait([shipment]) production.incoming_shipment = shipment - location = from_location - # Update production - #production.warehouse = + storage_location = production.warehouse.storage_location + production_location = production.warehouse.production_location for move in production.inputs: - move.from_location = location + move.from_location = storage_location + move.to_location = production_location move.save() - for move in production.outputs: - move.to_location = location + move.from_location = production_location + move.to_location = storage_location move.save() - production.save() + def _get_incoming_shipment_move(self, output, from_location, to_location): + Move = Pool().get('stock.move') + return Move( + from_location=from_location, + to_location=to_location, + product=output.product, + # TODO: Support lots + quantity=output.quantity, + uom=output.uom, + ) - # Missing function to synchronize output production moves with incoming - # internal shipment. Should emulate behaviour of ShipmentOut and ShipmentIn - # where there is no direct linke between stock moves but are calculated by - # product and quantities. See _sync_inventory_to_outgoing in + def _get_subcontract_warehouse(self): + return self.purchase_request.party.production_warehouse + + @classmethod + def write(cls, *args): + actions = iter(args) + to_update = [] + for productions, values in zip(actions, actions): + if 'outputs' in values: + to_update.extend(productions) + super(Production, cls).write(*args) + if to_update: + Production._sync_outputs_to_shipment(to_update) + + # TODO: Missing function to synchronize output production moves with + # incoming internal shipment. Should emulate behaviour of ShipmentOut and + # ShipmentIn where there is no direct linke between stock moves but are + # calculated by product and quantities. See _sync_inventory_to_outgoing in # stock/shipment.py. + @classmethod + @ModelView.button + @Workflow.transition('done') + def done(cls, productions): + InternalShipment = Pool().get('stock.shipment.internal') + super(Production, cls).done(productions) + shipments = [x.incoming_shipment for x in productions if + x.incoming_shipment] + if shipments: + InternalShipment.assign_try(shipments) + +# TODO: Internal shipment should be updated each time outputs are changed class Purchase: __name__ = 'purchase.purchase' + __metaclass__ = PoolMeta @classmethod def process(cls, purchases): diff --git a/production.xml b/production.xml index 1c9964f..b19a03a 100644 --- a/production.xml +++ b/production.xml @@ -19,5 +19,17 @@ production_list + + production.bom + form + + bom_form + + + production.bom + tree + + bom_list + diff --git a/setup.py b/setup.py index 55cb5e1..7134f12 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ import re import os import ConfigParser -MODULE = 'production_external' +MODULE = 'production_subcontract' PREFIX = 'nantic' MODULE2PREFIX = {} diff --git a/tests/__init__.py b/tests/__init__.py index c619d36..ee87d85 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ # The COPYRIGHT file at the top level of this repository contains the full # copyright notices and license terms. -from .test_production_external import suite +from .test_production_subcontract import suite diff --git a/tests/scenario_production.rst b/tests/scenario_production.rst new file mode 100644 index 0000000..29a9055 --- /dev/null +++ b/tests/scenario_production.rst @@ -0,0 +1,267 @@ +=================== +Production Scenario +=================== + +============= +General Setup +============= + +Imports:: + + >>> import datetime + >>> from dateutil.relativedelta import relativedelta + >>> from decimal import Decimal + >>> from proteus import config, Model, Wizard + >>> today = datetime.date.today() + >>> yesterday = today - relativedelta(days=1) + +Create database:: + + >>> config = config.set_trytond() + >>> config.pool.test = True + +Install production Module:: + + >>> Module = Model.get('ir.module.module') + >>> modules = Module.find([('name', '=', 'production_subcontract')]) + >>> Module.install([x.id for x in modules], config.context) + >>> Wizard('ir.module.module.install_upgrade').execute('upgrade') + +Create company:: + + >>> Currency = Model.get('currency.currency') + >>> CurrencyRate = Model.get('currency.currency.rate') + >>> Company = Model.get('company.company') + >>> Party = Model.get('party.party') + >>> company_config = Wizard('company.company.config') + >>> company_config.execute('company') + >>> company = company_config.form + >>> party = Party(name='Dunder Mifflin') + >>> party.save() + >>> company.party = party + >>> currencies = Currency.find([('code', '=', 'USD')]) + >>> if not currencies: + ... currency = Currency(name='Euro', symbol=u'$', code='USD', + ... rounding=Decimal('0.01'), mon_grouping='[3, 3, 0]', + ... mon_decimal_point=',') + ... currency.save() + ... CurrencyRate(date=today + relativedelta(month=1, day=1), + ... rate=Decimal('1.0'), currency=currency).save() + ... else: + ... currency, = currencies + >>> company.currency = currency + >>> company_config.execute('add') + >>> company, = Company.find() + +Reload the context:: + + >>> User = Model.get('res.user') + >>> config._context = User.get_preferences(True, config.context) + +Create supplier warehouse:: + + >>> Location = Model.get('stock.location') + >>> supplier_storage = Location(name='Supplier Storage', type='storage') + >>> supplier_storage.save() + >>> supplier_input = Location(name='Supplier Input', type='storage') + >>> supplier_input.save() + >>> supplier_output = Location(name='Supplier Output', type='storage') + >>> supplier_output.save() + >>> supplier_production = Location(name='Supplier Production', + ... type='storage') + >>> supplier_production.save() + >>> supplier_warehouse = Location() + >>> supplier_warehouse.type = 'warhouse' + >>> supplier_warehouse.name = 'Supplier Warehouse' + >>> supplier_warehouse.storage_location = supplier_storage + >>> supplier_warehouse.input_location = supplier_input + >>> supplier_warehouse.output_location = supplier_output + >>> supplier_warehouse.production_location = supplier_production + >>> supplier_warehouse.save() + +Create supplier:: + + >>> Party = Model.get('party.party') + >>> party = Party(name='Supplier') + >>> party.production_warehouse = supplier_warehouse + +Create product:: + + >>> ProductUom = Model.get('product.uom') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + >>> ProductTemplate = Model.get('product.template') + >>> Product = Model.get('product.product') + >>> product = Product() + >>> template = ProductTemplate() + >>> template.name = 'product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal(30) + >>> template.cost_price = Decimal(20) + >>> template.save() + >>> product.template = template + >>> product.save() + +Create Components:: + + >>> component1 = Product() + >>> template1 = ProductTemplate() + >>> template1.name = 'component 1' + >>> template1.default_uom = unit + >>> template1.type = 'goods' + >>> template1.list_price = Decimal(5) + >>> template1.cost_price = Decimal(1) + >>> template1.save() + >>> component1.template = template1 + >>> component1.save() + + >>> meter, = ProductUom.find([('name', '=', 'Meter')]) + >>> centimeter, = ProductUom.find([('name', '=', 'centimeter')]) + >>> component2 = Product() + >>> template2 = ProductTemplate() + >>> template2.name = 'component 2' + >>> template2.default_uom = meter + >>> template2.type = 'goods' + >>> template2.list_price = Decimal(7) + >>> template2.cost_price = Decimal(5) + >>> template2.save() + >>> component2.template = template2 + >>> component2.save() + +Create Subcontract Product:: + + >>> subcontract = Product() + >>> stemplate = ProductTemplate() + >>> stemplate.name = 'Subcontract' + >>> stemplate.default_uom = unit + >>> stemplate.type = 'service' + >>> stemplate.list_price = Decimal(0) + >>> stemplate.cost_price = Decimal(100) + >>> stemplate.save() + >>> subcontract.template = stemplate + >>> subcontract.save() + +Create Bill of Material:: + + >>> BOM = Model.get('production.bom') + >>> BOMInput = Model.get('production.bom.input') + >>> BOMOutput = Model.get('production.bom.output') + >>> bom = BOM(name='product', subcontract_product=subcontract) + >>> input1 = BOMInput() + >>> bom.inputs.append(input1) + >>> input1.product = component1 + >>> input1.quantity = 5 + >>> input2 = BOMInput() + >>> bom.inputs.append(input2) + >>> input2.product = component2 + >>> input2.quantity = 150 + >>> input2.uom = centimeter + >>> output = BOMOutput() + >>> bom.outputs.append(output) + >>> output.product = product + >>> output.quantity = 1 + >>> bom.save() + + >>> ProductBom = Model.get('product.product-production.bom') + >>> product.boms.append(ProductBom(bom=bom)) + >>> product.save() + +Create an Inventory:: + + >>> Inventory = Model.get('stock.inventory') + >>> InventoryLine = Model.get('stock.inventory.line') + >>> Location = Model.get('stock.location') + >>> storage = supplier_warehouse.storage_location + >>> inventory = Inventory() + >>> inventory.location = storage + >>> inventory_line1 = InventoryLine() + >>> inventory.lines.append(inventory_line1) + >>> inventory_line1.product = component1 + >>> inventory_line1.quantity = 20 + >>> inventory_line2 = InventoryLine() + >>> inventory.lines.append(inventory_line2) + >>> inventory_line2.product = component2 + >>> inventory_line2.quantity = 6 + >>> inventory.save() + >>> Inventory.confirm([inventory.id], config.context) + >>> inventory.state + u'done' + +Make a production:: + + >>> warehouse = Location.find(['code', '=', 'WH']) + >>> Production = Model.get('production') + >>> production = Production() + >>> production.warehouse = warehouse + >>> production.product = product + >>> production.bom = bom + >>> production.quantity = 2 + >>> sorted([i.quantity for i in production.inputs]) == [10, 300] + True + >>> output, = production.outputs + >>> output.quantity == 2 + True + >>> production.cost + Decimal('25.0') + >>> production.save() + >>> Production.wait([production.id], config.context) + >>> production.state + u'waiting' + >>> Production.assign_try([production.id], config.context) + True + >>> production.reload() + >>> all(i.state == 'assigned' for i in production.inputs) + True + >>> Production.run([production.id], config.context) + >>> production.reload() + >>> all(i.state == 'done' for i in production.inputs) + True + >>> len(set(i.effective_date == today for i in production.inputs)) + 1 + >>> Production.done([production.id], config.context) + >>> production.reload() + >>> output, = production.outputs + >>> output.state + u'done' + >>> output.effective_date == production.effective_date + True + >>> config._context['locations'] = [storage.id] + >>> product.reload() + >>> product.quantity == 2 + True + +Make a production with effective date yesterday:: + + >>> Production = Model.get('production') + >>> production = Production() + >>> production.effective_date = yesterday + >>> production.product = product + >>> production.bom = bom + >>> production.quantity = 2 + >>> production.subcontract_product == subcontract + >>> production.click('wait') + >>> production.click('create_purchase_request') + +Process purchase request:: + + >>> purchase_request = production.purchase_request + >>> create_purchase = Wizard('purchase.request.create_purchase', + ... [purchase_request]) + >>> purchase_request.reload() + >>> purchase = purchase_request.purchase + >>> purchase.click('quotation') + >>> purchase.click('confirm') + >>> purchase.click('process') + >>> production.reload() + >>> production.warehouse = supplier_warehouse + >>> production.destination_warehouse = warehouse + >>> shipment = production.incoming_shipment + +Process production:: + + >>> Production.assign_try([production.id], config.context) + True + >>> production.click('run') + >>> production.reload() + >>> shipment.reload() + >>> shipment.state = 'reserved' diff --git a/tests/test_production_external.py b/tests/test_production_subcontract.py similarity index 59% rename from tests/test_production_external.py rename to tests/test_production_subcontract.py index 2947290..d97cacc 100644 --- a/tests/test_production_external.py +++ b/tests/test_production_subcontract.py @@ -2,19 +2,21 @@ # The COPYRIGHT file at the top level of this repository contains the full # copyright notices and license terms. import unittest +import doctest import trytond.tests.test_tryton from trytond.tests.test_tryton import test_view, test_depends +from trytond.tests.test_tryton import doctest_setup, doctest_teardown class TestCase(unittest.TestCase): 'Test module' def setUp(self): - trytond.tests.test_tryton.install_module('production_external') + trytond.tests.test_tryton.install_module('production_subcontract') def test0005views(self): 'Test views' - test_view('production_external') + test_view('production_subcontract') def test0006depends(self): 'Test depends' @@ -24,4 +26,7 @@ class TestCase(unittest.TestCase): def suite(): suite = trytond.tests.test_tryton.suite() suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestCase)) + suite.addTests(doctest.DocFileSuite('scenario_production.rst', + setUp=doctest_setup, tearDown=doctest_teardown, encoding='utf-8', + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/view/bom_form.xml b/view/bom_form.xml new file mode 100644 index 0000000..7cd1f7b --- /dev/null +++ b/view/bom_form.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/view/bom_list.xml b/view/bom_list.xml new file mode 100644 index 0000000..0317464 --- /dev/null +++ b/view/bom_list.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/view/party_form.xml b/view/party_form.xml index 491a9c3..48e0efc 100644 --- a/view/party_form.xml +++ b/view/party_form.xml @@ -3,7 +3,7 @@ copyright notices and license terms. --> - diff --git a/view/production_form.xml b/view/production_form.xml index 45209ab..d3f21db 100644 --- a/view/production_form.xml +++ b/view/production_form.xml @@ -9,12 +9,14 @@ colspan="2"/> + position="after"> diff --git a/view/production_list.xml b/view/production_list.xml index b41af90..b29c93d 100644 --- a/view/production_list.xml +++ b/view/production_list.xml @@ -3,6 +3,6 @@ copyright notices and license terms. --> - +