sale margin report export excel print

This commit is contained in:
raman 2025-11-13 17:07:51 +05:30
parent 0aeb0132cc
commit 4b90321123
5 changed files with 273 additions and 143 deletions

View File

@ -1 +1,2 @@
from . import controllers
from . import models from . import models

View File

@ -0,0 +1 @@
from . import sale_margin_excel

View File

@ -0,0 +1,146 @@
import io
import json
import xlsxwriter
from odoo import http
from odoo.http import request
class SaleMarginController(http.Controller):
@http.route('/sale_margin/report_excel', type='http', auth='user', methods=['POST'], csrf=False)
def report_excel(self, **post):
try:
# Safely get JSON data from the POST body
data = json.loads(request.httprequest.data) or {}
col_model = data.get('colModel')
grid_data = data.get('gridData')
if not col_model or not grid_data:
return request.make_response('Missing data', [('Content-Type', 'text/plain')])
# ---------------------------------------------
# 1⃣ Create Excel in memory
# ---------------------------------------------
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
worksheet = workbook.add_worksheet('Sale Margin Report')
# ---------------------------------------------
# 2⃣ Define Excel formats
# ---------------------------------------------
title_fmt = workbook.add_format({
'bold': True, 'font_size': 16, 'font_color': 'white',
'align': 'center', 'valign': 'vcenter',
'bg_color': '#4F81BD', 'border': 1
})
header_fmt = workbook.add_format({
'bold': True, 'font_size': 11, 'font_color': 'white',
'align': 'center', 'valign': 'vcenter',
'bg_color': '#366092', 'border': 1
})
text_fmt = workbook.add_format({
'font_size': 10, 'align': 'left', 'valign': 'vcenter', 'border': 1
})
float_fmt = workbook.add_format({
'font_size': 10, 'align': 'right', 'valign': 'vcenter',
'border': 1, 'num_format': '#,##0.00'
})
group_fmt = workbook.add_format({
'bold': True, 'font_size': 11, 'align': 'left',
'bg_color': '#DCE6F1', 'border': 1
})
subtotal_fmt = workbook.add_format({
'bold': True, 'font_size': 10, 'align': 'right',
'bg_color': '#EAF1DD', 'border': 1, 'num_format': '#,##0.00'
})
# ---------------------------------------------
# 3⃣ Title and headers
# ---------------------------------------------
visible_cols = [c for c in col_model if not c.get('hidden')]
visible_cols.pop(-1)
last_col = len(visible_cols) - 1
worksheet.merge_range(0, 0, 0, last_col, 'SALE MARGIN REPORT', title_fmt)
for col_idx, col in enumerate(visible_cols):
worksheet.write(1, col_idx, col.get('title', col.get('dataIndx', '')), header_fmt)
worksheet.set_column(col_idx, col_idx, 15)
# ---------------------------------------------
# 4⃣ Group data by "tag"
# ---------------------------------------------
group_field = 'tags' # <-- adjust if your grouping field has a different key name
grouped = {}
for row in grid_data:
group_key = row.get(group_field, 'Ungrouped')
grouped.setdefault(group_key, []).append(row)
# ---------------------------------------------
# 5⃣ Write grouped data + subtotal per group
# ---------------------------------------------
row_pos = 2
for group_name, rows in grouped.items():
# Group header
worksheet.merge_range(row_pos, 0, row_pos, last_col, f'Group: {group_name}', group_fmt)
row_pos += 1
# Track totals per numeric column
group_sums = [0.0] * len(visible_cols)
# Track margin_percent separately
margin_sum = 0
margin_count = 0
margin_idx = None
# Find index of margin_percent column
for idx, col in enumerate(visible_cols):
if col.get('dataIndx') == 'margin_percent':
margin_idx = idx
break
# Write group rows
for row_data in rows:
for col_idx, col in enumerate(visible_cols):
key = col.get('dataIndx')
val = row_data.get(key, '')
if isinstance(val, (int, float)):
if key == 'margin_percent':
margin_sum += val
margin_count += 1
worksheet.write_number(row_pos, col_idx, val, float_fmt)
else:
worksheet.write_number(row_pos, col_idx, val, float_fmt)
group_sums[col_idx] += val
else:
worksheet.write(row_pos, col_idx, str(val), text_fmt)
row_pos += 1
# Subtotal line
worksheet.write(row_pos, 0, f'{group_name} Total', subtotal_fmt)
for col_idx, total in enumerate(group_sums):
if col_idx > 0 and total != 0:
worksheet.write_number(row_pos, col_idx, total, subtotal_fmt)
# Write margin_percent average
if margin_idx is not None and margin_count > 0:
margin_avg = margin_sum / margin_count
worksheet.write_number(row_pos, margin_idx, margin_avg, subtotal_fmt)
row_pos += 2 # leave a blank line after subtotal
# ---------------------------------------------
# Finalize and return file
# ---------------------------------------------
workbook.close()
output.seek(0)
filecontent = output.read()
headers = [
('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', 'attachment; filename="Sale_Margin_Report.xlsx"'),
]
return request.make_response(filecontent, headers=headers)
except Exception as e:
return request.make_response(f"Error: {str(e)}", [('Content-Type', 'text/plain')])

View File

@ -2,6 +2,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from collections import defaultdict from collections import defaultdict
from functools import reduce from functools import reduce
import pytz
from datetime import datetime, time from datetime import datetime, time
@ -12,8 +13,22 @@ class SamashtiDashboard(models.AbstractModel):
@api.model @api.model
def get_stock_moves_data(self, from_date,to_date): def get_stock_moves_data(self, from_date,to_date):
fromDate = "'"+str(from_date)+" 00:00:00'" from_date_obj = datetime.strptime(from_date, '%Y-%m-%d').date()
toDate = "'"+str(to_date)+" 23:59:59'" to_date_obj = datetime.strptime(to_date, '%Y-%m-%d').date()
# Get user's timezone
user_tz = self.env.user.tz or 'UTC'
local_tz = pytz.timezone(user_tz)
# Convert local datetime to UTC
from_local = local_tz.localize(datetime.combine(from_date_obj, time.min))
to_local = local_tz.localize(datetime.combine(to_date_obj, time.max))
from_utc = from_local.astimezone(pytz.UTC)
to_utc = to_local.astimezone(pytz.UTC)
# Convert to string in Odoo datetime format
fromDate = "'"+str(fields.Datetime.to_string(from_utc))+"'"
toDate = "'"+str(fields.Datetime.to_string(to_utc))+"'"
sql = f""" sql = f"""
SELECT SELECT
@ -126,97 +141,86 @@ class SamashtiDashboard(models.AbstractModel):
return [] return []
@api.model @api.model
def get_sale_margin_data(self,from_date,to_date): def get_sale_margin_data(self, from_date, to_date):
fromDate = "'"+str(from_date)+" 00:00:00'" # Get user's timezone
toDate = "'"+str(to_date)+" 23:59:59'" from_date_obj = datetime.strptime(from_date, '%Y-%m-%d').date()
datas = [] to_date_obj = datetime.strptime(to_date, '%Y-%m-%d').date()
# Get user's timezone
user_tz = self.env.user.tz or 'UTC'
local_tz = pytz.timezone(user_tz)
# Convert local datetime to UTC
from_local = local_tz.localize(datetime.combine(from_date_obj, time.min))
to_local = local_tz.localize(datetime.combine(to_date_obj, time.max))
from_utc = from_local.astimezone(pytz.UTC)
to_utc = to_local.astimezone(pytz.UTC)
# Convert to string in Odoo datetime format
from_datetime = fields.Datetime.to_string(from_utc)
to_datetime = fields.Datetime.to_string(to_utc)
# Search for posted invoices within the date range
invoice_ids = self.env['account.move'].search([
('state', '=', 'posted'),
('invoice_date', '>=', from_datetime),
('invoice_date', '<=', to_datetime)
])
# Read sale orders linked to those invoices
sale_orders = self.env['sale.order'].search_read( sale_orders = self.env['sale.order'].search_read(
[ [
('state', '=', 'sale'), ('state', '=', 'sale'),
('date_order', '>=', fromDate), ('invoice_ids', 'in', invoice_ids.ids)
('date_order', '<=', toDate)
], ],
['id', 'name', 'amount_untaxed', 'partner_id', 'total_production_cost','date_order'] [
'id', 'name', 'amount_untaxed', 'partner_id',
'total_production_cost', 'date_order', 'order_line'
]
) )
for r in sale_orders: datas = []
cost = r['total_production_cost']
sale_price = r['amount_untaxed'] for order_data in sale_orders:
order = self.env['sale.order'].browse(order_data['id'])
cost = order_data['total_production_cost'] or 0.0
sale_price = order_data['amount_untaxed'] or 0.0
margin = sale_price - cost margin = sale_price - cost
margin_percent = (margin / sale_price * 100) if sale_price else 0.0 margin_percent = (margin / sale_price * 100) if sale_price else 0.0
customer = r['partner_id'][-1]
quantity = sum(self.env['sale.order'].browse(r['id']).order_line.mapped('product_uom_qty')) customer = order_data['partner_id'][-1] if order_data['partner_id'] else "Unknown"
weight = str(sum(self.env['sale.order'].browse(r['id']).order_line.mapped('bag_weight')))+" kg" quantity = sum(order.order_line.mapped('product_uom_qty'))
date = self.env['sale.order'].browse(r['id']).invoice_ids.invoice_date weight = f"{sum(order.order_line.mapped('bag_weight'))} kg"
invoice = self.env['sale.order'].browse(r['id']).invoice_ids.name
# Combine product tags
product_tags = ', '.join(
tag.name for tag in order.order_line.mapped('product_id.all_product_tag_ids')
)
# Combine invoice names and dates
if order.invoice_ids:
invoice = ', '.join(inv.name for inv in order.invoice_ids)
date = ', '.join(str(inv.invoice_date) for inv in order.invoice_ids if inv.invoice_date)
else:
invoice = "N/A"
date = "No invoices"
datas.append({ datas.append({
'sale_order': r['name'], 'sale_order': order_data['name'],
'id':r['id'], 'id': order_data['id'],
'weight':weight, 'weight': weight,
'invoice':invoice, 'tags': product_tags,
'customer':customer, 'invoice': invoice,
'quantity':quantity, 'customer': customer,
'quantity': quantity,
'cost': cost, 'cost': cost,
'date':date, 'date': date,
'sale_price': sale_price, 'sale_price': sale_price,
'margin': margin, 'margin': margin,
'margin_percent':margin_percent 'margin_percent': margin_percent,
}) })
return datas return datas
where_caluse = f" AND so.date_order BETWEEN {fromDate} AND {toDate}"
sql = """
SELECT
so.name AS sale_order,
COALESCE(pp.default_code, '') AS product_code,
'[' || (COALESCE(pp.default_code, '') || '] ' || jsonb_extract_path_text(pt.name, rp.lang)) AS product_name,
pc.complete_name AS category,
sl.product_uom_qty AS quantity,
COALESCE(sl.unit_prod_cost, 1) AS unit_cost,
COALESCE(sl.price_unit, 1) AS unit_sale_price,
ABS(COALESCE(sl.unit_prod_cost, 1) - COALESCE(sl.price_unit, 1)) AS margin,
sl.product_uom_qty * COALESCE(sl.unit_prod_cost, 1) AS total_cost,
sl.product_uom_qty * COALESCE(sl.price_unit, 1) AS total_sale_price,
ABS((sl.product_uom_qty * COALESCE(sl.unit_prod_cost, 1))
- (sl.product_uom_qty * COALESCE(sl.price_unit, 1))) AS total_margin
FROM sale_order_line sl
JOIN sale_order so
ON so.id = sl.order_id
JOIN product_product pp
ON pp.id = sl.product_id
JOIN product_template pt
ON pt.id = pp.product_tmpl_id
LEFT JOIN product_category pc
ON pc.id = pt.categ_id
JOIN res_company rc
ON rc.id = sl.company_id
JOIN res_partner rp
ON rp.id = rc.partner_id
WHERE sl.state = 'sale'
"""
if where_caluse:
sql += where_caluse
self.env.cr.execute(sql)
data = self.env.cr.dictfetchall()
if data:
return data
else:
return []
def _get_production_cost(self, sale_order_id):
sale_order = self.env['sale.order'].browse(sale_order_id)
cost = 0.0
for line in sale_order.order_line.filtered(lambda l: l.product_id.type == 'consu'):
line_cost = line.purchase_price or 0
cost += line_cost * line.product_uom_qty
for line in sale_order.order_line.filtered(lambda l: l.product_id.type == 'service' and l.price_total > 0):
cost += line.price_total
return cost
def action_view_sale_orders(self, id): def action_view_sale_orders(self, id):
result = self.env['ir.actions.act_window']._for_xml_id('sale.action_orders') result = self.env['ir.actions.act_window']._for_xml_id('sale.action_orders')
result['views'] = [(self.env.ref('sale.view_order_form', False).id, 'form')] result['views'] = [(self.env.ref('sale.view_order_form', False).id, 'form')]

View File

@ -156,6 +156,46 @@ export class SamashtiDashboard extends Component {
} }
async export_Excel() {
try {
if ($(this.gridSaleContainer.el).length > 0) {
const grid = $(this.gridSaleContainer.el).pqGrid('instance');
const colModel = grid.option('colModel');
const dataModel = grid.option('dataModel');
const gridData = dataModel.data || dataModel;
const response = await fetch('/sale_margin/report_excel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
colModel: colModel,
gridData: gridData,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'Sale_Margin_Report.xlsx';
document.body.appendChild(a);
a.click();
a.remove();
}
} catch (error) {
console.error('Export failed:', error);
alert('Export failed: ' + error.message);
}
}
// --- Define grid columns // --- Define grid columns
async getColumns() { async getColumns() {
return [ return [
@ -176,9 +216,10 @@ export class SamashtiDashboard extends Component {
async getSaleColumns(){ async getSaleColumns(){
return[ return[
{ title: "Sale Order", dataIndx: "sale_order", width: 100,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } }, { title: "Sale Order", dataIndx: "sale_order", width: 100,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Invoice", dataIndx: "invoice", width: 100,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Customer", dataIndx: "customer", width: 280,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Date", dataIndx: "date", width: 150 }, { title: "Date", dataIndx: "date", width: 150 },
{ title: "Invoice", dataIndx: "invoice", width: 180,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Customer", dataIndx: "customer", width: 280,filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Tags", dataIndx:"tags", width:100, },
{ title: "Quantity", dataIndx: "quantity", width: 120, dataType: "float", format: "#,###.00",summary: { type: "sum" },align: "right" }, { title: "Quantity", dataIndx: "quantity", width: 120, dataType: "float", format: "#,###.00",summary: { type: "sum" },align: "right" },
{ title: "Weight", dataIndx: "weight", width: 150 }, { title: "Weight", dataIndx: "weight", width: 150 },
{ title: "Production Cost", dataIndx: "cost", width: 120, dataType: "float", format: "#,###.00",summary: { type: "sum" },align: "right" }, { title: "Production Cost", dataIndx: "cost", width: 120, dataType: "float", format: "#,###.00",summary: { type: "sum" },align: "right" },
@ -307,15 +348,9 @@ export class SamashtiDashboard extends Component {
colModel: columns, colModel: columns,
toolbar: { toolbar: {
items: [ items: [
{
type: 'select',
label: '<span style="color: #555; font-size: 13px;">Format:</span>',
attr: 'id="export_format" style="margin-left: 10px; margin-right: 20px; padding: 5px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fafafa; color: #333; font-size: 13px; min-width: 110px;"',
options: [{ xlsx: '📊 Excel', csv: '📝 CSV', htm: '🌐 HTML', json: '🔤 JSON'}]
},
{ type: "button", label: "Export", icon: "ui-icon-arrowthickstop-1-s", { type: "button", label: "Export", icon: "ui-icon-arrowthickstop-1-s",
attr: 'style="padding: 8px 16px; border: 1px solid #3498db; border-radius: 6px; background: linear-gradient(135deg, #3498db, #2980b9); color: white; font-weight: 600; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3);"', attr: 'style="padding: 8px 16px; border: 1px solid #3498db; border-radius: 6px; background: linear-gradient(135deg, #3498db, #2980b9); color: white; font-weight: 600; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3);"',
listener: () => this.exportSaleGrid() }, listener: () => this.export_Excel() },
{ {
type: 'button', type: 'button',
@ -347,6 +382,7 @@ export class SamashtiDashboard extends Component {
// Then set groupModel using groupOption // Then set groupModel using groupOption
const groupModel = { const groupModel = {
on: true, on: true,
dataIndx: ['tags'],
checkbox: true, checkbox: true,
checkboxHead: true, checkboxHead: true,
header:true, header:true,
@ -424,64 +460,6 @@ export class SamashtiDashboard extends Component {
alert('Unsupported format: ' + format_ex); alert('Unsupported format: ' + format_ex);
} }
} }
exportSaleGrid() {
const format_ex = $("#export_format").val();
const grid = $(this.gridSaleContainer.el);
console.log("Starting export with format:", format_ex);
// Different handling for different formats
switch(format_ex) {
case 'xlsx':
const blobXlsx = grid.pqGrid("exportData", {
format: 'xlsx',
render: true
});
saveAs(blobXlsx, "SaleMarginData.xlsx");
break;
case 'csv':
const blobCsv = grid.pqGrid("exportData", {
format: 'csv',
render: true
});
// CSV often returns as string
if (typeof blobCsv === 'string') {
saveAs(new Blob([blobCsv], { type: 'text/csv' }), "StockData.csv");
} else {
saveAs(blobCsv, "SaleMarginData.csv");
}
break;
case 'htm':
case 'html':
const blobHtml = grid.pqGrid("exportData", {
format: 'htm',
render: true
});
if (typeof blobHtml === 'string') {
saveAs(new Blob([blobHtml], { type: 'text/html' }), "StockData.html");
} else {
saveAs(blobHtml, "SaleMarginData.html");
}
break;
case 'json':
const blobJson = grid.pqGrid("exportData", {
format: 'json',
render: true
});
if (typeof blobJson === 'string') {
saveAs(new Blob([blobJson], { type: 'application/json' }), "StockData.json");
} else {
saveAs(blobJson, "SaleMargin.json");
}
break;
default:
alert('Unsupported format: ' + format_ex);
}
}
} }