first commit
This commit is contained in:
parent
50b885e6f4
commit
3448fbaceb
49
README.md
49
README.md
@ -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
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
18
__manifest__.py
Normal file
18
__manifest__.py
Normal 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',
|
||||
}
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/pos_order.cpython-312.pyc
Normal file
BIN
models/__pycache__/pos_order.cpython-312.pyc
Normal file
Binary file not shown.
2
security/ir.model.access.csv
Normal file
2
security/ir.model.access.csv
Normal 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
wizard/__init__.py
Normal file
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import pos_export_bc_wizard
|
||||
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
wizard/__pycache__/pos_export_bc_wizard.cpython-311.pyc
Normal file
BIN
wizard/__pycache__/pos_export_bc_wizard.cpython-311.pyc
Normal file
Binary file not shown.
BIN
wizard/__pycache__/pos_export_bc_wizard.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/pos_export_bc_wizard.cpython-312.pyc
Normal file
Binary file not shown.
195
wizard/pos_export_bc_wizard.py
Normal file
195
wizard/pos_export_bc_wizard.py
Normal 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',
|
||||
}
|
||||
40
wizard/pos_export_bc_wizard_views.xml
Normal file
40
wizard/pos_export_bc_wizard_views.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user