first commit

This commit is contained in:
Abdul Aziz Amrullah 2026-03-24 15:15:58 +07:00
parent 50b885e6f4
commit 3448fbaceb
13 changed files with 305 additions and 1 deletions

View File

@ -1,3 +1,50 @@
# pos_export_bc
This module adds a wizard in the POS backend to export POS Orders (Sales) into a specific BC Excel format
POS Export BC Format Module
This module adds a new wizard in the POS backend to export sales data into a specific Excel format ("BC Format").
User Review Required
NOTE
The current plan maps the requested Excel columns to standard Odoo POS fields as closely as possible. Columns like "Takeaway Charge", "Packaging Fee", "Service" will be left empty or 0 if there's no clear standard Odoo field for them. If you have custom fields for these on the pos.order model, please let me know so I can map them accurately. "Price Type" is mapped to the POS Order's Pricelist name. "Table/Customer" will prefer the Table name (if dining in) or Customer name. "Dinein" will be inferred based on whether a Table is set ("dinein") or not ("takeaway").
Proposed Changes
POS Export Module (pos_export_bc)
[NEW] pos_export_bc/__init__.py
Initialize the module directories (models, wizard).
[NEW] pos_export_bc/__manifest__.py
Define module metadata, dependencies (point_of_sale), and data files to load (wizard view, security).
[NEW] pos_export_bc/wizard/__init__.py
Import the wizard models.
[NEW] pos_export_bc/wizard/pos_export_bc_wizard.py
A TransientModel (pos.export.bc.wizard) that:
Prompts for start_date and end_date.
Has an action_export_bc method to query pos.order within the date range.
Uses xlsxwriter (or similar standard library) via io.BytesIO to generate the Excel file.
Generates 2 sheets: "Invoice" (orders where amount_total >= 0) and "Refund" (orders where amount_total < 0).
Writes the specific headers and loops through every order line. Order-level values (Subtotal, Tax, Charge, etc.) are only written on the first row of each order to match the screenshot format.
Returns a UI action to download the generated file.
[NEW] pos_export_bc/wizard/pos_export_bc_wizard_views.xml
Defines the Form view for the wizard containing the start_date, end_date inputs, and the "Export to BC Format" button. Also defines an Action and a Menu Item under Point of Sale > Reporting > Export BC Format.
[NEW] pos_export_bc/security/ir.model.access.csv
Grants read/write access to the pos.export.bc.wizard model so users can open and run it.
Verification Plan
Automated Tests
This is a UI export tool, so no complex backend automated tests will be added initially unless requested. We will rely on manual verification to ensure the Excel output exactly matches the user's expected visual layout.
Manual Verification
1. Install the pos_export_bc module locally or on the Odoo instance.
2. Go to Point of Sale > Reporting > Export BC Format.
3. Select a date range that contains some existing POS orders.
4. Click Export to BC Format.
5. Download the Excel file and open it.
6. Verify the file has "Invoice" and "Refund" sheets.
7. Verify the headers match exactly: No, Date, Outlet, Table/Customer, Invoice, Category, SKU, Product, etc.
8. Verify "MIE MAPAN" and "INVOICES" titles are present at the top.
9. Verify order-level data is correctly row-grouped as per the provided screenshot.

1
__init__.py Normal file
View File

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

18
__manifest__.py Normal file
View File

@ -0,0 +1,18 @@
{
'name': 'POS Export BC Format',
'version': '1.0',
'category': 'Point of Sale',
'summary': 'Export POS orders to BC Excel format',
'description': """
This module adds a wizard in the POS backend to export POS Orders
into a specific BC Excel format ("MIE MAPAN INVOICES").
""",
'depends': ['point_of_sale'],
'data': [
'security/ir.model.access.csv',
'wizard/pos_export_bc_wizard_views.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pos_export_bc_wizard,pos.export.bc.wizard,model_pos_export_bc_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_pos_export_bc_wizard pos.export.bc.wizard model_pos_export_bc_wizard base.group_user 1 1 1 1

1
wizard/__init__.py Normal file
View File

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

Binary file not shown.

View File

@ -0,0 +1,195 @@
import base64
import io
from datetime import datetime
import pytz
from odoo import api, fields, models, _
from odoo.exceptions import UserError
import odoo.tools
try:
import xlsxwriter
except ImportError:
xlsxwriter = None
class PosExportBcWizard(models.TransientModel):
_name = 'pos.export.bc.wizard'
_description = 'POS Export BC Format Wizard'
start_date = fields.Date(string="Start Date", required=True, default=fields.Date.context_today)
end_date = fields.Date(string="End Date", required=True, default=fields.Date.context_today)
def action_export_bc(self):
self.ensure_one()
if not xlsxwriter:
raise UserError(_("The Python library 'xlsxwriter' is required. Please install it."))
if self.start_date > self.end_date:
raise UserError(_("Start Date must be earlier or equal to End Date."))
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
# Define formats
header_format = workbook.add_format({
'bold': True, 'align': 'center', 'valign': 'vcenter', 'border': 1
})
title_format = workbook.add_format({
'bold': True, 'align': 'center', 'valign': 'vcenter', 'font_color': 'red', 'size': 14
})
subtitle_format = workbook.add_format({
'bold': True, 'align': 'center', 'valign': 'vcenter', 'font_color': 'black', 'size': 12
})
date_format = workbook.add_format({'num_format': 'dd-mm-yyyy'})
date_time_format = workbook.add_format({'num_format': 'dd-mm-yyyy hh:mm:ss'})
number_format = workbook.add_format({'num_format': '#,##0.00'})
headers = [
"No", "Date", "Outlet", "Table/Customer", "Invoice", "Category", "SKU", "Product", "Quantity",
"Price Type", "Price", "Price Cut", "Subtotal", "Discount", "Tax", "Service", "Takeaway Charge",
"Packaging Fee", "Rounding", "Charge", "Paid", "Pax", "Paid At", "Return", "Refund", "Payment",
"Note", "Dinein", "User", "Promo", "Order from", "Nama Penerima", "Alamat Penerima", "Link Maps"
]
# Datetime timezone conversion
# We need these in UTC for the domain search
user_tz_str = self.env.user.tz or 'UTC'
user_tz = pytz.timezone(user_tz_str)
start_datetime = datetime.combine(self.start_date, datetime.min.time())
end_datetime = datetime.combine(self.end_date, datetime.max.time())
start_utc = user_tz.localize(start_datetime).astimezone(pytz.UTC).replace(tzinfo=None)
end_utc = user_tz.localize(end_datetime).astimezone(pytz.UTC).replace(tzinfo=None)
def write_sheet(sheet_name, domain):
sheet = workbook.add_worksheet(sheet_name)
# Title
sheet.merge_range('A1:AF1', 'MIE MAPAN', title_format)
sheet.merge_range('A2:AF2', 'INVOICES', subtitle_format)
# Period
start_dt_str = start_datetime.strftime('%d-%m-%Y 00:00')
end_dt_str = end_datetime.strftime('%d-%m-%Y 23:59')
sheet.write('A3', 'Period')
sheet.write('B3', ':')
sheet.write('C3', f"{start_dt_str} - {end_dt_str}")
# Headers
for col_num, header in enumerate(headers):
sheet.write(4, col_num, header, header_format)
row_num = 5
orders = self.env['pos.order'].search(domain, order='date_order asc')
order_no = 1
for order in orders:
local_date = order.date_order.replace(tzinfo=pytz.UTC).astimezone(user_tz) if order.date_order else False
outlet = order.config_id.name or ''
table = order.table_id.display_name if 'table_id' in order._fields and order.table_id else ''
customer = order.partner_id.name or ''
table_customer = table or customer
invoice = order.pos_reference or order.name
subtotal = sum(l.price_subtotal for l in order.lines)
discount_order = 0.0 # Standard Odoo doesn't have an order level discount easily isolated from line discounts
tax = order.amount_tax
charge = order.amount_total
# In Odoo, payment amount can be negative for change.
# To get the total amount tendered before change, we sum only the positive payments.
# The change/return is typically stored in order.amount_return
paid = sum(p.amount for p in order.payment_ids if p.amount > 0)
pax = order.customer_count if 'customer_count' in order._fields else 1
return_amt = order.amount_return if 'amount_return' in order._fields else (paid - charge if paid > charge else 0)
payment_methods = ', '.join(order.payment_ids.mapped('payment_method_id.name'))
note = order.note if 'note' in order._fields else ''
# dinein = 'dinein' if table else 'takeaway'
preset = order.preset_id.name
if "dine" in preset.lower():
dinein = "dinein"
else:
dinein = "takeaway"
user = order.user_id.name or ''
is_first_line = True
for line in order.lines:
sheet.write(row_num, 0, order_no)
if local_date:
date_str = local_date.strftime('%d-%m-%Y %H:%M:%S')
sheet.write_string(row_num, 1, date_str)
sheet.write(row_num, 2, outlet)
sheet.write(row_num, 3, table_customer)
sheet.write(row_num, 4, invoice)
category = line.product_id.pos_categ_ids[0].name if line.product_id.pos_categ_ids else ''
sku = line.product_id.x_studio_popcorn_sku if 'x_studio_popcorn_sku' in line.product_id._fields and line.product_id.x_studio_popcorn_sku else ''
product_name = line.product_id.name or ''
qty = line.qty
price_type = order.pricelist_id.name or 'DEFAULT'
price = line.price_unit
price_cut = (line.price_unit * line.discount / 100) if line.discount else 0.0
sheet.write(row_num, 5, category)
sheet.write(row_num, 6, sku)
sheet.write(row_num, 7, product_name)
sheet.write(row_num, 8, qty)
sheet.write(row_num, 9, price_type)
sheet.write(row_num, 10, price, number_format)
sheet.write(row_num, 11, price_cut, number_format)
if is_first_line:
sheet.write(row_num, 12, subtotal, number_format)
sheet.write(row_num, 13, discount_order, number_format)
sheet.write(row_num, 14, tax, number_format)
sheet.write(row_num, 15, 0, number_format) # Service
sheet.write(row_num, 16, 0, number_format) # Takeaway Charge
sheet.write(row_num, 17, 0, number_format) # Packaging Fee
sheet.write(row_num, 18, 0, number_format) # Rounding
sheet.write(row_num, 19, charge, number_format)
sheet.write(row_num, 20, paid, number_format)
sheet.write(row_num, 21, pax)
if local_date:
date_str = local_date.strftime('%d-%m-%Y %H:%M:%S')
sheet.write_string(row_num, 22, date_str)
sheet.write(row_num, 23, return_amt, number_format)
sheet.write(row_num, 24, 0, number_format) # Refund
sheet.write(row_num, 25, payment_methods)
sheet.write(row_num, 26, note)
sheet.write(row_num, 27, dinein)
sheet.write(row_num, 28, user)
sheet.write(row_num, 29, "") # Promo
sheet.write(row_num, 30, "cashier") # Order from
sheet.write(row_num, 31, "")
sheet.write(row_num, 32, "")
sheet.write(row_num, 33, "")
is_first_line = False
row_num += 1
order_no += 1
domain_base = [('date_order', '>=', start_utc), ('date_order', '<=', end_utc)]
write_sheet('Invoice', domain_base + [('amount_total', '>=', 0)])
write_sheet('Refund', domain_base + [('amount_total', '<', 0)])
workbook.close()
output.seek(0)
# Save as an ir.attachment and return action to download
attachment = self.env['ir.attachment'].create({
'name': f"POS_BC_{self.start_date}_to_{self.end_date}.xlsx",
'type': 'binary',
'datas': base64.b64encode(output.read()),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'self',
}

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_pos_export_bc_wizard_form" model="ir.ui.view">
<field name="name">pos.export.bc.wizard.form</field>
<field name="model">pos.export.bc.wizard</field>
<field name="arch" type="xml">
<form string="Export to BC Format">
<sheet>
<group>
<group>
<field name="start_date" required="1"/>
</group>
<group>
<field name="end_date" required="1"/>
</group>
</group>
</sheet>
<footer>
<button name="action_export_bc" string="Export to BC Format" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="action_pos_export_bc_wizard" model="ir.actions.act_window">
<field name="name">Export BC Format</field>
<field name="res_model">pos.export.bc.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_pos_export_bc"
name="Export BC Format"
parent="point_of_sale.menu_point_rep"
action="action_pos_export_bc_wizard"
sequence="100"/>
</odoo>