Files
odoo-usability/stock_valuation_xlsx/wizard/stock_valuation_xlsx.py
2021-11-26 18:57:02 +03:00

917 lines
33 KiB
Python

# Copyright 2020 Akretion France (http://www.akretion.com/)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# 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