From f1e1dd357d789a2d32f0fb1b8d3a5cb04ec52deb Mon Sep 17 00:00:00 2001 From: Bertrand Chenal Date: Thu, 4 Sep 2008 23:27:49 +0200 Subject: [PATCH] Supply module: initial commit --- __init__.py | 6 + __tryton__.py | 23 ++++ order_point.py | 41 +++++++ order_point.xml | 59 +++++++++ product.py | 20 +++ product.xml | 24 ++++ purchase_request.py | 198 ++++++++++++++++++++++++++++++ purchase_request.xml | 103 ++++++++++++++++ supply_schedule.py | 286 +++++++++++++++++++++++++++++++++++++++++++ supply_schedule.xml | 12 ++ 10 files changed, 772 insertions(+) create mode 100644 __init__.py create mode 100644 __tryton__.py create mode 100644 order_point.py create mode 100644 order_point.xml create mode 100644 product.py create mode 100644 product.xml create mode 100644 purchase_request.py create mode 100644 purchase_request.xml create mode 100644 supply_schedule.py create mode 100644 supply_schedule.xml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..3cebf17cf2 --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +#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 order_point import * +from product import * +from purchase_request import * +from supply_schedule import * diff --git a/__tryton__.py b/__tryton__.py new file mode 100644 index 0000000000..56e3240af3 --- /dev/null +++ b/__tryton__.py @@ -0,0 +1,23 @@ +#This file is part of Tryton. The COPYRIGHT file at the top level of this repository contains the full copyright notices and license terms. +{ + 'name': 'Supply Management', + 'version': '0.1', + 'author': 'B2CK', + 'email': 'info@b2ck.com', + 'website': 'http://www.tryton.org/', + 'category': 'Generic', + 'description': 'Supply Management Module', + 'depends': [ + 'product', + 'stock', + 'purchase', + ], + 'xml': [ + 'product.xml', + 'order_point.xml', + 'purchase_request.xml', + 'supply_schedule.xml', + ], + 'translation': [ + ] +} diff --git a/order_point.py b/order_point.py new file mode 100644 index 0000000000..c4cc1f33a5 --- /dev/null +++ b/order_point.py @@ -0,0 +1,41 @@ +#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.osv import fields, OSV +from trytond.wizard import Wizard, WizardOSV + +class OrderPoint(OSV): + """ + Order Point: Provide a way to define a supply policy for each + product on each locations. Order points on warehouse are + conciderer by the supply scheduler to generate purchase requests. + """ + _name = 'supply.order_point' + _description = __doc__ + + product = fields.Many2One( + 'product.product', 'Product', required=True, select=True) + location = fields.Many2One( + 'stock.location', 'Location', required=True, select=True) + min_quantity = fields.Float('Minimal Quantity', required=True) + max_quantity = fields.Float('Maximal Quantity', required=True) + company = fields.Many2One('company.company', 'Company', required=True) + + def default_company(self, cursor, user, context=None): + company_obj = self.pool.get('company.company') + if context is None: + context = {} + if context.get('company'): + return company_obj.name_get(cursor, user, context['company'], + context=context)[0] + return False + + def __init__(self): + super(OrderPoint, self).__init__() + self._sql_constraints += [ + ('product_location_uniq', 'UNIQUE(product,location,company)', + 'Only one order point is allowed for each product-location pair.'), + ('check_min_max_quantity', + 'CHECK( max_quantity is null or min_quantity is null or max_quantity >= min_quantity )', + 'Maximal quantity must be bigger than Minimal quantity'), + ] + +OrderPoint() diff --git a/order_point.xml b/order_point.xml new file mode 100644 index 0000000000..87c4d81220 --- /dev/null +++ b/order_point.xml @@ -0,0 +1,59 @@ + + + + + + supply.order_point + form + + + + + supply.order_point + tree + + + + + + + + + ]]> + + + + + Order Points + supply.order_point + form + + + + + + + + + + + + + + diff --git a/product.py b/product.py new file mode 100644 index 0000000000..c27d1f25fe --- /dev/null +++ b/product.py @@ -0,0 +1,20 @@ +#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.osv import fields, OSV +import datetime + +class Product(OSV): + "Product" + _name = "product.product" + + order_points = fields.One2Many( + 'supply.order_point', 'product', 'Order Points') + +Product() + +class ProductSupplier(OSV): + 'Product Supplier' + _name = 'purchase.product_supplier' + + lead_time = fields.Integer("Lead Time") # XXX put it on the party obj ? + +ProductSupplier() diff --git a/product.xml b/product.xml new file mode 100644 index 0000000000..10f7de925c --- /dev/null +++ b/product.xml @@ -0,0 +1,24 @@ + + + + + + + purchase.product_supplier + form + + + + + + + ]]> + + + + + diff --git a/purchase_request.py b/purchase_request.py new file mode 100644 index 0000000000..8ac56b884b --- /dev/null +++ b/purchase_request.py @@ -0,0 +1,198 @@ +#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.osv import fields, OSV +from trytond.wizard import Wizard, WizardOSV +import datetime + +# TODO ensure that the link p_request p_line is never inconsistent +# (uom, qty, product, ...). +class PurchaseRequest(OSV): + 'Purchase Request' + _name = 'supply.purchase_request' + _description = __doc__ + + product = fields.Many2One( + 'product.product', 'Product', required=True, select=True) + party = fields.Many2One('relationship.party', 'Party', select=True) + quantity = fields.Float('Quantity', required=True) + uom = fields.Many2One('product.uom', 'UOM', required=True, select=True) + purchase_date = fields.Date('Expected Purchase Date') + supply_date = fields.Date('Expected Suplly Date') + stock_level = fields.Float('Stock at Supply Date') + warehouse = fields.Many2One( + 'stock.location', "Warehouse", required=True, + domain="[('type', '=', 'warehouse')]") + purchase_line = fields.Many2One('purchase.line', 'Purchase Line') + company = fields.Many2One('company.company', 'Company', required=True) + + def default_company(self, cursor, user, context=None): + company_obj = self.pool.get('company.company') + if context is None: + context = {} + if context.get('company'): + return company_obj.name_get(cursor, user, context['company'], + context=context)[0] + return False + +PurchaseRequest() + + +class CreatePurchaseAsk(WizardOSV): + _name = 'supply.purchase_request.create_purchase.ask' + party = fields.Many2One('relationship.party', 'Supplier', readonly=True) + company = fields.Many2One('company.company', 'Company', readonly=True) + payment_term = fields.Many2One( + 'account.invoice.payment_term', 'Payment Term', required=True) + +CreatePurchaseAsk() + +class CreatePurchase(Wizard): + 'Create Purchase' + _name = 'supply.purchase_request.create_purchase' + + states = { + + 'init': { + 'result': { + 'type': 'action', + 'action': '_compute_purchase', + 'state': 'choice', + }, + }, + + 'choice': { + 'result': { + 'type': 'choice', + 'next_state': '_check_payment_term', + }, + }, + + 'ask_user': { + 'actions': ['_set_default'], + 'result': { + 'type': 'form', + 'object': 'supply.purchase_request.create_purchase.ask', + 'state': [ + ('end', 'Cancel', 'tryton-cancel'), + ('choice', 'Continue', 'tryton-ok', True), + ], + }, + }, + + 'create': { + 'result': { + 'type': 'action', + 'action': '_create_purchase', + 'state': 'end', + }, + }, + + } + + def _set_default(self, cursor, user, data, context=None): + + if not data.get('party_wo_pt'): + return {} + party, company = data['party_wo_pt'].pop() + return {'party': party,'company': company} + + def _check_payment_term(self, cursor, user, data, context=None): + party_obj = self.pool.get('relationship.party') + if 'purchases' not in data: + return 'end' + form = data['form'] + if form.get('payment_term') and form.get('party') and \ + form.get('company'): + for key, val in data['purchases'].iteritems(): + if (key[0], key[1]) == (form['party'], + form['company']): + val['payment_term'] = form['payment_term'] + local_context = context.copy() + local_context['company'] = form['company'] + party_obj.write( + cursor, user, form['party'], + {'supplier_payment_term': form['payment_term']}, context=local_context) + if data.get('party_wo_pt'): + return 'ask_user' + return 'create' + + def _compute_purchase(self, cursor, user, data, context=None): + request_obj = self.pool.get('supply.purchase_request') + requests = request_obj.browse(cursor, user, data['ids'], context=context) + purchases = {} + for request in requests: + if (not request.party) or request.purchase_line: + continue + key = (request.party.id, request.company.id, request.warehouse.id) + + if key not in purchases: + purchase = { + 'company': request.company.id, + 'party': request.party.id, + 'purchase_date': request.purchase_date or datetime.date.today(), + 'payment_term': request.party.supplier_payment_term and \ + request.party.supplier_payment_term.id or None, + 'warehouse': request.warehouse.id, + 'currency': request.company.currency.id, + 'lines': [], + } + purchases[key] = purchase + else: + purchase = purchases[key] + + purchase['lines'].append({ + 'product': request.product.id, + 'unit': request.uom.id, + 'quantity': request.quantity, + 'request': request.id, + }) + if request.purchase_date: + if not purchase['purchase_date']: + purchase['purchase_date'] = request.purchase_date + else: + purchase['purchase_date'] = min(purchase['purchase_date'], + request.purchase_date) + data['purchases'] = purchases + data['party_wo_pt'] = set( + k[:2] for k in purchases if not purchases[k]['payment_term']) + return {} + + def _create_purchase(self, cursor, user, data, context=None): + request_obj = self.pool.get('supply.purchase_request') + purchase_obj = self.pool.get('purchase.purchase') + line_obj = self.pool.get('purchase.line') + product_obj = self.pool.get('product.product') + created_ids = [] + products = [] + # collect product names + for purchase in data['purchases'].itervalues(): + for line in purchase['lines']: + products.append(line['product']) + product_name = product_obj.name_get( + cursor, user, products, context=context) + + # create purchases, lines and update requests + for purchase in data['purchases'].itervalues(): + purchase_lines = purchase.pop('lines') + purchase_id = purchase_obj.create( + cursor, user, purchase, context=context) + created_ids.append(purchase_id) + for line in purchase_lines: + request_id = line.pop('request') + line['purchase'] = purchase_id + line['description'] = product_name[line['product']][1] + local_context = context.copy() + local_context['uom'] = line['unit'] + local_context['supplier'] = purchase['party'] + local_context['currency'] = purchase['currency'] + product_price = product_obj.get_purchase_price( + cursor, user, [line['product']], line['quantity'], + context=local_context)[line['product']] + line['unit_price'] = product_price + line_id = line_obj.create(cursor, user, line, context=context) + request_obj.write( + cursor, user, request_id, {'purchase_line': line_id}, + context=context) + + return {} + +CreatePurchase() diff --git a/purchase_request.xml b/purchase_request.xml new file mode 100644 index 0000000000..d005dd7529 --- /dev/null +++ b/purchase_request.xml @@ -0,0 +1,103 @@ + + + + + + supply.purchase_request + form + + + + + supply.purchase_request + tree + + + + + + + + + + + + + + ]]> + + + + + Purchase Requests + supply.purchase_request + form + + + + + + + + + + + + + + + + + supply.purchase_request.create_purchase.ask + form + + + + + Create Purchase + supply.purchase_request.create_purchase + + + form_action + supply.purchase_request,0 + + + + + diff --git a/supply_schedule.py b/supply_schedule.py new file mode 100644 index 0000000000..3fd1da8f9d --- /dev/null +++ b/supply_schedule.py @@ -0,0 +1,286 @@ +from trytond.wizard import Wizard, WizardOSV +import datetime +# * Generate for X day in advance ? +# * Provide a way to choose how mutch day before the shortage the suplly +# must be planned + +class GeneratePurchaseRequest(Wizard): + 'Generate Purchase Requests' + _name = 'supply.generate_purchase_request' + states = { + 'init': { + 'result': { + 'type': 'action', + 'action': 'generate_requests', + 'state': 'end', + }, + }, + } + + + def generate_requests(self, cursor, user, data, context=None): + """ + For each order point compute the purchase that must be create + today to meet product outputs. + """ + order_point_obj = self.pool.get('supply.order_point') + purchase_request_obj = self.pool.get('supply.purchase_request') + product_obj = self.pool.get('product.product') + order_point_ids = order_point_obj.search( + cursor, user, [], context=context) + order_points = order_point_obj.browse( #XXX filter on company ?? + cursor, user, order_point_ids, context=context) + + product_ids = [op.product.id for op in order_points] + products = product_obj.browse(cursor, user, product_ids, context=context) + local_context = context.copy() + new_requests = [] + + + for product in products: + # Get min and max supply dates + min_date, max_date = self.get_supply_dates( + cursor, user, product, context=context) + # Fetch all locations for whose this product has an order point + locations = [op.location.id for op in product.order_points] + # Compute stock + local_context.update({'stock_date_end': min_date or datetime.date.max}) + pbl = product_obj.products_by_location( + cursor, user, locations, [product.id], with_childs=True, + skip_zero=False, context=local_context) + print "DATES",min_date, max_date, pbl + for order_point in product.order_points: + # ignore order point on other locations than warehouse + if order_point.location.type != 'warehouse': + continue + print "OP",order_point.id, order_point.location.id, product.id + # Search for shortage between min-max + shortage_date, stock_quantity = self.get_shortage( + cursor, user, order_point, min_date, max_date, + min_date_stock=pbl[order_point.location.id, product.id], + context=context) + + print "REQUEST",order_point.product.name,order_point.location.name, shortage_date, stock_quantity + + if shortage_date == None or stock_quantity == None: + continue + request_val = self.compute_request( + cursor, user, order_point, shortage_date, + stock_quantity, context=context) + + new_requests.append(request_val) + self.create_requests(cursor, user, new_requests, context=context) + return {} + + def create_requests(self, cursor, user, new_requests, context=None): + """ + Compare new_requests with already existing request and avoid + to re-create existing requests. + """ + # delete purchase request without a purchase line + uom_obj = self.pool.get('product.uom') + request_obj = self.pool.get('supply.purchase_request') + product_supplier_obj = self.pool.get('purchase.product_supplier') + req_ids = request_obj.search( + cursor, user, [('purchase_line', '=', False)], context=context) + request_obj.delete(cursor, user, req_ids, context=context) + + req_ids = request_obj.search(cursor, user, [], context=context) + requests = request_obj.browse(cursor, user, req_ids, context=context) + # Fetch lead_times for each (product,supplier) + sup_lead_time = {} + for request in requests: + product, supplier = None, None + if request.purchase_line: + product = request.purchase_line.product.id + supplier = request.purchase_line.purchase.party.id + else: + product = request.product.id + supplier = request.party and request.party.id + if not supplier: + continue + sup_lead_time[product, supplier] = None + + print "TUPLE",['OR', ] + [ + ['AND', ('product', '=', x[0]), ('party', '=', x[1])] \ + for x in sup_lead_time.iterkeys() + ] + + prod_sup_ids = product_supplier_obj.search( + cursor, user, + ['OR', ] + [ + ['AND', ('product', '=', x[0]), ('party', '=', x[1])] \ + for x in sup_lead_time.iterkeys() + ], + context=context) + for prod_sup in product_supplier_obj.browse(cursor, user, prod_sup_ids, + context=context): + sup_lead_time[prod_sup.product.id, prod_sup.party.id] = \ + prod_sup.lead_time + + # Fetch data from existing requests + existing_req = {} + for request in requests: + if request.purchase_line: + product = request.purchase_line.product + warehouse = request.purchase_line.purchase.warehouse + purchase_date = request.purchase_line.purchase.purchase_date + qty = line.quantity + uom = line.unit + supplier = line.purchase.party + else: + product = request.product + warehouse = request.warehouse + purchase_date = request.purchase_date + qty = request.quantity + uom = request.uom + supplier = request.party.id + + lead_time = sup_lead_time.get((product.id, supplier.id)) + if lead_time: + supply_date = purchase_date + \ + datetime.timedelta(lead_time) + else: + supply_date = datetime.date.max + + existing_req.setdefault((product.id, warehouse.id), []).append( + {'supply_date': supply_date, 'quantity': qty, 'uom': uom}) + + for i in existing_req.itervalues(): + i.sort + print "OLD_REQ",existing_req + print "NEW_REQ", new_requests + # Update new requests to take existing requests into account + new_requests.sort(lambda r,s: cmp(r['supply_date'],s['supply_date'])) + for new_req in new_requests: + for old_req in existing_req.get((new_req['product'].id, + new_req['warehouse'].id), []): + + print old_req['supply_date'] <= new_req['supply_date'], old_req['supply_date'], new_req['supply_date'] + if old_req['supply_date'] <= new_req['supply_date']: + quantity = uom_obj.compute_qty( + cursor, user, old_req['uom'], old_req['quantity'], + new_req['uom'], context=context) + new_req['quantity'] = max(0.0, new_req['quantity'] - quantity) + old_req['quantity'] = uom_obj.compute_qty( + cursor, user, new_req['uom'], + max(0.0, quantity - new_req['quantity']), + old_req['uom'], context=context) + else: + break + + for new_req in new_requests: + if new_req['quantity'] > 0.0: + new_req.update({'product': new_req['product'].id, + 'party': new_req['party'] and new_req['party'].id, + 'uom': new_req['uom'].id, + 'company': new_req['company'].id + }) + print "CREATE", new_req + + request_obj.create(cursor, user, new_req, context=context) + + def get_supply_dates(self, cursor, user, product, context=None): + """ + Return for the given product min and max values for the + earliest supply dates across all available suppliers. + """ + min_date = None + max_date = None + today = datetime.date.today() + + for product_supplier in product.product_suppliers: + supply_date = today + datetime.timedelta(product_supplier.lead_time) + if (not min_date) or supply_date < min_date: + min_date = supply_date + if (not max_date) or supply_date > max_date: + max_date = supply_date + return (min_date, max_date) + + def compute_request(self, cursor, user, order_point, shortage_date, + stock_quantity, context=None): + """ + Return the value of the purchase request which will answer to + the needed quantity at the given date. I.e: the latest + purchase date, the expected supply date and the prefered + supplier. + """ + product = order_point.product + supplier = None + seq = None + on_time = False + today = datetime.date.today() + + for product_supplier in product.product_suppliers: + supply_date = today + datetime.timedelta(product_supplier.lead_time) + sup_on_time = supply_date < shortage_date + if not supplier: + supplier = product_supplier.party + on_time = sup_on_time + seq = product_supplier.sequence + continue + + if (sup_on_time, product_supplier.sequence) < (on_time, seq): + supplier = product_supplier.party + on_time = sup_on_time + seq = product_supplier.sequence + + if supplier and product_supplier.lead_time: + purchase_date = \ + shortage_date - datetime.timedelta(product_supplier.lead_time) + else: + purchase_date = today + + return {'product': product, + 'party': supplier and supplier or None, + 'quantity': order_point.max_quantity - stock_quantity, + 'uom': product.default_uom, + 'purchase_date': purchase_date, + 'supply_date': shortage_date, + 'stock_level': stock_quantity, + 'company': order_point.company, + 'warehouse': order_point.location, + } + + def get_shortage(self, cursor, user, order_point, min_date, + max_date, min_date_stock, context=None): + """ + Compute stock quantities between the two given dates. If given + the order point, one date will be lacking in products this + date is returned alongside the stock level at this date. + + If no dates are given, the stock is computed for an infinire + date and the shortage date is today. + """ + product_obj = self.pool.get('product.product') + + if not min_date: + if min_date_stock < order_point.min_quantity: + return ( + datetime.date.today(), min_date_stock) + else: + return (None, None) + if not max_date: max_date = min_date + + current_date = min_date + current_stock = min_date_stock + while current_date <= max_date: + print "SHTG", current_stock, order_point.min_quantity + if current_stock < order_point.min_quantity: + return current_date, current_stock + + local_context = context.copy() + local_context['stock_date_start'] = current_date + local_context['stock_date_end'] = current_date + + res = product_obj.products_by_location( + cursor, user, [order_point.location.id], + [order_point.location.id], with_childs=True, skip_zero=False, + context=context) + for qty in res.itervalues(): + current_stock += qty + current_date += datetime.timedelta(1) + + return (None, None) + +GeneratePurchaseRequest() diff --git a/supply_schedule.xml b/supply_schedule.xml new file mode 100644 index 0000000000..efe8dcfee9 --- /dev/null +++ b/supply_schedule.xml @@ -0,0 +1,12 @@ + + + + + + Generate Purchase Requests + supply.generate_purchase_request + + + +