diff --git a/custom_addons/dashboard/__init__.py b/custom_addons/dashboard/__init__.py index 0650744f6..91c5580fe 100644 --- a/custom_addons/dashboard/__init__.py +++ b/custom_addons/dashboard/__init__.py @@ -1 +1,2 @@ +from . import controllers from . import models diff --git a/custom_addons/dashboard/controllers/__init__.py b/custom_addons/dashboard/controllers/__init__.py new file mode 100644 index 000000000..3edaf326a --- /dev/null +++ b/custom_addons/dashboard/controllers/__init__.py @@ -0,0 +1 @@ +from . import sale_margin_excel \ No newline at end of file diff --git a/custom_addons/dashboard/controllers/sale_margin_excel.py b/custom_addons/dashboard/controllers/sale_margin_excel.py new file mode 100644 index 000000000..27ab0b093 --- /dev/null +++ b/custom_addons/dashboard/controllers/sale_margin_excel.py @@ -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')]) \ No newline at end of file diff --git a/custom_addons/dashboard/models/stock_dashboard.py b/custom_addons/dashboard/models/stock_dashboard.py index f703bb9d0..9a717e7ea 100644 --- a/custom_addons/dashboard/models/stock_dashboard.py +++ b/custom_addons/dashboard/models/stock_dashboard.py @@ -2,6 +2,7 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError from collections import defaultdict from functools import reduce +import pytz from datetime import datetime, time @@ -12,8 +13,22 @@ class SamashtiDashboard(models.AbstractModel): @api.model def get_stock_moves_data(self, from_date,to_date): - fromDate = "'"+str(from_date)+" 00:00:00'" - toDate = "'"+str(to_date)+" 23:59:59'" + from_date_obj = datetime.strptime(from_date, '%Y-%m-%d').date() + 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""" SELECT @@ -126,97 +141,86 @@ class SamashtiDashboard(models.AbstractModel): return [] @api.model - def get_sale_margin_data(self,from_date,to_date): - fromDate = "'"+str(from_date)+" 00:00:00'" - toDate = "'"+str(to_date)+" 23:59:59'" - datas = [] + def get_sale_margin_data(self, from_date, to_date): + # Get user's timezone + from_date_obj = datetime.strptime(from_date, '%Y-%m-%d').date() + 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( [ ('state', '=', 'sale'), - ('date_order', '>=', fromDate), - ('date_order', '<=', toDate) + ('invoice_ids', 'in', invoice_ids.ids) ], - ['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: - cost = r['total_production_cost'] - sale_price = r['amount_untaxed'] + datas = [] + + 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_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')) - weight = str(sum(self.env['sale.order'].browse(r['id']).order_line.mapped('bag_weight')))+" kg" - date = self.env['sale.order'].browse(r['id']).invoice_ids.invoice_date - invoice = self.env['sale.order'].browse(r['id']).invoice_ids.name + + customer = order_data['partner_id'][-1] if order_data['partner_id'] else "Unknown" + quantity = sum(order.order_line.mapped('product_uom_qty')) + weight = f"{sum(order.order_line.mapped('bag_weight'))} kg" + + # 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({ - 'sale_order': r['name'], - 'id':r['id'], - 'weight':weight, - 'invoice':invoice, - 'customer':customer, - 'quantity':quantity, + 'sale_order': order_data['name'], + 'id': order_data['id'], + 'weight': weight, + 'tags': product_tags, + 'invoice': invoice, + 'customer': customer, + 'quantity': quantity, 'cost': cost, - 'date':date, + 'date': date, 'sale_price': sale_price, 'margin': margin, - 'margin_percent':margin_percent + 'margin_percent': margin_percent, }) + 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): 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')] diff --git a/custom_addons/dashboard/static/src/components/pqgrid_dashboard/pqgrid_stock_dashboard.js b/custom_addons/dashboard/static/src/components/pqgrid_dashboard/pqgrid_stock_dashboard.js index 99616935c..688a64715 100644 --- a/custom_addons/dashboard/static/src/components/pqgrid_dashboard/pqgrid_stock_dashboard.js +++ b/custom_addons/dashboard/static/src/components/pqgrid_dashboard/pqgrid_stock_dashboard.js @@ -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 async getColumns() { return [ @@ -176,9 +216,10 @@ export class SamashtiDashboard extends Component { async getSaleColumns(){ return[ { 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: "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: "Weight", dataIndx: "weight", width: 150 }, { 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, toolbar: { items: [ - { - type: 'select', - label: 'Format:', - 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", 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', @@ -347,6 +382,7 @@ export class SamashtiDashboard extends Component { // Then set groupModel using groupOption const groupModel = { on: true, + dataIndx: ['tags'], checkbox: true, checkboxHead: true, header:true, @@ -424,64 +460,6 @@ export class SamashtiDashboard extends Component { 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); - } - } }