# Copyright 2020 Akretion France (http://www.akretion.com/) # @author Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import base64 import logging from datetime import datetime from io import BytesIO import xlsxwriter from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools import float_is_zero, float_round logger = logging.getLogger(__name__) class StockValuationXlsx(models.TransientModel): _name = "stock.valuation.xlsx" _description = "Generate XLSX report for stock valuation" export_file = fields.Binary(string="XLSX Report", readonly=True, attachment=True) export_filename = fields.Char(readonly=True) # I don't use ir.actions.url on v12, because it renders # the wizard unusable after the first report generation, which creates # a lot of confusion for users state = fields.Selection( [ ("setup", "Setup"), ("done", "Done"), ], string="State", default="setup", readonly=True, ) warehouse_id = fields.Many2one( "stock.warehouse", string="Warehouse", states={"done": [("readonly", True)]} ) location_id = fields.Many2one( "stock.location", string="Root Stock Location", required=True, domain=[("usage", "in", ("view", "internal"))], default=lambda self: self._default_location(), states={"done": [("readonly", True)]}, help="The childen locations of the selected locations will " u"be taken in the valuation.", ) categ_ids = fields.Many2many( "product.category", string="Product Category Filter", help="Leave this field empty to have a stock valuation for all your products.", states={"done": [("readonly", True)]}, ) source = fields.Selection( [ ("inventory", "Physical Inventory"), ("stock", "Stock Levels"), ], string="Source data", default="stock", required=True, states={"done": [("readonly", True)]}, ) inventory_id = fields.Many2one( "stock.inventory", string="Inventory", domain=[("state", "=", "done")], states={"done": [("readonly", True)]}, ) stock_date_type = fields.Selection( [ ("present", "Present"), ("past", "Past"), ], string="Present or Past", default="present", states={"done": [("readonly", True)]}, ) past_date = fields.Datetime( string="Past Date", states={"done": [("readonly", True)]}, default=fields.Datetime.now, ) categ_subtotal = fields.Boolean( string="Subtotals per Categories", default=True, states={"done": [("readonly", True)]}, help="Show a subtotal per product category.", ) standard_price_date = fields.Selection( [ ("past", "Past Date or Inventory Date"), ("present", "Current"), ], default="past", string="Cost Price Date", states={"done": [("readonly", True)]}, ) # I can't put a compute field for has_expiry_date # because I want to have the value when the wizard is started, # and not wait until run has_expiry_date = fields.Boolean( default=lambda self: self._default_has_expiry_date(), readonly=True ) apply_depreciation = fields.Boolean( string="Apply Depreciation Rules", default=True, states={"done": [("readonly", True)]}, ) split_by_lot = fields.Boolean( string="Display Lots", states={"done": [("readonly", True)]} ) split_by_location = fields.Boolean( string="Display Stock Locations", states={"done": [("readonly", True)]} ) @api.model def _default_has_expiry_date(self): splo = self.env["stock.production.lot"] has_expiry_date = False if hasattr(splo, "expiry_date"): has_expiry_date = True return has_expiry_date @api.model def _default_location(self): wh = self.env.ref("stock.warehouse0") return wh.lot_stock_id @api.onchange("warehouse_id") def warehouse_id_change(self): if self.warehouse_id: self.location_id = self.warehouse_id.view_location_id.id def _check_config(self, company_id): self.ensure_one() if ( self.source == "stock" and self.stock_date_type == "past" and self.past_date > fields.Datetime.now() ): raise UserError(_("The 'Past Date' must be in the past !")) if self.source == "inventory": if not self.inventory_id: raise UserError(_("You must select an inventory.")) elif self.inventory_id.state != "done": raise UserError( _("The selected inventory (%s) is not in done state.") % self.inventory_id.display_name ) cost_method_real_count = self.env["ir.property"].search( [ ("company_id", "=", company_id), ("name", "=", "property_cost_method"), ("value_text", "=", "real"), ("type", "=", "selection"), ], count=True, ) if cost_method_real_count: raise UserError( _( "There are %d properties that have " "'Costing Method' = 'Real Price'. This costing " "method is not supported by this module." ) % cost_method_real_count ) def _prepare_product_domain(self): self.ensure_one() domain = [("type", "=", "product")] if self.categ_ids: domain += [("categ_id", "child_of", self.categ_ids.ids)] return domain def get_product_ids(self): self.ensure_one() domain = self._prepare_product_domain() products = self.env["product.product"].search(domain) return products.ids def _prepare_product_fields(self): return ["uom_id", "name", "default_code", "categ_id"] def _prepare_expiry_depreciation_rules(self, company_id, past_date): rules = self.env["stock.expiry.depreciation.rule"].search_read( [("company_id", "=", company_id)], ["start_limit_days", "ratio"], order="start_limit_days desc", ) if past_date: date_dt = past_date else: date_dt = fields.Date.context_today(self) for rule in rules: rule["start_date"] = date_dt - relativedelta(days=rule["start_limit_days"]) logger.debug("depreciation_rules=%s", rules) return rules def compute_product_data( self, company_id, in_stock_product_ids, standard_price_past_date=False ): self.ensure_one() logger.debug("Start compute_product_data") ppo = self.env["product.product"] ppho = self.env["product.price.history"] fields_list = self._prepare_product_fields() if not standard_price_past_date: fields_list.append("standard_price") products = ppo.search_read([("id", "in", in_stock_product_ids)], fields_list) product_id2data = {} for p in products: logger.debug("p=%d", p["id"]) # I don't call the native method get_history_price() # because it requires a browse record and it is too slow if standard_price_past_date: history = ppho.search_read( [ ("company_id", "=", company_id), ("product_id", "=", p["id"]), ("datetime", "<=", standard_price_past_date), ], ["cost"], order="datetime desc, id desc", limit=1, ) standard_price = history and history[0]["cost"] or 0.0 else: standard_price = p["standard_price"] product_id2data[p["id"]] = {"standard_price": standard_price} for pfield in fields_list: if pfield.endswith("_id"): product_id2data[p["id"]][pfield] = p[pfield][0] else: product_id2data[p["id"]][pfield] = p[pfield] logger.debug("End compute_product_data") return product_id2data @api.model def product_categ_id2name(self, categories): pco = self.env["product.category"] categ_id2name = {} categ_domain = [] if categories: categ_domain = [("id", "child_of", categories.ids)] for categ in pco.search_read(categ_domain, ["display_name"]): categ_id2name[categ["id"]] = categ["display_name"] return categ_id2name @api.model def uom_id2name(self): puo = self.env["uom.uom"].with_context(active_test=False) uom_id2name = {} uoms = puo.search_read([], ["name"]) for uom in uoms: uom_id2name[uom["id"]] = uom["name"] return uom_id2name @api.model def prodlot_id2data(self, product_ids, has_expiry_date, depreciation_rules): splo = self.env["stock.production.lot"] lot_id2data = {} lot_fields = ["name"] if has_expiry_date: lot_fields.append("expiry_date") lots = splo.search_read([("product_id", "in", product_ids)], lot_fields) for lot in lots: lot_id2data[lot["id"]] = lot lot_id2data[lot["id"]]["depreciation_ratio"] = 0 if depreciation_rules and lot.get("expiry_date"): expiry_date = lot["expiry_date"] for rule in depreciation_rules: if expiry_date <= rule["start_date"]: lot_id2data[lot["id"]]["depreciation_ratio"] = ( rule["ratio"] / 100.0 ) break return lot_id2data @api.model def stock_location_id2name(self, location): slo = self.env["stock.location"].with_context(active_test=False) loc_id2name = {} locs = slo.search_read( [("id", "child_of", self.location_id.id)], ["display_name"] ) for loc in locs: loc_id2name[loc["id"]] = loc["display_name"] return loc_id2name def compute_data_from_inventory(self, product_ids, prec_qty): self.ensure_one() logger.debug("Start compute_data_from_inventory") # Can he modify UoM ? inv_lines = self.env["stock.inventory.line"].search_read( [ ("inventory_id", "=", self.inventory_id.id), ("location_id", "child_of", self.location_id.id), ("product_id", "in", product_ids), ("product_qty", ">", 0), ], ["product_id", "location_id", "prod_lot_id", "product_qty"], ) res = [] in_stock_products = {} for l in inv_lines: if not float_is_zero(l["product_qty"], precision_digits=prec_qty): res.append( { "product_id": l["product_id"][0], "lot_id": l["prod_lot_id"] and l["prod_lot_id"][0] or False, "qty": l["product_qty"], "location_id": l["location_id"][0], } ) in_stock_products[l["product_id"][0]] = True logger.debug("End compute_data_from_inventory") return res, in_stock_products def compute_data_from_present_stock(self, company_id, product_ids, prec_qty): self.ensure_one() logger.debug("Start compute_data_from_present_stock") quants = self.env["stock.quant"].search_read( [ ("product_id", "in", product_ids), ("location_id", "child_of", self.location_id.id), ("company_id", "=", company_id), ], ["product_id", "lot_id", "location_id", "quantity"], ) res = [] in_stock_products = {} for quant in quants: if not float_is_zero(quant["quantity"], precision_digits=prec_qty): res.append( { "product_id": quant["product_id"][0], "lot_id": quant["lot_id"] and quant["lot_id"][0] or False, "location_id": quant["location_id"][0], "qty": quant["quantity"], } ) in_stock_products[quant["product_id"][0]] = True logger.debug("End compute_data_from_present_stock") return res, in_stock_products def compute_data_from_past_stock(self, product_ids, prec_qty, past_date): self.ensure_one() logger.debug("Start compute_data_from_past_stock past_date=%s", past_date) ppo = self.env["product.product"] products = ppo.with_context( to_date=past_date, location=self.location_id.id ).browse(product_ids) res = [] in_stock_products = {} for product in products: qty = product.qty_available if not float_is_zero(qty, precision_digits=prec_qty): res.append( { "product_id": product.id, "qty": qty, "lot_id": False, "location_id": False, } ) in_stock_products[product.id] = True logger.debug("End compute_data_from_past_stock") return res, in_stock_products def group_result(self, data, split_by_lot, split_by_location): logger.debug( "Start group_result split_by_lot=%s, split_by_location=%s", split_by_lot, split_by_location, ) wdict = {} for l in data: key_list = [l["product_id"]] if split_by_lot: key_list.append(l["lot_id"]) if split_by_location: key_list.append(l["location_id"]) key = tuple(key_list) wdict.setdefault( key, dict( product_id=l["product_id"], lot_id=l["lot_id"], location_id=l["location_id"], qty=0.0, ), ) wdict[key]["qty"] += l["qty"] logger.debug("End group_result") return wdict.values() def stringify_and_sort_result( self, product_ids, product_id2data, data, prec_qty, prec_price, prec_cur_rounding, categ_id2name, uom_id2name, lot_id2data, loc_id2name, apply_depreciation, ): logger.debug("Start stringify_and_sort_result") res = [] for l in data: product_id = l["product_id"] qty = float_round(l["qty"], precision_digits=prec_qty) standard_price = float_round( product_id2data[product_id]["standard_price"], precision_digits=prec_price, ) subtotal_before_depreciation = float_round( standard_price * qty, precision_rounding=prec_cur_rounding ) depreciation_ratio = 0 if apply_depreciation and l["lot_id"]: depreciation_ratio = lot_id2data[l["lot_id"]].get( "depreciation_ratio", 0 ) subtotal = float_round( subtotal_before_depreciation * (1 - depreciation_ratio), precision_rounding=prec_cur_rounding, ) else: subtotal = subtotal_before_depreciation res.append( dict( product_id2data[product_id], product_name=product_id2data[product_id]["name"], loc_name=l["location_id"] and loc_id2name[l["location_id"]] or "", lot_name=l["lot_id"] and lot_id2data[l["lot_id"]]["name"] or "", expiry_date=l["lot_id"] and lot_id2data[l["lot_id"]].get("expiry_date"), depreciation_ratio=depreciation_ratio, qty=qty, uom_name=uom_id2name[product_id2data[product_id]["uom_id"]], standard_price=standard_price, subtotal_before_depreciation=subtotal_before_depreciation, subtotal=subtotal, categ_name=categ_id2name[product_id2data[product_id]["categ_id"]], ) ) sort_res = sorted(res, key=lambda x: x["product_name"]) logger.debug("End stringify_and_sort_result") return sort_res def generate(self): self.ensure_one() logger.debug("Start generate XLSX stock valuation report") splo = self.env["stock.production.lot"].with_context(active_test=False) prec_qty = self.env["decimal.precision"].precision_get( "Product Unit of Measure" ) prec_price = self.env["decimal.precision"].precision_get("Product Price") company = self.env.user.company_id company_id = company.id prec_cur_rounding = company.currency_id.rounding self._check_config(company_id) apply_depreciation = self.apply_depreciation if ( (self.source == "stock" and self.stock_date_type == "past") or not self.split_by_lot or not self.has_expiry_date ): apply_depreciation = False product_ids = self.get_product_ids() if not product_ids: raise UserError(_("There are no products to analyse.")) split_by_lot = self.split_by_lot split_by_location = self.split_by_location if self.source == "stock": if self.stock_date_type == "present": past_date = False data, in_stock_products = self.compute_data_from_present_stock( company_id, product_ids, prec_qty ) elif self.stock_date_type == "past": split_by_lot = False split_by_location = False past_date = self.past_date data, in_stock_products = self.compute_data_from_past_stock( product_ids, prec_qty, past_date ) elif self.source == "inventory": past_date = self.inventory_id.date data, in_stock_products = self.compute_data_from_inventory( product_ids, prec_qty ) standard_price_past_date = past_date if ( not (self.source == "stock" and self.stock_date_type == "present") and self.standard_price_date == "present" ): standard_price_past_date = False depreciation_rules = [] if apply_depreciation: depreciation_rules = self._prepare_expiry_depreciation_rules( company_id, past_date ) if not depreciation_rules: raise UserError( _("The are not stock depreciation rule for company '%s'.") % company.display_name ) in_stock_product_ids = list(in_stock_products.keys()) product_id2data = self.compute_product_data( company_id, in_stock_product_ids, standard_price_past_date=standard_price_past_date, ) data_res = self.group_result(data, split_by_lot, split_by_location) categ_id2name = self.product_categ_id2name(self.categ_ids) uom_id2name = self.uom_id2name() lot_id2data = self.prodlot_id2data( in_stock_product_ids, self.has_expiry_date, depreciation_rules ) loc_id2name = self.stock_location_id2name(self.location_id) res = self.stringify_and_sort_result( product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding, categ_id2name, uom_id2name, lot_id2data, loc_id2name, apply_depreciation, ) logger.debug("Start create XLSX workbook") file_data = BytesIO() workbook = xlsxwriter.Workbook(file_data) sheet = workbook.add_worksheet("Stock") styles = self._prepare_styles(workbook, company, prec_price) cols = self._prepare_cols() categ_subtotal = self.categ_subtotal # remove cols that we won't use if not split_by_lot: cols.pop("lot_name", None) cols.pop("expiry_date", None) if not self.has_expiry_date: cols.pop("expiry_date", None) if not split_by_location: cols.pop("loc_name", None) if not categ_subtotal: cols.pop("categ_subtotal", None) if not apply_depreciation: cols.pop("depreciation_ratio", None) cols.pop("subtotal_before_depreciation", None) j = 0 for col, col_vals in sorted(cols.items(), key=lambda x: x[1]["sequence"]): cols[col]["pos"] = j cols[col]["pos_letter"] = chr(j + 97).upper() sheet.set_column(j, j, cols[col]["width"]) j += 1 # HEADER now_dt = fields.Datetime.context_timestamp(self, datetime.now()) now_str = fields.Datetime.to_string(now_dt) if past_date: stock_time_utc_dt = past_date stock_time_dt = fields.Datetime.context_timestamp(self, stock_time_utc_dt) stock_time_str = fields.Datetime.to_string(stock_time_dt) else: stock_time_str = now_str if standard_price_past_date: standard_price_date_str = stock_time_str else: standard_price_date_str = now_str i = 0 sheet.write(i, 0, "Odoo - Stock Valuation", styles["doc_title"]) sheet.set_row(0, 26) i += 1 sheet.write(i, 0, "Inventory Date: %s" % stock_time_str, styles["doc_subtitle"]) i += 1 sheet.write( i, 0, "Cost Price Date: %s" % standard_price_date_str, styles["doc_subtitle"], ) i += 1 sheet.write( i, 0, "Stock location (children included): %s" % self.location_id.complete_name, styles["doc_subtitle"], ) if self.categ_ids: i += 1 sheet.write( i, 0, "Product Categories: %s" % ", ".join([categ.display_name for categ in self.categ_ids]), styles["doc_subtitle"], ) i += 1 sheet.write( i, 0, "Generated on %s by %s" % (now_str, self.env.user.name), styles["regular_small"], ) # TITLE of COLS i += 2 for col in cols.values(): sheet.write(i, col["pos"], col["title"], styles["col_title"]) i += 1 sheet.write(i, cols["subtotal"]["pos"] - 1, _("TOTAL:"), styles["total_title"]) total_row = i # LINES if categ_subtotal: categ_ids = categ_id2name.keys() else: categ_ids = [0] total = 0.0 letter_qty = cols["qty"]["pos_letter"] letter_price = cols["standard_price"]["pos_letter"] letter_subtotal = cols["subtotal"]["pos_letter"] if apply_depreciation: letter_subtotal_before_depreciation = cols["subtotal_before_depreciation"][ "pos_letter" ] letter_depreciation_ratio = cols["depreciation_ratio"]["pos_letter"] crow = 0 lines = res for categ_id in categ_ids: ctotal = 0.0 categ_has_line = False if categ_subtotal: # skip a line and save it's position as crow i += 1 crow = i lines = filter(lambda x: x["categ_id"] == categ_id, res) for l in lines: i += 1 total += l["subtotal"] ctotal += l["subtotal"] categ_has_line = True qty_by_price_formula = "=%s%d*%s%d" % ( letter_qty, i + 1, letter_price, i + 1, ) if apply_depreciation: sheet.write_formula( i, cols["subtotal_before_depreciation"]["pos"], qty_by_price_formula, styles["regular_currency"], l["subtotal_before_depreciation"], ) subtotal_formula = "=%s%d*(1 - %s%d)" % ( letter_subtotal_before_depreciation, i + 1, letter_depreciation_ratio, i + 1, ) else: subtotal_formula = qty_by_price_formula sheet.write_formula( i, cols["subtotal"]["pos"], subtotal_formula, styles["regular_currency"], l["subtotal"], ) for col_name, col in cols.items(): if not col.get("formula"): if col.get("type") == "date": if l[col_name]: l[col_name] = fields.Date.from_string(l[col_name]) else: l[col_name] = "" # to avoid display of 31/12/1899 sheet.write(i, col["pos"], l[col_name], styles[col["style"]]) if categ_subtotal: if categ_has_line: sheet.write(crow, 0, categ_id2name[categ_id], styles["categ_title"]) for x in range(cols["categ_subtotal"]["pos"] - 1): sheet.write(crow, x + 1, "", styles["categ_title"]) cformula = "=SUM(%s%d:%s%d)" % ( letter_subtotal, crow + 2, letter_subtotal, i + 1, ) sheet.write_formula( crow, cols["categ_subtotal"]["pos"], cformula, styles["categ_currency"], float_round(ctotal, precision_rounding=prec_cur_rounding), ) else: i -= 1 # go back to skipped line # Write total total_formula = "=SUM(%s%d:%s%d)" % ( letter_subtotal, total_row + 2, letter_subtotal, i + 1, ) sheet.write_formula( total_row, cols["subtotal"]["pos"], total_formula, styles["total_currency"], float_round(total, precision_rounding=prec_cur_rounding), ) workbook.close() logger.debug("End create XLSX workbook") file_data.seek(0) filename = "Odoo_stock_%s.xlsx" % stock_time_str.replace(" ", "-").replace( ":", "_" ) export_file_b64 = base64.b64encode(file_data.read()) self.write( { "state": "done", "export_filename": filename, "export_file": export_file_b64, } ) # action = { # 'name': _('Stock Valuation XLSX'), # 'type': 'ir.actions.act_url', # 'url': "web/content/?model=%s&id=%d&filename_field=export_filename&" # "field=export_file&download=true&filename=%s" % ( # self._name, self.id, self.export_filename), # 'target': 'self', # } action = self.env["ir.actions.act_window"].for_xml_id( "stock_valuation_xlsx", "stock_valuation_xlsx_action" ) action["res_id"] = self.id return action def _prepare_styles(self, workbook, company, prec_price): total_bg_color = "#faa03a" categ_bg_color = "#e1daf5" col_title_bg_color = "#fff9b4" regular_font_size = 10 currency_num_format = u"# ### ##0.00 %s" % company.currency_id.symbol price_currency_num_format = u"# ### ##0.%s %s" % ( "0" * prec_price, company.currency_id.symbol, ) styles = { "doc_title": workbook.add_format( { "bold": True, "font_size": regular_font_size + 10, "font_color": "#003b6f", } ), "doc_subtitle": workbook.add_format( {"bold": True, "font_size": regular_font_size} ), "col_title": workbook.add_format( { "bold": True, "bg_color": col_title_bg_color, "text_wrap": True, "font_size": regular_font_size, "align": "center", } ), "total_title": workbook.add_format( { "bold": True, "text_wrap": True, "font_size": regular_font_size + 2, "align": "right", "bg_color": total_bg_color, } ), "total_currency": workbook.add_format( {"num_format": currency_num_format, "bg_color": total_bg_color} ), "regular_date": workbook.add_format({"num_format": "dd/mm/yyyy"}), "regular_currency": workbook.add_format( {"num_format": currency_num_format} ), "regular_price_currency": workbook.add_format( {"num_format": price_currency_num_format} ), "regular_int_percent": workbook.add_format({"num_format": u"0.%"}), "regular": workbook.add_format({}), "regular_small": workbook.add_format({"font_size": regular_font_size - 2}), "categ_title": workbook.add_format( { "bold": True, "bg_color": categ_bg_color, "font_size": regular_font_size, } ), "categ_currency": workbook.add_format( {"num_format": currency_num_format, "bg_color": categ_bg_color} ), "date_title": workbook.add_format( {"bold": True, "font_size": regular_font_size, "align": "right"} ), "date_title_val": workbook.add_format( {"bold": True, "font_size": regular_font_size} ), } return styles def _prepare_cols(self): cols = { "default_code": { "width": 18, "style": "regular", "sequence": 10, "title": _("Product Code"), }, "product_name": { "width": 40, "style": "regular", "sequence": 20, "title": _("Product Name"), }, "loc_name": { "width": 25, "style": "regular_small", "sequence": 30, "title": _("Location Name"), }, "lot_name": { "width": 18, "style": "regular", "sequence": 40, "title": _("Lot"), }, "expiry_date": { "width": 11, "style": "regular_date", "sequence": 50, "title": _("Expiry Date"), "type": "date", }, "qty": {"width": 8, "style": "regular", "sequence": 60, "title": _("Qty")}, "uom_name": { "width": 5, "style": "regular_small", "sequence": 70, "title": _("UoM"), }, "standard_price": { "width": 14, "style": "regular_price_currency", "sequence": 80, "title": _("Cost Price"), }, "subtotal_before_depreciation": { "width": 16, "style": "regular_currency", "sequence": 90, "title": _("Sub-total"), "formula": True, }, "depreciation_ratio": { "width": 10, "style": "regular_int_percent", "sequence": 100, "title": _("Depreciation"), }, "subtotal": { "width": 16, "style": "regular_currency", "sequence": 110, "title": _("Sub-total"), "formula": True, }, "categ_subtotal": { "width": 16, "style": "regular_currency", "sequence": 120, "title": _("Categ Sub-total"), "formula": True, }, "categ_name": { "width": 40, "style": "regular_small", "sequence": 130, "title": _("Product Category"), }, } return cols