diff --git a/frepple/controllers/frepplexml.py b/frepple/controllers/frepplexml.py index 03ef024f..2f2707c2 100644 --- a/frepple/controllers/frepplexml.py +++ b/frepple/controllers/frepplexml.py @@ -164,6 +164,7 @@ def xml(self, **kwargs): == "true", version=version, delta=float(kwargs.get("delta", 999)), + language=language, ) # last empty double quote is to let python understand frepple is a folder. xml_folder = os.path.join(str(Path.home()), "logs", "frepple", "") diff --git a/frepple/controllers/inbound.py b/frepple/controllers/inbound.py index 5e0427c0..8cc6c20f 100644 --- a/frepple/controllers/inbound.py +++ b/frepple/controllers/inbound.py @@ -141,7 +141,18 @@ def run(self): mo_references = {} wo_data = [] + # Workcenters of a workorder to update + resources = [] + for event, elem in iterparse(self.datafile, events=("start", "end")): + if ( + elem.tag == "operationplan" + and elem.get("ordertype") == "WO" + and event == "start" + ): + resources = [] + elif elem.tag == "resource" and event == "end": + resources.append(elem.get("id")) if event == "start" and elem.tag == "workorder" and elem.get("operation"): try: wo = { @@ -182,7 +193,7 @@ def run(self): ordertype = elem.get("ordertype") if ordertype == "PO": # Create purchase order - supplier_id = int(elem.get("supplier").split(" ", 1)[0]) + supplier_id = int(elem.get("supplier").rsplit(" ", 1)[-1]) quantity = elem.get("quantity") date_planned = elem.get("end") if date_planned: @@ -198,15 +209,16 @@ def run(self): po = proc_order.create( { "company_id": self.company.id, - "partner_id": int( - elem.get("supplier").split(" ", 1)[0] - ), + "partner_id": supplier_id, # TODO Odoo has no place to store the location and criticality # int(elem.get('location_id')), # elem.get('criticality'), "origin": "frePPLe", } ) + po.payment_term_id = ( + po.partner_id.property_supplier_payment_term_id.id + ) supplier_reference[supplier_id] = { "id": po.id, "min_planned": date_planned, @@ -344,17 +356,17 @@ def run(self): if not hasattr(self, "stock_picking_dict"): self.stock_picking_dict = {} if not self.stock_picking_dict.get((origin, destination)): - self.stock_picking_dict[ - (origin, destination) - ] = stck_picking.create( - { - "picking_type_id": picking_type_id, - "scheduled_date": date_shipping, - "location_id": location_id["id"], - "location_dest_id": location_dest_id["id"], - "move_type": "direct", - "origin": "frePPLe", - } + self.stock_picking_dict[(origin, destination)] = ( + stck_picking.create( + { + "picking_type_id": picking_type_id, + "scheduled_date": date_shipping, + "location_id": location_id["id"], + "location_dest_id": location_dest_id["id"], + "move_type": "direct", + "origin": "frePPLe", + } + ) ) sp = self.stock_picking_dict.get((origin, destination)) if not hasattr(self, "sm_dict"): @@ -410,26 +422,55 @@ def run(self): # Can't filter on the computed display_name field in the search... continue if wo: - wo.write( - { - "date_start": self.timezone.localize( - datetime.strptime( - elem.get("start"), - "%Y-%m-%d %H:%M:%S", - ) + data = { + "date_start": self.timezone.localize( + datetime.strptime( + elem.get("start"), + "%Y-%m-%d %H:%M:%S", + ) + ) + .astimezone(UTC) + .replace(tzinfo=None), + "date_finished": self.timezone.localize( + datetime.strptime( + elem.get("end"), + "%Y-%m-%d %H:%M:%S", ) - .astimezone(UTC) - .replace(tzinfo=None), - "date_finished": self.timezone.localize( - datetime.strptime( - elem.get("end"), - "%Y-%m-%d %H:%M:%S", + ) + .astimezone(UTC) + .replace(tzinfo=None), + } + for res_id in resources: + res = mfg_workcenter.search( + [("id", "=", res_id)] + ) + if not res: + continue + if ( + not wo.operation_id # No operation defined + or ( + wo.operation_id.workcenter_id + == res # Same workcenter + or ( + # New member of a pool + wo.operation_id.workcenter_id + and wo.operation_id.workcenter_id + == res.owner ) ) - .astimezone(UTC) - .replace(tzinfo=None), - } - ) + ): + # Change primary work center + data["workcenter_id"] = res.id + else: + # Check assigned secondary resources + for sec in wo.secondary_workcenters: + if sec.workcenter_id.owner == res: + break + if sec.workcenter_id.owner == res.owner: + # Change secondary work center + sec.write({"workcenter_id": res.id}) + break + wo.write(data) break else: # Create manufacturing order diff --git a/frepple/controllers/outbound.py b/frepple/controllers/outbound.py index 0923e6ee..8c7536b5 100644 --- a/frepple/controllers/outbound.py +++ b/frepple/controllers/outbound.py @@ -57,13 +57,22 @@ def callMethod(self, model, id, method, args=[]): return getattr(obj, method)(*args) return None - def getData(self, model, search=[], order=None, fields=[], ids=None): + def getData(self, model, search=[], order=None, fields=[], ids=None, object=False): if ids is not None: - return self.env[model].browse(ids).read(fields) if ids else [] + if object: + return self.env[model].browse(ids) if ids else [] + else: + return self.env[model].browse(ids).read(fields) if ids else [] if order: - return self.env[model].search(search, order=order).read(fields) + if object: + return self.env[model].search(search, order=order) + else: + return self.env[model].search(search, order=order).read(fields) else: - return self.env[model].search(search).read(fields) + if object: + return self.env[model].search(search) + else: + return self.env[model].search(search).read(fields) class XMLRPC_generator: @@ -152,6 +161,7 @@ def __init__( singlecompany=False, version="0.0.0.unknown", delta=999, + language="en_US", ): self.database = database self.company = company @@ -176,6 +186,7 @@ def __init__( self.timeformat = "%Y-%m-%dT%H:%M:%S" self.singlecompany = singlecompany self.delta = delta + self.language = language # The mode argument defines different types of runs: # - Mode 1: @@ -221,6 +232,9 @@ def run(self): yield '\n' % self.mode yield "Generated by odoo %s\n" % odoo.release.version + self.currentdate = datetime.now() + yield "%s" % self.currentdate.strftime("%Y-%m-%dT%H:%M:%S") + # Synchronize users yield from self.export_users() @@ -297,7 +311,11 @@ def load_company(self): self.manufacturing_lead = i["manufacturing_lead"] self.respect_reservations = i["respect_reservations"] try: - self.calendar = i["calendar"] and i["calendar"][1] or None + self.calendar = ( + i["calendar"] + and ("%s %s" % (i["calendar"][1], i["calendar"][0])) + or None + ) self.mfg_location = ( i["manufacturing_warehouse"] and i["manufacturing_warehouse"][1] @@ -326,7 +344,6 @@ def load_uom(self): unit of measure of the uom dimension. """ self.uom = {} - self.uom_categories = {} for i in self.generator.getData( "uom.uom", # We also need to load INactive UOMs, because there still might be records @@ -334,8 +351,6 @@ def load_uom(self): search=["|", ("active", "=", 1), ("active", "=", 0)], fields=["factor", "uom_type", "category_id", "name"], ): - if i["uom_type"] == "reference": - self.uom_categories[i["category_id"][0]] = i["id"] self.uom[i["id"]] = { "factor": i["factor"], "category": i["category_id"][0], @@ -365,19 +380,21 @@ def load_operation_types(self): "name": i["name"], "code": i["code"], "sequence_code": i["sequence_code"], - "default_location_src_id": self.map_locations.get( - i["default_location_src_id"][0], None - ) - if i["default_location_src_id"] - else None, - "default_location_dest_id": self.map_locations.get( - i["default_location_dest_id"][0], None - ) - if i["default_location_dest_id"] - else None, - "warehouse_id": self.warehouses.get(i["warehouse_id"][0], None) - if i["warehouse_id"] - else None, + "default_location_src_id": ( + self.map_locations.get(i["default_location_src_id"][0], None) + if i["default_location_src_id"] + else None + ), + "default_location_dest_id": ( + self.map_locations.get(i["default_location_dest_id"][0], None) + if i["default_location_dest_id"] + else None + ), + "warehouse_id": ( + self.warehouses.get(i["warehouse_id"][0], None) + if i["warehouse_id"] + else None + ), } def convert_qty_uom(self, qty, uom_id, product_template_id=None): @@ -457,7 +474,7 @@ def export_calendar(self): resource.calendar.name -> calendar.name (default value is 0) resource.calendar.attendance.date_from -> calendar bucket start date (or 2020-01-01 if unspecified) - resource.calendar.attendance.date_to -> calendar bucket end date (or 2030-01-01 if unspecified) + resource.calendar.attendance.date_to -> calendar bucket end date (or 2030-12-31 if unspecified) resource.calendar.attendance.hour_from -> calendar bucket start time resource.calendar.attendance.hour_to -> calendar bucket end time resource.calendar.attendance.dayofweek -> calendar bucket day @@ -486,8 +503,45 @@ def export_calendar(self): "tz", ], ): - cal_tz[i["name"]] = i["tz"] cal_ids.add(i["id"]) + cal_tz["%s %s" % (i["name"], i["id"])] = i["tz"] + + # Read the resource calendar association + calendar_resource = {} + for i in self.generator.getData( + "mrp.workcenter", + search=[("resource_calendar_id", "!=", False)], + fields=[ + "resource_id", + "resource_calendar_id", + ], + ): + if i["resource_calendar_id"][0] not in calendar_resource: + calendar_resource[i["resource_calendar_id"][0]] = set() + calendar_resource[i["resource_calendar_id"][0]].add(i["resource_id"][0]) + + # Read from the attendance/leaves which resource has specific entries + self.resources_with_specific_calendars = {} + for i in self.generator.getData( + "resource.calendar.attendance", + search=[("resource_id", "!=", False)], + fields=[ + "resource_id", + ], + ): + self.resources_with_specific_calendars[i["resource_id"][0]] = i[ + "resource_id" + ][1] + for i in self.generator.getData( + "resource.calendar.leaves", + search=[("resource_id", "!=", False), ("time_type", "=", "leave")], + fields=[ + "resource_id", + ], + ): + self.resources_with_specific_calendars[i["resource_id"][0]] = i[ + "resource_id" + ][1] # Read the attendance for all calendars for i in self.generator.getData( @@ -501,13 +555,41 @@ def export_calendar(self): "hour_to", "calendar_id", "week_type", + "resource_id", ], ): if i["calendar_id"] and i["calendar_id"][0] in cal_ids: - if i["calendar_id"][1] not in calendars: - calendars[i["calendar_id"][1]] = [] - i["attendance"] = True - calendars[i["calendar_id"][1]].append(i) + calendar_name = "%s %s" % (i["calendar_id"][1], i["calendar_id"][0]) + + if not i["resource_id"]: + if calendar_name not in calendars: + calendars[calendar_name] = [] + i["attendance"] = True + calendars[calendar_name].append(i) + + if calendar_resource.get(i["calendar_id"][0]): + for res in calendar_resource.get(i["calendar_id"][0]): + if i["resource_id"] and res != i["resource_id"][0]: + continue + if res in self.resources_with_specific_calendars: + if ( + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + not in calendars + ): + calendars[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ] = [] + cal_tz[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ] = cal_tz[calendar_name] + i["attendance"] = True + calendars[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ].append(i) # Read the leaves for all calendars for i in self.generator.getData( @@ -517,13 +599,41 @@ def export_calendar(self): "date_from", "date_to", "calendar_id", + "resource_id", ], ): + calendar_name = "%s %s" % (i["calendar_id"][1], i["calendar_id"][0]) + if i["calendar_id"] and i["calendar_id"][0] in cal_ids: - if i["calendar_id"][1] not in calendars: - calendars[i["calendar_id"][1]] = [] - i["attendance"] = False - calendars[i["calendar_id"][1]].append(i) + if not i["resource_id"]: + if calendar_name not in calendars: + calendars[calendar_name] = [] + i["attendance"] = False + calendars[calendar_name].append(i) + + if calendar_resource.get(i["calendar_id"][0]): + for res in calendar_resource.get(i["calendar_id"][0]): + if i["resource_id"] and res != i["resource_id"][0]: + continue + if res in self.resources_with_specific_calendars: + if ( + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + not in calendars + ): + calendars[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ] = [] + cal_tz[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ] = cal_tz[i["calendar_id"][1]] + i["attendance"] = False + calendars[ + "calendar for %s" + % (self.resources_with_specific_calendars[res],) + ].append(i) # Iterate over the results: for i in calendars: @@ -539,32 +649,42 @@ def export_calendar(self): if j.get("week_type", False) == False: # ONE-WEEK CALENDAR yield '\n' % ( - self.formatDateTime(j["date_from"], cal_tz[i]) - if not j["attendance"] - else ( - j["date_from"].strftime("%Y-%m-%dT00:00:00") - if j["date_from"] - else "2020-01-01T00:00:00" + ( + self.formatDateTime(j["date_from"], cal_tz[i]) + if not j["attendance"] + else ( + j["date_from"].strftime("%Y-%m-%dT00:00:00") + if j["date_from"] + else "2020-01-01T00:00:00" + ) ), - self.formatDateTime(j["date_to"], cal_tz[i]) - if not j["attendance"] - else ( - j["date_to"].strftime("%Y-%m-%dT00:00:00") - if j["date_to"] - else "2030-01-01T00:00:00" + ( + self.formatDateTime(j["date_to"], cal_tz[i]) + if not j["attendance"] + else ( + j["date_to"].strftime("%Y-%m-%dT00:00:00") + if j["date_to"] + else "2030-12-31T00:00:00" + ) ), "1" if j["attendance"] else "0", - (2 ** ((int(j["dayofweek"]) + 1) % 7)) - if "dayofweek" in j - else (2 ** 7) - 1, + ( + (2 ** ((int(j["dayofweek"]) + 1) % 7)) + if "dayofweek" in j + else (2**7) - 1 + ), priority_attendance if j["attendance"] else priority_leave, # In odoo, monday = 0. In frePPLe, sunday = 0. - ("PT%dM" % round(j["hour_from"] * 60)) - if "hour_from" in j - else "PT0M", - ("PT%dM" % round(j["hour_to"] * 60)) - if "hour_to" in j - else "PT1440M", + ( + ("PT%dM" % round(j["hour_from"] * 60)) + if "hour_from" in j + else "PT0M" + ), + ( + ("PT%dM" % round(j["hour_to"] * 60)) + if "hour_to" in j + else "PT1440M" + ), ) if j["attendance"]: priority_attendance += 1 @@ -573,13 +693,11 @@ def export_calendar(self): else: # TWO-WEEKS CALENDAR start = j["date_from"] or datetime(2020, 1, 1) - end = j["date_to"] or datetime(2030, 1, 1) + end = j["date_to"] or datetime(2030, 12, 31) t = start while t < end: if int(t.isocalendar()[1] % 2) == int(j["week_type"]): - if j["hour_to"] == 0: - logger.info(j) yield '\n' % ( self.formatDateTime(t, cal_tz[i]), self.formatDateTime( @@ -587,17 +705,23 @@ def export_calendar(self): cal_tz[i], ), "1", - (2 ** ((int(j["dayofweek"]) + 1) % 7)) - if "dayofweek" in j - else (2 ** 7) - 1, + ( + (2 ** ((int(j["dayofweek"]) + 1) % 7)) + if "dayofweek" in j + else (2**7) - 1 + ), priority_attendance, # In odoo, monday = 0. In frePPLe, sunday = 0. - ("PT%dM" % round(j["hour_from"] * 60)) - if "hour_from" in j - else "PT0M", - ("PT%dM" % round(j["hour_to"] * 60)) - if "hour_to" in j - else "PT1440M", + ( + ("PT%dM" % round(j["hour_from"] * 60)) + if "hour_from" in j + else "PT0M" + ), + ( + ("PT%dM" % round(j["hour_to"] * 60)) + if "hour_to" in j + else "PT1440M" + ), ) priority_attendance += 1 dow = t.weekday() @@ -688,17 +812,28 @@ def export_customers(self): """ self.map_customers = {} first = True + individual_inserted = False for i in self.generator.getData( "res.partner", - search=[("is_company", "=", True)], - fields=["name"], + search=[], + fields=["name", "parent_id", "is_company"], + order="id", ): if first: yield "\n" yield "\n" first = False - name = "%s %s" % (i["name"], i["id"]) - yield "\n" % quoteattr(name) + if i["is_company"]: + name = "%s %s" % (i["name"], i["id"]) + yield "\n" % quoteattr(name) + elif i["parent_id"] == False or i["id"] == i["parent_id"][0]: + name = "Individuals" + if not individual_inserted: + yield "\n" % quoteattr(name) + individual_inserted = True + else: + name = self.map_customers[i["parent_id"][0]] + self.map_customers[i["id"]] = name if not first: yield "\n" @@ -712,16 +847,12 @@ def export_suppliers(self): res.partner.id res.partner.name -> supplier.name """ first = True - for i in self.generator.getData( - "res.partner", - search=[("is_company", "=", True)], - fields=["name"], - ): + for i in self.map_customers: if first: yield "\n" yield "\n" first = False - yield "\n" % quoteattr("%d %s" % (i["id"], i["name"])) + yield "\n" % quoteattr(self.map_customers[i]) if not first: yield "\n" @@ -784,6 +915,7 @@ def export_workcenters(self): "mrp.workcenter", fields=[ "name", + "resource_id", "owner", "resource_calendar_id", "time_efficiency", @@ -797,7 +929,22 @@ def export_workcenters(self): first = False name = i["name"] owner = i["owner"] - available = i["resource_calendar_id"] + available = ( + ( + ( + 0, + "%s %s" + % (i["resource_calendar_id"][1], i["resource_calendar_id"][0]), + ) + if i["resource_calendar_id"] + else None + ) + if not self.resources_with_specific_calendars.get(i["resource_id"][0]) + else ( + 0, + "calendar for %s" % (i["resource_id"][1],), + ) + ) self.map_workcenters[i["id"]] = name yield '%s%s\n' % ( quoteattr(name), @@ -841,6 +988,13 @@ def export_items(self): self.product_product = {} self.product_template_product = {} self.product_templates = {} + self.routes = { + i["id"]: i for i in self.generator.getData("stock.route", fields=["name"]) + } + self.route_mto = None + for k, v in self.routes.items(): + if v["name"] == "Replenish on Order (MTO)": + self.route_mto = k for i in self.generator.getData( "product.template", search=[("type", "not in", ("service", "consu"))], @@ -852,10 +1006,39 @@ def export_items(self): "uom_id", "categ_id", "product_variant_ids", + "route_ids", ], ): self.product_templates[i["id"]] = i + # Check if we can use short names + # To use short names, the internal reference (or the name when no internal reference is defined) + # needs to be unique + use_short_names = True + + self.generator.env.cr.execute( + """ + select count(*) from + ( + select coalesce(product_product.default_code, + product_template.name->>%s, + product_template.name->>'en_US'), count(*) + from product_product + inner join product_template on product_product.product_tmpl_id = product_template.id + where product_template.type not in ('service', 'consu') + group by coalesce(product_product.default_code, + product_template.name->>%s, + product_template.name->>'en_US') + having count(*) > 1 + ) t + """, + (self.language, self.language), + ) + for i in self.generator.env.cr.fetchall(): + if i[0] > 0: + use_short_names = False + break + # Read the products supplierinfo_fields = [ "partner_id", @@ -890,13 +1073,20 @@ def export_items(self): continue tmpl = self.product_templates[i["product_tmpl_id"][0]] if i["code"]: - name = ("[%s] %s" % (i["code"], i["name"]))[:300] + name = ( + (("[%s] %s" % (i["code"], i["name"]))[:300]) + if not use_short_names + else i["code"][:300] + ) + description = i["name"][:500] if use_short_names else None # product is a variant and has no internal reference # we use the product id as code elif i["product_template_attribute_value_ids"]: name = ("[%s] %s" % (i["id"], i["name"]))[:300] + description = i["name"][:500] if use_short_names else None else: name = i["name"][:300] + description = i["name"][:500] if use_short_names else None prod_obj = { "name": name, "template": i["product_tmpl_id"][0], @@ -906,9 +1096,15 @@ def export_items(self): } self.product_product[i["id"]] = prod_obj self.product_template_product[i["product_tmpl_id"][0]] = prod_obj + # For make-to-order items the next line needs to XML snippet ' type="item_mto"'. - yield '\n' % ( + yield '\n' % ( quoteattr(name), + ( + ("description=%s" % (quoteattr(description),)) + if use_short_names + else "" + ), quoteattr(tmpl["uom_id"][1]) if tmpl["uom_id"] else "", i["volume"] or 0, i["weight"] or 0, @@ -918,8 +1114,9 @@ def export_items(self): # max(0, tmpl["standard_price"]) or 0) # Option 2: Map the "cost" to frepple / self.convert_qty_uom(1.0, tmpl["uom_id"], i["product_tmpl_id"][0]), quoteattr(tmpl["categ_id"][1]) if tmpl["categ_id"] else '""', - self.uom_categories[self.uom[tmpl["uom_id"][0]]["category"]], + tmpl["uom_id"][0], i["id"], + ' type="item_mto"' if self.route_mto in tmpl["route_ids"] else "", ) # Export suppliers for the item, if the item is allowed to be purchased if tmpl["purchase_ok"]: @@ -940,7 +1137,7 @@ def export_items(self): ) suppliers = {} for sup in results: - name = "%d %s" % (sup["partner_id"][0], sup["partner_id"][1]) + name = self.map_customers.get(sup["partner_id"][0]) if sup.get("is_subcontractor", False): if not hasattr(tmpl, "subcontractors"): tmpl["subcontractors"] = [] @@ -1000,13 +1197,18 @@ def export_items(self): v["batching_window"] or 0, v["min_qty"], max(0, v["price"]), - ' effective_end="%sT00:00:00"' - % v["date_end"].strftime("%Y-%m-%d") - if v["date_end"] - else "", - ' effective_start="%sT00:00:00"' % k[1].strftime("%Y-%m-%d") - if k[1] - else "", + ( + ' effective_end="%sT00:00:00"' + % v["date_end"].strftime("%Y-%m-%d") + if v["date_end"] + else "" + ), + ( + ' effective_start="%sT00:00:00"' + % k[1].strftime("%Y-%m-%d") + if k[1] + else "" + ), quoteattr(k[0]), ) yield "\n" @@ -1147,9 +1349,11 @@ def export_boms(self): if subcontractor: yield '\n' "\n" % ( quoteattr(operation), - ("description=%s " % quoteattr(i["code"])) - if i["code"] - else "", + ( + ("description=%s " % quoteattr(i["code"])) + if i["code"] + else "" + ), quoteattr(subcontractor["name"]), subcontractor.get("delay", 0), self.po_lead, @@ -1159,16 +1363,22 @@ def export_boms(self): quoteattr(location), ) else: - duration_per = (i["produce_delay"] or 0) + (i["days_to_prepare_mo"] or 0) + duration_per = (i["produce_delay"] or 0) + ( + i["days_to_prepare_mo"] or 0 + ) yield '\n' "\n" % ( quoteattr(operation), - ("description=%s " % quoteattr(i["code"])) - if i["code"] - else "", - self.convert_float_time(duration_per) - if duration_per and duration_per > 0 - else "P0D", + ( + ("description=%s " % quoteattr(i["code"])) + if i["code"] + else "" + ), + ( + self.convert_float_time(duration_per) + if duration_per and duration_per > 0 + else "P0D" + ), self.manufacturing_lead, 100 + (i["sequence"] or 1), quoteattr(product_buf["name"]), @@ -1256,9 +1466,11 @@ def export_boms(self): if not product: continue yield '\n' % ( - "flow_fixed_end" - if j["subproduct_type"] == "fixed" - else "flow_end", + ( + "flow_fixed_end" + if j["subproduct_type"] == "fixed" + else "flow_end" + ), self.convert_qty_uom( j["product_qty"], j["product_uom"], @@ -1287,9 +1499,11 @@ def export_boms(self): quoteattr( self.map_workcenters[j["workcenter_id"][0]] ), - ("" % quoteattr(j["skill"][1])) - if j["skill"] - else "", + ( + ("" % quoteattr(j["skill"][1])) + if j["skill"] + else "" + ), ) # create a load for secondary workcenters # prepare the secondary workcenter xml string upfront @@ -1299,11 +1513,13 @@ def export_boms(self): sw_id ] yield '%s' % ( - 1 - if not secondary_workcenter["duration"] - or j["time_cycle"] == 0 - else secondary_workcenter["duration"] - / j["time_cycle"], + ( + 1 + if not secondary_workcenter["duration"] + or j["time_cycle"] == 0 + else secondary_workcenter["duration"] + / j["time_cycle"] + ), quoteattr(secondary_workcenter["search_mode"]), quoteattr( self.map_workcenters[ @@ -1311,13 +1527,15 @@ def export_boms(self): ] ), ( - "" - % quoteattr( - secondary_workcenter["skill"][1] + ( + "" + % quoteattr( + secondary_workcenter["skill"][1] + ) ) - ) - if secondary_workcenter["skill"] - else "", + if secondary_workcenter["skill"] + else "" + ), ) if exists: @@ -1329,9 +1547,11 @@ def export_boms(self): # yield '\n' % ( quoteattr(operation), - ("description=%s " % quoteattr(i["code"])) - if i["code"] - else "", + ( + ("description=%s " % quoteattr(i["code"])) + if i["code"] + else "" + ), self.manufacturing_lead, 100 + (i["sequence"] or 1), quoteattr(product_buf["name"]), @@ -1392,9 +1612,11 @@ def export_boms(self): fl[ ( j["product_id"][0], - j["operation_id"][0] - if j["operation_id"] - else None, + ( + j["operation_id"][0] + if j["operation_id"] + else None + ), ) ]["qty"] += qty else: @@ -1402,9 +1624,11 @@ def export_boms(self): fl[ ( j["product_id"][0], - j["operation_id"][0] - if j["operation_id"] - else None, + ( + j["operation_id"][0] + if j["operation_id"] + else None + ), ) ] = j @@ -1442,44 +1666,59 @@ def export_boms(self): not in self.map_workcenters ): continue - secondary_workcenter_str += '%s' % ( - 1 - if not secondary_workcenter["duration"] - or step["time_cycle"] == 0 - else secondary_workcenter["duration"] - / step["time_cycle"], - quoteattr(secondary_workcenter["search_mode"]), - quoteattr( - self.map_workcenters[ - secondary_workcenter["workcenter_id"][0] - ] - ), - ( - "" - % quoteattr(secondary_workcenter["skill"][1]) + secondary_workcenter_str += ( + '%s' + % ( + ( + 1 + if not secondary_workcenter["duration"] + or step["time_cycle"] == 0 + else secondary_workcenter["duration"] + / step["time_cycle"] + ), + quoteattr(secondary_workcenter["search_mode"]), + quoteattr( + self.map_workcenters[ + secondary_workcenter["workcenter_id"][0] + ] + ), + ( + ( + "" + % quoteattr( + secondary_workcenter["skill"][1] + ) + ) + if secondary_workcenter["skill"] + else "" + ), ) - if secondary_workcenter["skill"] - else "", ) yield "" '\n' "\n" '%s%s\n' % ( quoteattr(name), - ("description=%s " % quoteattr(i["code"])) - if i["code"] - else "", + ( + ("description=%s " % quoteattr(i["code"])) + if i["code"] + else "" + ), counter * 10, - self.convert_float_time(step["time_cycle"] / 1440.0) - if step["time_cycle"] and step["time_cycle"] > 0 - else "P0D", + ( + self.convert_float_time(step["time_cycle"] / 1440.0) + if step["time_cycle"] and step["time_cycle"] > 0 + else "P0D" + ), quoteattr(location), 1, quoteattr(step["search_mode"]), quoteattr( self.map_workcenters[step["workcenter_id"][0]] ), - ("" % quoteattr(step["skill"][1])) - if step["skill"] - else "", + ( + ("" % quoteattr(step["skill"][1])) + if step["skill"] + else "" + ), secondary_workcenter_str, ) first_flow = True @@ -1630,19 +1869,7 @@ def getReservedQuantity(stock_move_id): j = so[i["order_id"][0]] location = j["warehouse_id"][1] customer = self.map_customers.get(j["partner_id"][0], None) - if not customer: - # The customer may be an individual. - # We check whether his/her company is in the map. - for c in self.generator.getData( - "res.partner", - ids=[j["partner_id"][0]], - fields=["commercial_partner_id"], - ): - customer = self.map_customers.get( - c["commercial_partner_id"][0], None - ) - if customer: - break + if not customer or not location or not product: # Not interested in this sales order... continue @@ -1659,10 +1886,6 @@ def getReservedQuantity(stock_move_id): x in stock_moves_dict_confirmed for x in i["move_ids"] ): state = "done" - logger.error( - "%s %s %s %s" - % (name, state, i["move_ids"], [x for x in stock_moves_dict]) - ) if state in ("draft", "sent"): # status = "inquiry" # Inquiries don't reserve capacity and materials status = "quote" # Quotes do reserve capacity and materials @@ -1701,9 +1924,11 @@ def getReservedQuantity(stock_move_id): ) % ( quoteattr(sol_name), quoteattr(batch), - qty - reserved_quantity - if qty - reserved_quantity > 0 - else qty, + ( + qty - reserved_quantity + if qty - reserved_quantity > 0 + else qty + ), due, priority, j["picking_policy"] == "one" and qty or 0.0, @@ -1903,7 +2128,7 @@ def export_purchaseorders(self): qty, quoteattr(item["name"]), quoteattr(location), - quoteattr("%d %s" % (j["partner_id"][0], j["partner_id"][1])), + quoteattr(self.map_customers.get(j["partner_id"][0])), ) yield "\n" @@ -1951,9 +2176,9 @@ def export_purchaseorders(self): fields=["production_id"], ): if k["production_id"]: - self.subcontracting_mo_po_mapping[ - k["production_id"][0] - ] = po_line_reference + self.subcontracting_mo_po_mapping[k["production_id"][0]] = ( + po_line_reference + ) continue item = self.product_product.get(i["product_id"][0], None) if not item: @@ -1984,7 +2209,7 @@ def export_purchaseorders(self): qty, quoteattr(item["name"]), quoteattr(location), - quoteattr("%d %s" % (j["partner_id"][0], j["partner_id"][1])), + quoteattr(self.map_customers.get(j["partner_id"][0])), ) yield "\n" @@ -2040,23 +2265,25 @@ def export_manufacturingorders(self): search=[("state", "in", ["progress", "confirmed", "to_close"])], # Option 2: Also import draft manufacturing order from odoo (to avoid that frepple reproposes it another time) # search=[("state", "in", ["draft", "progress", "confirmed", "to_close"])], - fields=[ - "bom_id", - "date_start", - "date_finished", - "name", - "state", - "qty_producing", - "product_qty", - "product_uom_id", - "location_dest_id", - "product_id", - "move_raw_ids", - "picking_type_id", - ] - + ["workorder_ids"] - if self.manage_work_orders - else [], + fields=( + [ + "bom_id", + "date_start", + "date_finished", + "name", + "state", + "qty_producing", + "product_qty", + "product_uom_id", + "location_dest_id", + "product_id", + "move_raw_ids", + "picking_type_id", + ] + + ["workorder_ids"] + if self.manage_work_orders + else [] + ), ): # Filter out irrelevant manufacturing orders location = self.map_locations.get(i["location_dest_id"][0], None) @@ -2123,43 +2350,7 @@ def export_manufacturingorders(self): for wo in self.generator.getData( "mrp.workorder", ids=i["workorder_ids"], - fields=[ - "display_name", - "name", - "product_uom_id", - "working_state", - "state", - "workcenter_id", - "product_id", - "workcenter_id", - "date_start", - "date_finished", - "duration_expected", - "duration_unit", - "product_uom_id", - "production_availability", - "production_state", - "production_bom_id", - "state", - "is_user_working", - "time_ids", - "date_start", - "date_finished", - "date_start", - "date_finished", - "duration_expected", - "duration", - "duration_unit", - "duration_percent", - "progress", - "operation_id", - "move_raw_ids", - "move_finished_ids", - "move_line_ids", - "production_date", - "display_name", - "secondary_workcenters", - ], + object=True, order="id", ) ] @@ -2191,11 +2382,13 @@ def export_manufacturingorders(self): if not wo_list: # There are no workorders on the manufacturing order - yield '' % ( + yield '' % ( quoteattr(operation), quoteattr(location), quoteattr(item["name"]), ) + # dictionary needed as BOM in Odoo might have multiple lines with the same product + operation_materials = {} for mv in mv_list: consumed_item = ( self.product_product[mv["product_id"][0]] @@ -2226,10 +2419,15 @@ def export_manufacturingorders(self): - mv["product_qty"], ) if qty_flow > 0: - yield '\n' % ( - -qty_flow / qty, - quoteattr(consumed_item["name"]), + operation_materials[consumed_item["name"]] = ( + operation_materials.get(consumed_item["name"], 0) + + (-qty_flow / qty) ) + for key in operation_materials: + yield '\n' % ( + operation_materials[key], + quoteattr(key), + ) yield '\n' % ( quoteattr(item["name"]), ) @@ -2273,6 +2471,8 @@ def export_manufacturingorders(self): quoteattr(location), ) idx += 10 + # dictionary needed as BOM in Odoo might have multiple lines with the same product + operation_materials = {} for mv in mv_list: item = ( self.product_product[mv["product_id"][0]] @@ -2323,37 +2523,58 @@ def export_manufacturingorders(self): ) yield "" if ( - wo["workcenter_id"] - and wo["workcenter_id"][0] in self.map_workcenters + wo.operation_id + and wo.workcenter_id + and wo.operation_id.workcenter_id + and wo.operation_id.workcenter_id.id in self.map_workcenters + and wo.workcenter_id.owner + and wo.workcenter_id.owner == wo.operation_id.workcenter_id ): + # Only send a load definition if the bom specifies a parent pool yield "" % quoteattr( - self.map_workcenters[wo["workcenter_id"][0]] + self.map_workcenters[wo.operation_id.workcenter_id.id] ) - if wo["secondary_workcenters"]: - yield "" - for secondary in self.generator.getData( - "mrp.workorder.secondary.workcenter", - ids=wo["secondary_workcenters"], - fields=["workcenter_id", "duration"], - ): + elif ( + wo.workcenter_id and wo.workcenter_id.id in self.map_workcenters + ): + yield "" % quoteattr( + self.map_workcenters[wo.workcenter_id.id] + ) + if wo.operation_id: + for wo_sec in wo.secondary_workcenters: if ( - secondary["workcenter_id"] - and secondary["workcenter_id"][0] - in self.map_workcenters - and secondary["workcenter_id"][0] - != wo["workcenter_id"][0] + not wo_sec.workcenter_id + or wo_sec.workcenter_id.id not in self.map_workcenters + or wo_sec.workcenter_id == wo.workcenter_id ): - yield '' % ( - secondary["duration"] / wo["duration_expected"] - if secondary["duration"] and wo["duration_expected"] - else 1, - quoteattr( - self.map_workcenters[ - secondary["workcenter_id"][0] - ] - ), - ) - yield "" + continue + for sec in wo.operation_id.secondary_workcenter: + if ( + wo_sec.workcenter_id.owner + and wo_sec.workcenter_id.owner == sec.workcenter_id + ): + yield '%s' % ( + ( + 1 + if not sec.duration + or wo.operation_id.time_cycle == 0 + else sec.duration + / wo.operation_idtime_cycle + ), + quoteattr(sec.search_mode), + quoteattr( + self.map_workcenters[sec.workcenter_id.id] + ), + ( + ( + "" + % quoteattr(sec.skill.name) + ) + if sec.skill + else "" + ), + ) + break first_wo = False yield "" yield "" @@ -2384,15 +2605,21 @@ def export_manufacturingorders(self): dt = now else: dt = max( - wo["date_start"] - if wo["date_start"] - else i["date_start"], + ( + wo["date_start"] + if wo["date_start"] + else ( + wo["date_start"] + if wo["date_start"] + else i["date_start"] + ) + ), now, ) wo_date = ' start="%s"' % self.formatDateTime(dt) except Exception: wo_date = "" - yield '\n' % ( + yield '' % ( quoteattr(wo["display_name"]), wo_date, qty, @@ -2400,6 +2627,30 @@ def export_manufacturingorders(self): quoteattr(suboperation), quoteattr(i["name"]), ) + if ( + wo.operation_id + and wo.workcenter_id + and wo.workcenter_id.id in self.map_workcenters + ): + yield "" % quoteattr( + self.map_workcenters[wo.workcenter_id.id] + ) + if wo.secondary_workcenters: + yield "" + for secondary in wo.secondary_workcenters: + if ( + secondary.workcenter_id + and secondary.workcenter_id.id in self.map_workcenters + and secondary.workcenter_id != wo.workcenter_id + ): + yield "" % ( + quoteattr( + self.map_workcenters[secondary.workcenter_id.id] + ), + ) + yield "" + + yield "\n" yield "\n" def export_orderpoints(self): @@ -2445,21 +2696,23 @@ def export_orderpoints(self): if i["product_min_qty"]: yield """ - + \n """ % ( (quoteattr("SS for %s" % (name,))), + self.currentdate.strftime("%Y-%m-%dT%H:%M:%S"), (i["product_min_qty"] * uom_factor), ) if i["product_max_qty"] - i["product_min_qty"] > 0: yield """ - + \n """ % ( (quoteattr("ROQ for %s" % (name,))), + self.currentdate.strftime("%Y-%m-%dT%H:%M:%S"), ((i["product_max_qty"] - i["product_min_qty"]) * uom_factor), ) if not first: @@ -2539,9 +2792,13 @@ def export_onhand(self): # This mode of working is not recommended for production use because of performance # considerations. # - # EXPERIMENTAL FEATURE!!! + # DEPRECATED EXPERIMENTAL FEATURE!!! + # This feature was always experimental, and we now see it as a dead end. # import argparse + from warnings import warn + + warn("The XMLRPC odoo connector is deprecated", DeprecationWarning) parser = argparse.ArgumentParser(description="Debug frepple odoo connector") parser.add_argument( diff --git a/freppledata/data/product.template.csv b/freppledata/data/product.template.csv index 4d8c532d..d1205ee9 100644 --- a/freppledata/data/product.template.csv +++ b/freppledata/data/product.template.csv @@ -4,4 +4,4 @@ __export__.product_template_44_67fbc973,1,chair leg,,1,0,__export__.product_prod __export__.product_template_46_a06bf0d6,1,cushion,,1,0,__export__.product_product_56_ea472d4f,Storable Product __export__.product_template_48_019c3042,1,varnish,,1,0,__export__.product_product_57_9ea5498d,Storable Product __export__.product_template_43_c7599540,1,varnished chair,,90,45,__export__.product_product_57_5aef267e,Storable Product -__export__.product_template_45_403ca5bc,1,wooden beam,,1,0,__export__.product_product_58_8eba4d11,Storable Product +__export__.product_template_45_403ca5bc,1,wooden beam - 木头,,1,0,__export__.product_product_58_8eba4d11,Storable Product \ No newline at end of file