7 Commits

468 changed files with 69392 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
====================================
Odoo 19 Accounting Financial Reports
====================================
This Module will provide all the financial reports for odoo 19
community edition
Installation
============
To install this module, you need to:
Download the module and add it to your Odoo addons folder. Afterward, log on to
your Odoo server and go to the Apps menu. Trigger the debug mode and update the
list by clicking on the "Update Apps List" link. Now install the module by
clicking on the install button.
Upgrade
============
To upgrade this module, you need to:
Download the module and add it to your Odoo addons folder. Restart the server
and log on to your Odoo server. Select the Apps menu and upgrade the module by
clicking on the upgrade button.
Configuration
=============
There is Nothing to Configure
Credits
=======
Contributors
------------
* Odoo Mates <odoomates@gmail.com>
Author & Maintainer
-------------------
This module is maintained by the Odoo Mates

View File

@@ -0,0 +1,7 @@
from . import wizard
from . import models
from . import report
def _pre_init_clean_m2m_models(env):
env.cr.execute("""DROP TABLE IF EXISTS account_journal_account_report_partner_ledger_rel""")

View File

@@ -0,0 +1,45 @@
{
'name': 'Odoo 19 Accounting Financial Reports',
'version': '19.0.1.0.2', # __odoosky_original_version__: '1.0.2'
'category': 'Invoicing Management',
'description': 'Accounting Reports For Odoo 19, Accounting Financial Reports, '
'Odoo 19 Financial Reports',
'summary': 'Accounting Reports For Odoo 19',
'sequence': '1',
'author': 'Odoo Mates, Odoo SA',
'license': 'LGPL-3',
'company': 'Odoo Mates',
'maintainer': 'Odoo Mates',
'support': 'odoomates@gmail.com',
'website': 'https://www.youtube.com/watch?v=yA4NLwOLZms',
'depends': ['account'],
'live_test_url': 'https://www.youtube.com/watch?v=yA4NLwOLZms',
'data': [
'security/ir.model.access.csv',
'data/account_account_type.xml',
'views/menu.xml',
'views/ledger_menu.xml',
'views/financial_report.xml',
'views/settings.xml',
'wizard/account_report_common_view.xml',
'wizard/partner_ledger.xml',
'wizard/general_ledger.xml',
'wizard/trial_balance.xml',
'wizard/balance_sheet.xml',
'wizard/profit_and_loss.xml',
'wizard/tax_report.xml',
'wizard/aged_partner.xml',
'wizard/journal_audit.xml',
'report/report.xml',
'report/report_partner_ledger.xml',
'report/report_general_ledger.xml',
'report/report_trial_balance.xml',
'report/report_financial.xml',
'report/report_tax.xml',
'report/report_aged_partner.xml',
'report/report_journal_audit.xml',
'report/report_journal_entries.xml',
],
'pre_init_hook': '_pre_init_clean_m2m_models',
'images': ['static/description/banner.gif'],
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="account.account.type" id="data_account_type_receivable">
<field name="name">Receivable</field>
<field name="type">asset_receivable</field>
</record>
<record model="account.account.type" id="data_account_type_payable">
<field name="name">Payable</field>
<field name="type">liability_payable</field>
</record>
<record model="account.account.type" id="data_account_type_liquidity">
<field name="name">Bank and Cash</field>
<field name="type">asset_cash</field>
</record>
<record model="account.account.type" id="data_account_type_credit_card">
<field name="name">Credit Card</field>
<field name="type">liability_credit_card</field>
</record>
<record model="account.account.type" id="data_account_type_current_assets">
<field name="name">Current Assets</field>
<field name="type">asset_current</field>
</record>
<record model="account.account.type" id="data_account_type_non_current_assets">
<field name="name">Non-current Assets</field>
<field name="type">asset_non_current</field>
</record>
<record model="account.account.type" id="data_account_type_prepayments">
<field name="name">Prepayments</field>
<field name="type">asset_prepayments</field>
</record>
<record model="account.account.type" id="data_account_type_fixed_assets">
<field name="name">Fixed Assets</field>
<field name="type">asset_fixed</field>
</record>
<record model="account.account.type" id="data_account_type_current_liabilities">
<field name="name">Current Liabilities</field>
<field name="type">liability_current</field>
</record>
<record model="account.account.type" id="data_account_type_non_current_liabilities">
<field name="name">Non-current Liabilities</field>
<field name="type">liability_non_current</field>
</record>
<record model="account.account.type" id="data_account_type_equity">
<field name="name">Equity</field>
<field name="type">equity</field>
</record>
<record model="account.account.type" id="data_unaffected_earnings">
<field name="name">Current Year Earnings</field>
<field name="type">equity_unaffected</field>
</record>
<record model="account.account.type" id="data_account_type_revenue">
<field name="name">Income</field>
<field name="type">income</field>
</record>
<record model="account.account.type" id="data_account_type_other_income">
<field name="name">Other Income</field>
<field name="type">income_other</field>
</record>
<record model="account.account.type" id="data_account_type_expenses">
<field name="name">Expenses</field>
<field name="type">expense</field>
</record>
<record model="account.account.type" id="data_account_type_depreciation">
<field name="name">Depreciation</field>
<field name="type">expense_depreciation</field>
</record>
<record model="account.account.type" id="data_account_type_direct_costs">
<field name="name">Cost of Revenue</field>
<field name="type">expense_direct_cost</field>
</record>
<record model="account.account.type" id="data_account_off_sheet">
<field name="name">Off-Balance Sheet</field>
<field name="type">off_balance</field>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
from . import account_account_type
from . import account_financial_report
from . import account_move_line

View File

@@ -0,0 +1,34 @@
from odoo import api, models, fields
class AccountAccountType(models.Model):
_name = "account.account.type"
_description = "Account Account Type"
name = fields.Char('Name', required=True, translate=True)
type = fields.Selection(
selection=[
("asset_receivable", "Receivable"),
("asset_cash", "Bank and Cash"),
("asset_current", "Current Assets"),
("asset_non_current", "Non-current Assets"),
("asset_prepayments", "Prepayments"),
("asset_fixed", "Fixed Assets"),
("liability_payable", "Payable"),
("liability_credit_card", "Credit Card"),
("liability_current", "Current Liabilities"),
("liability_non_current", "Non-current Liabilities"),
("equity", "Equity"),
("equity_unaffected", "Current Year Earnings"),
("income", "Income"),
("income_other", "Other Income"),
("expense", "Expenses"),
("expense_depreciation", "Depreciation"),
("expense_direct_cost", "Cost of Revenue"),
("off_balance", "Off-Balance Sheet"),
],
string="Type",
help="These types are defined according to your country. The type contains more information " \
"about the account and its specificities."
)

View File

@@ -0,0 +1,74 @@
from odoo import api, models, fields
class AccountFinancialReport(models.Model):
_name = "account.financial.report"
_description = "Account Report"
@api.depends('parent_id', 'parent_id.level')
def _get_level(self):
'''Returns a dictionary with key=the ID of a record and value = the level of this
record in the tree structure.'''
for report in self:
level = 0
if report.parent_id:
level = report.parent_id.level + 1
report.level = level
def _get_children_by_order(self):
res = self
children = self.search([('parent_id', 'in', self.ids)], order='sequence ASC')
if children:
for child in children:
res += child._get_children_by_order()
return res
name = fields.Char('Report Name', required=True, translate=True)
parent_id = fields.Many2one('account.financial.report', 'Parent')
children_ids = fields.One2many('account.financial.report', 'parent_id', 'Account Report')
sequence = fields.Integer('Sequence')
level = fields.Integer(compute='_get_level', string='Level', store=True, recursive=True)
type = fields.Selection([
('sum', 'View'),
('accounts', 'Accounts'),
('account_type', 'Account Type'),
('account_report', 'Report Value'),
], 'Type', default='sum')
account_ids = fields.Many2many(
'account.account', 'account_account_financial_report',
'report_line_id', 'account_id', 'Accounts'
)
account_report_id = fields.Many2one('account.financial.report', 'Report Value')
account_type_ids = fields.Many2many(
'account.account.type', 'account_account_financial_report_type',
'report_id', 'account_type_id', 'Account Types'
)
report_domain = fields.Char(string="Report Domain")
sign = fields.Selection(
[('-1', 'Reverse balance sign'), ('1', 'Preserve balance sign')], 'Sign on Reports',
required=True, default='1',
help='For accounts that are typically more debited than credited and that you would '
'like to print as negative amounts in your reports, you should reverse the sign '
'of the balance; e.g.: Expense account. The same applies for accounts that are '
'typically more credited than debited and that you would like to print as positive '
'amounts in your reports; e.g.: Income account.'
)
display_detail = fields.Selection([
('no_detail', 'No detail'),
('detail_flat', 'Display children flat'),
('detail_with_hierarchy', 'Display children with hierarchy')
], 'Display details', default='detail_flat')
style_overwrite = fields.Selection([
('0', 'Automatic formatting'),
('1', 'Main Title 1 (bold, underlined)'),
('2', 'Title 2 (bold)'),
('3', 'Title 3 (bold, smaller)'),
('4', 'Normal Text'),
('5', 'Italic Text (smaller)'),
('6', 'Smallest Text'),
], 'Financial Report Style', default='0',
help="You can set up here the format you want this record to be displayed. "
"If you leave the automatic formatting, it will be computed based on the "
"financial reports hierarchy (auto-computed field 'level').")
children_ids = fields.One2many('account.financial.report', 'parent_id', string='Children')

View File

@@ -0,0 +1,117 @@
import ast
from odoo.osv import expression
from odoo import api, models, fields
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
@api.model
def _where_calc(self, domain, active_test=True):
"""Computes the WHERE clause needed to implement an OpenERP domain.
:param list domain: the domain to compute
:param bool active_test: whether the default filtering of records with
``active`` field set to ``False`` should be applied.
:return: the query expressing the given domain as provided in domain
:rtype: Query
"""
# if the object has an active field ('active', 'x_active'), filter out all
# inactive records unless they were explicitly asked for
if self._active_name and active_test and self.env.context.get('active_test', True):
# the item[0] trick below works for domain items and '&'/'|'/'!'
# operators too
if not any(item[0] == self._active_name for item in domain):
domain = [(self._active_name, '=', 1)] + domain
if domain:
return expression.expression(domain, self).query
else:
return Query(self.env, self._table, self._table_sql)
@api.model
def _apply_ir_rules(self, query, mode='read'):
"""Add what's missing in ``query`` to implement all appropriate ir.rules
(using the ``model_name``'s rules or the current model's rules if ``model_name`` is None)
:param query: the current query object
"""
if self.env.su:
return
# apply main rules on the object
Rule = self.env['ir.rule']
domain = Rule._compute_domain(self._name, mode)
if domain:
expression.expression(domain, self.sudo(), self._table, query)
@api.model
def _query_get(self, domain=None):
self.check_access('read')
context = dict(self.env.context or {})
domain = domain or []
if not isinstance(domain, (list, tuple)):
domain = ast.literal_eval(domain)
date_field = 'date'
if context.get('aged_balance'):
date_field = 'date_maturity'
if context.get('date_to'):
domain += [(date_field, '<=', context['date_to'])]
if context.get('date_from'):
if not context.get('strict_range'):
domain += ['|', (date_field, '>=', context['date_from']), ('account_id.include_initial_balance', '=', True)]
elif context.get('initial_bal'):
domain += [(date_field, '<', context['date_from'])]
else:
domain += [(date_field, '>=', context['date_from'])]
if context.get('journal_ids'):
domain += [('journal_id', 'in', context['journal_ids'])]
state = context.get('state')
if state and state.lower() != 'all':
domain += [('parent_state', '=', state)]
if context.get('company_id'):
domain += [('company_id', '=', context['company_id'])]
elif context.get('allowed_company_ids'):
domain += [('company_id', 'in', self.env.companies.ids)]
else:
domain += [('company_id', '=', self.env.company.id)]
if context.get('reconcile_date'):
domain += ['|', ('reconciled', '=', False), '|', ('matched_debit_ids.max_date', '>', context['reconcile_date']), ('matched_credit_ids.max_date', '>', context['reconcile_date'])]
if context.get('account_tag_ids'):
domain += [('account_id.tag_ids', 'in', context['account_tag_ids'].ids)]
if context.get('account_ids'):
domain += [('account_id', 'in', context['account_ids'].ids)]
if context.get('analytic_tag_ids'):
domain += [('analytic_tag_ids', 'in', context['analytic_tag_ids'].ids)]
if context.get('analytic_account_ids'):
domain += [('analytic_distribution', 'in', context['analytic_account_ids'].ids)]
if context.get('partner_ids'):
domain += [('partner_id', 'in', context['partner_ids'].ids)]
if context.get('partner_categories'):
domain += [('partner_id.category_id', 'in', context['partner_categories'].ids)]
where_clause = ""
where_clause_params = []
tables = ''
if domain:
domain.append(('display_type', 'not in', ('line_section', 'line_note')))
domain.append(('parent_state', '!=', 'cancel'))
query = self._where_calc(domain)
self._apply_ir_rules(query)
from_string, from_params = query.from_clause
where_string, where_params = query.where_clause
tables, where_clause, where_clause_params = from_string, where_string, from_params + where_params
return tables, where_clause, where_clause_params

View File

@@ -0,0 +1,12 @@
from . import report_partner_ledger
from . import report_general_ledger
from . import report_trial_balance
from . import report_tax
from . import report_aged_partner
from . import report_journal
from . import report_financial

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_general_ledger" model="ir.actions.report">
<field name="name">General Ledger</field>
<field name="model">account.report.general.ledger</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_general_ledger</field>
<field name="report_file">accounting_pdf_reports.report_general_ledger</field>
</record>
<record id="action_report_partnerledger" model="ir.actions.report">
<field name="name">Partner Ledger</field>
<field name="model">account.report.partner.ledger</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_partnerledger</field>
<field name="report_file">accounting_pdf_reports.report_partnerledger</field>
</record>
<record id="action_report_trial_balance" model="ir.actions.report">
<field name="name">Trial Balance</field>
<field name="model">account.balance.report</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_trialbalance</field>
<field name="report_file">accounting_pdf_reports.report_trialbalance</field>
</record>
<record id="action_report_financial" model="ir.actions.report">
<field name="name">Financial Report</field>
<field name="model">account.financial.report</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_financial</field>
<field name="report_file">accounting_pdf_reports.report_financial</field>
</record>
<record id="action_report_account_tax" model="ir.actions.report">
<field name="name">Tax Report</field>
<field name="model">account.tax.report.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_tax</field>
<field name="report_file">accounting_pdf_reports.report_tax</field>
</record>
<record id="action_report_aged_partner_balance" model="ir.actions.report">
<field name="name">Aged Partner Balance</field>
<field name="model">res.partner</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_agedpartnerbalance</field>
<field name="report_file">accounting_pdf_reports.report_agedpartnerbalance</field>
</record>
<record id="action_report_journal" model="ir.actions.report">
<field name="name">Journals Audit</field>
<field name="model">account.common.journal.report</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_journal</field>
<field name="report_file">accounting_pdf_reports.report_journal</field>
</record>
<record id="action_report_journal_entries" model="ir.actions.report">
<field name="name">Journals Entries</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">accounting_pdf_reports.report_journal_entries</field>
<field name="report_file">accounting_pdf_reports.report_journal_entries</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,263 @@
import time
from odoo import api, models, fields, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
from datetime import datetime
from dateutil.relativedelta import relativedelta
class ReportAgedPartnerBalance(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_agedpartnerbalance'
_description = 'Aged Partner Balance Report'
def _get_partner_move_lines(self, account_type, partner_ids,
date_from, target_move, period_length):
# This method can receive the context key 'include_nullified_amount' {Boolean}
# Do an invoice and a payment and unreconcile. The amount will be nullified
# By default, the partner wouldn't appear in this report.
# The context key allow it to appear
# In case of a period_length of 30 days as of 2019-02-08, we want the following periods:
# Name Stop Start
# 1 - 30 : 2019-02-07 - 2019-01-09
# 31 - 60 : 2019-01-08 - 2018-12-10
# 61 - 90 : 2018-12-09 - 2018-11-10
# 91 - 120 : 2018-11-09 - 2018-10-11
# +120 : 2018-10-10
periods = {}
start = datetime.strptime(str(date_from), "%Y-%m-%d")
date_from = datetime.strptime(str(date_from), "%Y-%m-%d").date()
for i in range(5)[::-1]:
stop = start - relativedelta(days=period_length)
period_name = str((5-(i+1)) * period_length + 1) + '-' + str((5-i) * period_length)
period_stop = (start - relativedelta(days=1)).strftime('%Y-%m-%d')
if i == 0:
period_name = '+' + str(4 * period_length)
periods[str(i)] = {
'name': period_name,
'stop': period_stop,
'start': (i!=0 and stop.strftime('%Y-%m-%d') or False),
}
start = stop
res = []
total = []
cr = self.env.cr
user_company = self.env.user.company_id
user_currency = user_company.currency_id
company_ids = self.env.context.get('company_ids') or [user_company.id]
move_state = ['draft', 'posted']
date = self.env.context.get('date') or fields.Date.today()
company = self.env['res.company'].browse(self.env.context.get('company_id')) or self.env.company
if target_move == 'posted':
move_state = ['posted']
arg_list = (tuple(move_state), tuple(account_type))
reconciliation_clause = '(l.reconciled IS FALSE)'
cr.execute('SELECT debit_move_id, credit_move_id FROM account_partial_reconcile where max_date > %s', (date_from,))
reconciled_after_date = []
for row in cr.fetchall():
reconciled_after_date += [row[0], row[1]]
if reconciled_after_date:
reconciliation_clause = '(l.reconciled IS FALSE OR l.id IN %s)'
arg_list += (tuple(reconciled_after_date),)
arg_list += (date_from, tuple(company_ids))
query = '''
SELECT DISTINCT l.partner_id, UPPER(res_partner.name)
FROM account_move_line AS l left join res_partner on l.partner_id = res_partner.id, account_account, account_move am
WHERE (l.account_id = account_account.id)
AND (l.move_id = am.id)
AND (am.state IN %s)
AND (account_account.account_type IN %s)
AND ''' + reconciliation_clause + '''
AND (l.date <= %s)
AND l.company_id IN %s
ORDER BY UPPER(res_partner.name)'''
cr.execute(query, arg_list)
partners = cr.dictfetchall()
# put a total of 0
for i in range(7):
total.append(0)
# Build a string like (1,2,3) for easy use in SQL query
if not partner_ids:
partner_ids = [partner['partner_id'] for partner in partners if partner['partner_id']]
lines = dict((partner['partner_id'] or False, []) for partner in partners)
if not partner_ids:
return [], [], {}
# This dictionary will store the not due amount of all partners
undue_amounts = {}
query = '''SELECT l.id
FROM account_move_line AS l, account_account, account_move am
WHERE (l.account_id = account_account.id) AND (l.move_id = am.id)
AND (am.state IN %s)
AND (account_account.account_type IN %s)
AND (COALESCE(l.date_maturity,l.date) >= %s)\
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND (l.date <= %s)
AND l.company_id IN %s'''
cr.execute(query, (tuple(move_state), tuple(account_type), date_from,
tuple(partner_ids), date_from, tuple(company_ids)))
aml_ids = cr.fetchall()
aml_ids = aml_ids and [x[0] for x in aml_ids] or []
for line in self.env['account.move.line'].browse(aml_ids):
partner_id = line.partner_id.id or False
if partner_id not in undue_amounts:
undue_amounts[partner_id] = 0.0
line_amount = line.company_id.currency_id._convert(line.balance,
user_currency,
company, date)
if user_currency.is_zero(line_amount):
continue
for partial_line in line.matched_debit_ids:
if partial_line.max_date <= date_from:
line_currency = partial_line.company_id.currency_id
line_amount += line_currency._convert(partial_line.amount,
user_currency,
company, date)
for partial_line in line.matched_credit_ids:
if partial_line.max_date <= date_from:
line_currency = partial_line.company_id.currency_id
line_amount -= line_currency._convert(partial_line.amount,
user_currency,
company, date)
if not self.env.user.company_id.currency_id.is_zero(line_amount):
undue_amounts[partner_id] += line_amount
lines[partner_id].append({
'line': line,
'amount': line_amount,
'period': 6,
})
# Use one query per period and store results in history (a list variable)
# Each history will contain: history[1] = {'<partner_id>': <partner_debit-credit>}
history = []
for i in range(5):
args_list = (tuple(move_state), tuple(account_type), tuple(partner_ids),)
dates_query = '(COALESCE(l.date_maturity,l.date)'
if periods[str(i)]['start'] and periods[str(i)]['stop']:
dates_query += ' BETWEEN %s AND %s)'
args_list += (periods[str(i)]['start'], periods[str(i)]['stop'])
elif periods[str(i)]['start']:
dates_query += ' >= %s)'
args_list += (periods[str(i)]['start'],)
else:
dates_query += ' <= %s)'
args_list += (periods[str(i)]['stop'],)
args_list += (date_from, tuple(company_ids))
query = '''SELECT l.id
FROM account_move_line AS l, account_account, account_move am
WHERE (l.account_id = account_account.id) AND (l.move_id = am.id)
AND (am.state IN %s)
AND (account_account.account_type IN %s)
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND ''' + dates_query + '''
AND (l.date <= %s)
AND l.company_id IN %s'''
cr.execute(query, args_list)
partners_amount = {}
aml_ids = cr.fetchall()
aml_ids = aml_ids and [x[0] for x in aml_ids] or []
for line in self.env['account.move.line'].browse(aml_ids):
partner_id = line.partner_id.id or False
if partner_id not in partners_amount:
partners_amount[partner_id] = 0.0
line_currency_id = line.company_id.currency_id
line_amount = line_currency_id._convert(line.balance, user_currency, company, date)
if user_currency.is_zero(line_amount):
continue
for partial_line in line.matched_debit_ids:
if partial_line.max_date <= date_from:
line_currency_id = partial_line.company_id.currency_id
line_amount += line_currency_id._convert(
partial_line.amount, user_currency, company, date)
for partial_line in line.matched_credit_ids:
if partial_line.max_date <= date_from:
line_currency_id = partial_line.company_id.currency_id
line_amount -= line_currency_id._convert(
partial_line.amount, user_currency, company, date)
if not self.env.user.company_id.currency_id.is_zero(line_amount):
partners_amount[partner_id] += line_amount
lines[partner_id].append({
'line': line,
'amount': line_amount,
'period': i + 1,
})
history.append(partners_amount)
for partner in partners:
if partner['partner_id'] is None:
partner['partner_id'] = False
at_least_one_amount = False
values = {}
undue_amt = 0.0
if partner['partner_id'] in undue_amounts: # Making sure this partner actually was found by the query
undue_amt = undue_amounts[partner['partner_id']]
total[6] = total[6] + undue_amt
values['direction'] = undue_amt
if not float_is_zero(values['direction'], precision_rounding=self.env.user.company_id.currency_id.rounding):
at_least_one_amount = True
for i in range(5):
during = False
if partner['partner_id'] in history[i]:
during = [history[i][partner['partner_id']]]
# Adding counter
total[(i)] = total[(i)] + (during and during[0] or 0)
values[str(i)] = during and during[0] or 0.0
if not float_is_zero(values[str(i)],
precision_rounding=self.env.user.company_id.currency_id.rounding):
at_least_one_amount = True
values['total'] = sum([values['direction']] + [values[str(i)] for i in range(5)])
## Add for total
total[(i + 1)] += values['total']
values['partner_id'] = partner['partner_id']
if partner['partner_id']:
browsed_partner = self.env['res.partner'].browse(partner['partner_id'])
values['name'] = browsed_partner.name and len(
browsed_partner.name) >= 45 and browsed_partner.name[
0:40] + '...' or browsed_partner.name
values['trust'] = browsed_partner.trust
else:
values['name'] = _('Unknown Partner')
values['trust'] = False
if at_least_one_amount or (self.env.context.get('include_nullified_amount') and lines[partner['partner_id']]):
res.append(values)
return res, total, lines
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Form content is missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_id'))
target_move = data['form'].get('target_move', 'all')
date_from = data['form'].get('date_from', time.strftime('%Y-%m-%d'))
if data['form']['result_selection'] == 'customer':
account_type = ['asset_receivable']
elif data['form']['result_selection'] == 'supplier':
account_type = ['liability_payable']
else:
account_type = ['asset_receivable', 'liability_payable']
partner_ids = data['form']['partner_ids']
movelines, total, dummy = self._get_partner_move_lines(
account_type, partner_ids, date_from, target_move, data['form']['period_length']
)
return {
'doc_ids': self.ids,
'doc_model': model,
'data': data['form'],
'docs': docs,
'time': time,
'get_partner_lines': movelines,
'get_direction': total,
}

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_agedpartnerbalance">
<t t-call="web.html_container">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<t t-call="web.internal_layout">
<div class="page">
<h2>Aged Partner Balance</h2>
<div class="row mt32">
<div class="col-3">
<strong>Start Date:</strong>
<p t-esc="data['date_from']"/>
</div>
<div class="col-3">
<strong>Period Length (days)</strong>
<p t-esc="data['period_length']"/>
</div>
</div>
<div class="row mb32">
<div class="col-3">
<strong>Partner's:</strong>
<p>
<span t-if="data['result_selection'] == 'customer'">Receivable Accounts</span>
<span t-if="data['result_selection'] == 'supplier'">Payable Accounts</span>
<span t-if="data['result_selection'] == 'customer_supplier'">Receivable and Payable Accounts</span>
</p>
</div>
<div class="col-3">
<strong>Target Moves:</strong>
<p>
<span t-if="data['target_move'] == 'all'">All Entries</span>
<span t-if="data['target_move'] == 'posted'">All Posted Entries</span>
</p>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr>
<th>Partners</th>
<th class="text-end">
<span>Not due</span>
</th>
<th class="text-end"><span t-esc="data['4']['name']"/></th>
<th class="text-end"><span t-esc="data['3']['name']"/></th>
<th class="text-end"><span t-esc="data['2']['name']"/></th>
<th class="text-end"><span t-esc="data['1']['name']"/></th>
<th class="text-end"><span t-esc="data['0']['name']"/></th>
<th class="text-end">Total</th>
</tr>
<tr t-if="get_partner_lines">
<th>Account Total</th>
<th class="text-end"><span t-esc="get_direction[6]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[4]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[3]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[2]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[1]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[0]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
<th class="text-end"><span t-esc="get_direction[5]" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></th>
</tr>
</thead>
<tbody>
<tr t-foreach="get_partner_lines" t-as="partner">
<td>
<span t-esc="partner['name']"/>
</td>
<td class="text-end">
<span t-esc="partner['direction']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['4']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['3']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['2']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['1']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['0']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="partner['total']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,163 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportFinancial(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_financial'
_description = 'Financial Reports'
def _compute_account_balance(self, accounts):
""" compute the balance, debit and credit for the provided accounts
"""
mapping = {
'balance': "COALESCE(SUM(debit),0) - COALESCE(SUM(credit), 0) as balance",
'debit': "COALESCE(SUM(debit), 0) as debit",
'credit': "COALESCE(SUM(credit), 0) as credit",
}
res = {}
for account in accounts:
res[account.id] = dict.fromkeys(mapping, 0.0)
if accounts:
tables, where_clause, where_params = self.env['account.move.line']._query_get()
tables = tables.replace('"', '') if tables else "account_move_line"
wheres = [""]
if where_clause.strip():
wheres.append(where_clause.strip())
filters = " AND ".join(wheres)
request = "SELECT account_id as id, " + ', '.join(mapping.values()) + \
" FROM " + tables + \
" WHERE account_id IN %s " \
+ filters + \
" GROUP BY account_id"
params = (tuple(accounts._ids),) + tuple(where_params)
self.env.cr.execute(request, params)
for row in self.env.cr.dictfetchall():
res[row['id']] = row
return res
def _compute_report_balance(self, reports):
'''returns a dictionary with key=the ID of a record and value=the credit, debit and balance amount
computed for this record. If the record is of type :
'accounts' : it's the sum of the linked accounts
'account_type' : it's the sum of leaf accoutns with such an account_type
'account_report' : it's the amount of the related report
'sum' : it's the sum of the children of this record (aka a 'view' record)'''
res = {}
fields = ['credit', 'debit', 'balance']
for report in reports:
if report.id in res:
continue
res[report.id] = dict((fn, 0.0) for fn in fields)
if report.type == 'accounts':
# it's the sum of the linked accounts
res[report.id]['account'] = self._compute_account_balance(report.account_ids)
for value in res[report.id]['account'].values():
for field in fields:
res[report.id][field] += value.get(field)
elif report.type == 'account_type':
# it's the sum the leaf accounts with such an account type
accounts = self.env['account.account'].search(
[('account_type', 'in', report.account_type_ids.mapped('type'))])
res[report.id]['account'] = self._compute_account_balance(accounts)
for value in res[report.id]['account'].values():
for field in fields:
res[report.id][field] += value.get(field)
elif report.type == 'account_report' and report.account_report_id:
# it's the amount of the linked report
res2 = self._compute_report_balance(report.account_report_id)
for key, value in res2.items():
for field in fields:
res[report.id][field] += value[field]
elif report.type == 'sum':
# it's the sum of the children of this account.report
res2 = self._compute_report_balance(report.children_ids)
for key, value in res2.items():
for field in fields:
res[report.id][field] += value[field]
return res
def get_account_lines(self, data):
lines = []
account_report = self.env['account.financial.report'].search(
[('id', '=', data['account_report_id'][0])])
child_reports = account_report._get_children_by_order()
res = self.with_context(data.get('used_context'))._compute_report_balance(child_reports)
if data['enable_filter']:
comparison_res = self.with_context(
data.get('comparison_context'))._compute_report_balance(
child_reports)
for report_id, value in comparison_res.items():
res[report_id]['comp_bal'] = value['balance']
report_acc = res[report_id].get('account')
if report_acc:
for account_id, val in comparison_res[report_id].get('account').items():
report_acc[account_id]['comp_bal'] = val['balance']
for report in child_reports:
vals = {
'name': report.name,
'balance': res[report.id]['balance'] * float(report.sign),
'type': 'report',
'level': bool(report.style_overwrite) and report.style_overwrite or report.level,
'account_type': report.type or False, #used to underline the financial report balances
}
if data['debit_credit']:
vals['debit'] = res[report.id]['debit']
vals['credit'] = res[report.id]['credit']
if data['enable_filter']:
vals['balance_cmp'] = res[report.id]['comp_bal'] * float(report.sign)
lines.append(vals)
if report.display_detail == 'no_detail':
#the rest of the loop is used to display the details of the financial report, so it's not needed here.
continue
if res[report.id].get('account'):
sub_lines = []
for account_id, value in res[report.id]['account'].items():
#if there are accounts to display, we add them to the lines with a level equals to their level in
#the COA + 1 (to avoid having them with a too low level that would conflicts with the level of data
#financial reports for Assets, liabilities...)
flag = False
account = self.env['account.account'].browse(account_id)
vals = {
'name': account.code + ' ' + account.name,
'balance': value['balance'] * float(report.sign) or 0.0,
'type': 'account',
'level': report.display_detail == 'detail_with_hierarchy' and 4,
'account_type': account.account_type,
}
if data['debit_credit']:
vals['debit'] = value['debit']
vals['credit'] = value['credit']
if not self.env.company.currency_id.is_zero(vals['debit']) or not self.env.company.currency_id.is_zero(vals['credit']):
flag = True
if not self.env.company.currency_id.is_zero(vals['balance']):
flag = True
if data['enable_filter']:
vals['balance_cmp'] = value['comp_bal'] * float(report.sign)
if not self.env.company.currency_id.is_zero(vals['balance_cmp']):
flag = True
if flag:
sub_lines.append(vals)
lines += sorted(sub_lines, key=lambda sub_line: sub_line['name'])
return lines
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model') or not self.env.context.get('active_id'):
raise UserError(_("Form content is missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_id'))
report_lines = self.get_account_lines(data.get('form'))
return {
'doc_ids': self.ids,
'doc_model': model,
'data': data['form'],
'docs': docs,
'time': time,
'get_account_lines': report_lines,
}

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_financial">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.internal_layout">
<div class="page">
<h2 t-esc="data['account_report_id'][1]"/>
<div class="row mt32 mb32">
<div class="col-4">
<strong>Target Moves:</strong>
<p>
<span t-if="data['target_move'] == 'all'">All Entries</span>
<span t-if="data['target_move'] == 'posted'">All Posted Entries</span>
</p>
</div>
<div class="col-4">
<p>
<t t-if="data['date_from']"><strong>Date from :</strong> <span t-esc="data['date_from']"/><br/></t>
<t t-if="data['date_to']"><strong>Date to :</strong> <span t-esc="data['date_to']"/></t>
</p>
</div>
</div>
<table class="table table-sm table-reports" t-if="data['debit_credit'] == 1">
<thead>
<tr>
<th>Name</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
<tr t-foreach="get_account_lines" t-as="a">
<t t-if="a['level'] != 0">
<t t-if="int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
<t t-if="not int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: bold;'"/></t>
<td>
<span style="color: white;" t-esc="'..' * int(a.get('level', 0))"/>
<span t-att-style="style" t-esc="a.get('name')"/>
</td>
<td class="text-end" style="white-space: text-nowrap;">
<span t-att-style="style" t-esc="a.get('debit')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end" style="white-space: text-nowrap;">
<span t-att-style="style" t-esc="a.get('credit')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end" style="white-space: text-nowrap;">
<span t-att-style="style" t-esc="a.get('balance')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</t>
</tr>
</tbody>
</table>
<table class="table table-sm table-reports" t-if="not data['enable_filter'] and not data['debit_credit']">
<thead>
<tr>
<th>Name</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
<tr t-foreach="get_account_lines" t-as="a">
<t t-if="a['level'] != 0">
<t t-if="int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
<t t-if="not int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: bold;'"/></t>
<td>
<span style="color: white;" t-esc="'..' * int(a.get('level', 0))"/>
<span t-att-style="style" t-esc="a.get('name')"/>
</td>
<td class="text-end"><span t-att-style="style" t-esc="a.get('balance')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
</t>
</tr>
</tbody>
</table>
<table class="table table-sm table-reports" t-if="data['enable_filter'] == 1 and not data['debit_credit']">
<thead>
<tr>
<th>Name</th>
<th class="text-end">Balance</th>
<th class="text-end"><span t-esc="data['label_filter']"/></th>
</tr>
</thead>
<tbody>
<tr t-foreach="get_account_lines" t-as="a">
<t t-if="a['level'] != 0">
<t t-if="int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
<t t-if="not int(a.get('level')) &gt; 3"><t t-set="style" t-value="'font-weight: bold;'"/></t>
<td>
<span style="color: white;" t-esc="'..'"/>
<span t-att-style="style" t-esc="a.get('name')"/>
</td>
<td class="text-end">
<span t-att-style="style" t-esc="a.get('balance')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-att-style="style" t-esc="a.get('balance_cmp')" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</t>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,184 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportGeneralLedger(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_general_ledger'
_description = 'General Ledger Report'
def _get_account_move_entry(self, accounts, analytic_account_ids,
partner_ids, init_balance,
sortby, display_account):
"""
:param:
accounts: the recordset of accounts
analytic_account_ids: the recordset of analytic accounts
init_balance: boolean value of initial_balance
sortby: sorting by date or partner and journal
display_account: type of account(receivable, payable and both)
Returns a dictionary of accounts with following key and value {
'code': account code,
'name': account name,
'debit': sum of total debit amount,
'credit': sum of total credit amount,
'balance': total balance,
'amount_currency': sum of amount_currency,
'move_lines': list of move line
}
"""
cr = self.env.cr
MoveLine = self.env['account.move.line']
move_lines = {x: [] for x in accounts.ids}
# Prepare initial sql query and Get the initial move lines
if init_balance:
context = dict(self.env.context)
context['date_from'] = self.env.context.get('date_from')
context['date_to'] = False
context['initial_bal'] = True
if analytic_account_ids:
context['analytic_account_ids'] = analytic_account_ids
if partner_ids:
context['partner_ids'] = partner_ids
init_tables, init_where_clause, init_where_params = MoveLine.with_context(context)._query_get()
init_wheres = [""]
if init_where_clause.strip():
init_wheres.append(init_where_clause.strip())
init_filters = " AND ".join(init_wheres)
filters = init_filters.replace('account_move_line__move_id', 'm').replace('account_move_line', 'l')
sql = ("""SELECT 0 AS lid, l.account_id AS account_id, '' AS ldate,
'' AS lcode, 0.0 AS amount_currency,
'' AS analytic_account_id, '' AS lref,
'Initial Balance' AS lname, COALESCE(SUM(l.debit),0.0) AS debit,
COALESCE(SUM(l.credit),0.0) AS credit,
COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) as balance,
'' AS lpartner_id,\
'' AS move_name, '' AS move_id, '' AS currency_code,\
NULL AS currency_id,\
'' AS invoice_id, '' AS invoice_type, '' AS invoice_number,\
'' AS partner_name\
FROM account_move_line l\
LEFT JOIN account_move m ON (l.move_id=m.id)\
LEFT JOIN res_currency c ON (l.currency_id=c.id)\
LEFT JOIN res_partner p ON (l.partner_id=p.id)\
JOIN account_journal j ON (l.journal_id=j.id)\
WHERE l.account_id IN %s""" + filters + ' GROUP BY l.account_id')
params = (tuple(accounts.ids),) + tuple(init_where_params)
cr.execute(sql, params)
for row in cr.dictfetchall():
move_lines[row.pop('account_id')].append(row)
sql_sort = 'l.date, l.move_id'
if sortby == 'sort_journal_partner':
sql_sort = 'j.code, p.name, l.move_id'
# Prepare sql query base on selected parameters from wizard
context = dict(self.env.context)
if analytic_account_ids:
context['analytic_account_ids'] = analytic_account_ids
if partner_ids:
context['partner_ids'] = partner_ids
tables, where_clause, where_params = MoveLine.with_context(context)._query_get()
wheres = [""]
if where_clause.strip():
wheres.append(where_clause.strip())
filters = " AND ".join(wheres)
filters = filters.replace('account_move_line__move_id', 'm').replace('account_move_line', 'l')
# Get move lines base on sql query and Calculate the total balance of move lines
sql = ('''SELECT l.id AS lid, l.account_id AS account_id,
l.date AS ldate, j.code AS lcode, l.currency_id,
l.amount_currency, '' AS analytic_account_id,
l.ref AS lref, l.name AS lname, COALESCE(l.debit,0) AS debit,
COALESCE(l.credit,0) AS credit,
COALESCE(SUM(l.debit),0) - COALESCE(SUM(l.credit), 0) AS balance,\
m.name AS move_name, c.symbol AS currency_code,
p.name AS partner_name\
FROM account_move_line l\
JOIN account_move m ON (l.move_id=m.id)\
LEFT JOIN res_currency c ON (l.currency_id=c.id)\
LEFT JOIN res_partner p ON (l.partner_id=p.id)\
JOIN account_journal j ON (l.journal_id=j.id)\
JOIN account_account acc ON (l.account_id = acc.id) \
WHERE l.account_id IN %s ''' + filters + ''' GROUP BY l.id,
l.account_id, l.date, j.code, l.currency_id, l.amount_currency,
l.ref, l.name, m.name, c.symbol, p.name ORDER BY ''' + sql_sort)
params = (tuple(accounts.ids),) + tuple(where_params)
cr.execute(sql, params)
for row in cr.dictfetchall():
balance = 0
for line in move_lines.get(row['account_id']):
balance += line['debit'] - line['credit']
row['balance'] += balance
move_lines[row.pop('account_id')].append(row)
# Calculate the debit, credit and balance for Accounts
account_res = []
for account in accounts:
currency = account.currency_id and account.currency_id or self.env.company.currency_id
res = dict((fn, 0.0) for fn in ['credit', 'debit', 'balance'])
res['code'] = account.code
res['name'] = account.name
res['move_lines'] = move_lines[account.id]
for line in res.get('move_lines'):
res['debit'] += line['debit']
res['credit'] += line['credit']
res['balance'] = line['balance']
if display_account == 'all':
account_res.append(res)
if display_account == 'movement' and res.get('move_lines'):
account_res.append(res)
if display_account == 'not_zero' and not currency.is_zero(res['balance']):
account_res.append(res)
return account_res
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model'):
raise UserError(_("Form content is missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_ids', []))
init_balance = data['form'].get('initial_balance', True)
sortby = data['form'].get('sortby', 'sort_date')
display_account = data['form']['display_account']
codes = []
if data['form'].get('journal_ids', False):
codes = [journal.code for journal in
self.env['account.journal'].search(
[('id', 'in', data['form']['journal_ids'])])]
analytic_account_ids = False
if data['form'].get('analytic_account_ids', False):
analytic_account_ids = self.env['account.analytic.account'].search(
[('id', 'in', data['form']['analytic_account_ids'])])
partner_ids = False
if data['form'].get('partner_ids', False):
partner_ids = self.env['res.partner'].search(
[('id', 'in', data['form']['partner_ids'])])
if model == 'account.account':
accounts = docs
else:
domain = []
if data['form'].get('account_ids', False):
domain.append(('id', 'in', data['form']['account_ids']))
accounts = self.env['account.account'].search(domain)
accounts_res = self.with_context(
data['form'].get('used_context', {}))._get_account_move_entry(
accounts,
analytic_account_ids,
partner_ids,
init_balance, sortby, display_account)
return {
'doc_ids': docids,
'doc_model': model,
'data': data['form'],
'docs': docs,
'time': time,
'Accounts': accounts_res,
'print_journal': codes,
'accounts': accounts,
'partner_ids': partner_ids,
'analytic_account_ids': analytic_account_ids,
}

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_general_ledger">
<t t-call="web.html_container">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<t t-call="web.internal_layout">
<div class="page">
<h2><span t-esc="res_company.name"/>: General ledger</h2>
<div class="row mt32">
<div class="col-4">
<strong>Journals:</strong>
<p t-esc="', '.join([ lt or '' for lt in print_journal ])"/>
</div>
<t groups="analytic.group_analytic_accounting">
<t t-if="analytic_account_ids">
<div class="col-4">
<strong>Analytic Accounts:</strong>
<p t-esc="', '.join([aa.name or '' for aa in analytic_account_ids ])"/>
</div>
</t>
</t>
<div class="col-4">
<strong>Display Account</strong>
<p>
<span t-if="data['display_account'] == 'all'">All accounts'</span>
<span t-if="data['display_account'] == 'movement'">With movements</span>
<span t-if="data['display_account'] == 'not_zero'">With balance not equal to zero</span>
</p>
</div>
<div class="col-4">
<strong>Target Moves:</strong>
<p t-if="data['target_move'] == 'all'">All Entries</p>
<p t-if="data['target_move'] == 'posted'">All Posted Entries</p>
</div>
</div>
<div class="row mb32">
<div class="col-4">
<strong>Sorted By:</strong>
<p t-if="data['sortby'] == 'sort_date'">Date</p>
<p t-if="data['sortby'] == 'sort_journal_partner'">Journal and Partner</p>
</div>
<div class="col-4">
<t t-if="data['date_from']"><strong>Date from :</strong> <span t-esc="data['date_from']"/><br/></t>
<t t-if="data['date_to']"><strong>Date to :</strong> <span t-esc="data['date_to']"/></t>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr class="text-center">
<th>Date</th>
<th>JRNL</th>
<th>Partner</th>
<th>Ref</th>
<th>Move</th>
<t groups="analytic.group_analytic_accounting">
<th>Analytic Account</th>
</t>
<th>Entry Label</th>
<th>Debit</th>
<th>Credit</th>
<th>Balance</th>
<th groups="base.group_multi_currency">Currency</th>
</tr>
</thead>
<tbody>
<t t-foreach="Accounts" t-as="account">
<tr style="font-weight: bold;">
<td colspan="6">
<span style="color: white;" t-esc="'..'"/>
<span t-esc="account['code']"/>
<span t-esc="account['name']"/>
</td>
<t groups="analytic.group_analytic_accounting">
<td></td>
</t>
<td class="text-end">
<span t-esc="account['debit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="account['credit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="account['balance']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td groups="base.group_multi_currency"/>
</tr>
<tr t-foreach="account['move_lines']" t-as="line">
<td><span t-esc="line['ldate']"/></td>
<td><span t-esc="line['lcode']"/></td>
<td><span t-esc="line['partner_name']"/></td>
<td><span t-if="line['lref']" t-esc="line['lref']"/></td>
<td><span t-esc="line['move_name']"/></td>
<t groups="analytic.group_analytic_accounting">
<td><span t-esc="line['analytic_account_id']"/></td>
</t>
<td><span t-esc="line['lname']"/></td>
<td class="text-end">
<span t-esc="line['debit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="line['credit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="line['balance']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end" groups="base.group_multi_currency">
<span t-esc="line['amount_currency'] if line['amount_currency'] and line['amount_currency'] > 0.00 else ''"/>
<span t-esc="line['currency_code'] if line['amount_currency'] and line['amount_currency'] > 0.00 else ''"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,117 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportJournal(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_journal'
_description = 'Journal Audit Report'
def lines(self, target_move, journal_ids, sort_selection, data):
if isinstance(journal_ids, int):
journal_ids = [journal_ids]
move_state = ['draft', 'posted']
if target_move == 'posted':
move_state = ['posted']
query_get_clause = self._get_query_get_clause(data)
params = [tuple(move_state), tuple(journal_ids)] + query_get_clause[2]
query = 'SELECT "account_move_line".id FROM ' + query_get_clause[0] + ', account_move am, account_account acc WHERE "account_move_line".account_id = acc.id AND "account_move_line".move_id=am.id AND am.state IN %s AND "account_move_line".journal_id IN %s AND ' + query_get_clause[1] + ' ORDER BY '
if sort_selection == 'date':
query += '"account_move_line".date'
else:
query += 'am.name'
query += ', "account_move_line".move_id'
self.env.cr.execute(query, tuple(params))
ids = (x[0] for x in self.env.cr.fetchall())
return self.env['account.move.line'].browse(ids)
def _sum_debit(self, data, journal_id):
move_state = ['draft', 'posted']
if data['form'].get('target_move', 'all') == 'posted':
move_state = ['posted']
query_get_clause = self._get_query_get_clause(data)
params = [tuple(move_state), tuple(journal_id.ids)] + query_get_clause[2]
self.env.cr.execute('SELECT SUM(debit) FROM ' + query_get_clause[0] + ', account_move am '
'WHERE "account_move_line".move_id=am.id AND am.state IN %s AND "account_move_line".journal_id IN %s AND ' + query_get_clause[1] + ' ',
tuple(params))
return self.env.cr.fetchone()[0] or 0.0
def _sum_credit(self, data, journal_id):
move_state = ['draft', 'posted']
if data['form'].get('target_move', 'all') == 'posted':
move_state = ['posted']
query_get_clause = self._get_query_get_clause(data)
params = [tuple(move_state), tuple(journal_id.ids)] + query_get_clause[2]
self.env.cr.execute('SELECT SUM(credit) FROM ' + query_get_clause[0] + ', account_move am '
'WHERE "account_move_line".move_id=am.id AND am.state IN %s AND "account_move_line".journal_id IN %s AND ' + query_get_clause[1] + ' ',
tuple(params))
return self.env.cr.fetchone()[0] or 0.0
def _get_taxes(self, data, journal_id):
move_state = ['draft', 'posted']
if data['form'].get('target_move', 'all') == 'posted':
move_state = ['posted']
query_get_clause = self._get_query_get_clause(data)
params = [tuple(move_state), tuple(journal_id.ids)] + query_get_clause[2]
query = """
SELECT rel.account_tax_id, SUM("account_move_line".balance) AS base_amount
FROM account_move_line_account_tax_rel rel, """ + query_get_clause[0] + """
LEFT JOIN account_move am ON "account_move_line".move_id = am.id
WHERE "account_move_line".id = rel.account_move_line_id
AND am.state IN %s
AND "account_move_line".journal_id IN %s
AND """ + query_get_clause[1] + """
GROUP BY rel.account_tax_id"""
self.env.cr.execute(query, tuple(params))
ids = []
base_amounts = {}
for row in self.env.cr.fetchall():
ids.append(row[0])
base_amounts[row[0]] = row[1]
res = {}
for tax in self.env['account.tax'].browse(ids):
self.env.cr.execute('SELECT sum(debit - credit) FROM ' + query_get_clause[0] + ', account_move am '
'WHERE "account_move_line".move_id=am.id AND am.state IN %s AND "account_move_line".journal_id IN %s AND ' + query_get_clause[1] + ' AND tax_line_id = %s',
tuple(params + [tax.id]))
res[tax] = {
'base_amount': base_amounts[tax.id],
'tax_amount': self.env.cr.fetchone()[0] or 0.0,
}
if journal_id.type == 'sale':
#sales operation are credits
res[tax]['base_amount'] = res[tax]['base_amount'] * -1
res[tax]['tax_amount'] = res[tax]['tax_amount'] * -1
return res
def _get_query_get_clause(self, data):
return self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Form content is missing, this report cannot be printed."))
target_move = data['form'].get('target_move', 'all')
sort_selection = data['form'].get('sort_selection', 'date')
res = {}
for journal in data['form']['journal_ids']:
res[journal] = self.with_context(data['form'].get('used_context', {})).lines(target_move, journal, sort_selection, data)
return {
'doc_ids': data['form']['journal_ids'],
'doc_model': self.env['account.journal'],
'data': data,
'docs': self.env['account.journal'].browse(data['form']['journal_ids']),
'time': time,
'lines': res,
'sum_credit': self._sum_credit,
'sum_debit': self._sum_debit,
'get_taxes': self._get_taxes,
}

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_journal">
<t t-call="web.html_container">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<t t-foreach="docs" t-as="o">
<t t-call="web.internal_layout">
<div class="page">
<h2><t t-esc="o.name"/> Journal</h2>
<div class="row mt32">
<div class="col-3">
<strong>Company:</strong>
<p t-esc="res_company.name"/>
</div>
<div class="col-3">
<strong>Journal:</strong>
<p t-esc="o.name"/>
</div>
<div class="col-3">
<strong>Entries Sorted By:</strong>
<p t-if="data['form'].get('sort_selection') != 'l.date'">Journal Entry Number</p>
<p t-if="data['form'].get('sort_selection') == 'l.date'">Date</p>
</div>
<div class="col-3">
<strong>Target Moves:</strong>
<p t-if="data['form']['target_move'] == 'all'">All Entries</p>
<p t-if="data['form']['target_move'] == 'posted'">All Posted Entries</p>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr>
<th>Move</th>
<th>Date</th>
<th>Account</th>
<th>Partner</th>
<th>Label</th>
<th>Debit</th>
<th>Credit</th>
<th t-if="data['form']['amount_currency']">Currency</th>
</tr>
</thead>
<tbody>
<tr t-foreach="lines[o.id]" t-as="aml">
<td><span t-esc="aml.move_id.name != '/' and aml.move_id.name or ('*'+str(aml.move_id.id))"/></td>
<td><span t-field="aml.date"/></td>
<td><span t-field="aml.account_id.code"/></td>
<td><span t-esc="aml.sudo().partner_id and aml.sudo().partner_id.name and aml.sudo().partner_id.name[:23] or ''"/></td>
<td><span t-esc="aml.name and aml.name[:35]"/></td>
<td><span t-esc="aml.debit" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
<td><span t-esc="aml.credit" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
<td t-if="data['form']['amount_currency'] and aml.amount_currency">
<span t-esc="aml.amount_currency" t-options="{'widget': 'monetary', 'display_currency': aml.currency_id}"/>
</td>
</tr>
</tbody>
</table>
<div class="row">
<div class="col-4 pull-right">
<table>
<tr>
<td><strong>Total</strong></td>
<td><span t-esc="sum_debit(data, o)" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
<td><span t-esc="sum_credit(data, o)" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
</tr>
</table>
</div>
</div>
<div class="row">
<div class="col-4">
<table class="table table-sm table-reports">
<thead>
<tr><th colspan="3">Tax Declaration</th></tr>
<tr>
<th>Name</th>
<th>Base Amount</th>
<th>Tax Amount</th>
</tr>
</thead>
<tbody>
<t t-set="taxes" t-value="get_taxes(data, o)"/>
<tr t-foreach="taxes" t-as="tax">
<td><span t-esc="tax.name"/></td>
<td><span t-esc="taxes[tax]['base_amount']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
<td><span t-esc="taxes[tax]['tax_amount']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_journal_entries">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<t t-foreach="docs" t-as="o">
<div class="page" style="font-size:15px;">
<div>
<h3>
<span t-field="o.name"/>
</h3>
</div>
<br></br>
<div class="row">
<table width="100%" class="table-bordered">
<tr>
<td>Journal:
<span t-field="o.journal_id.name"/>
</td>
<td>
Date:
<span t-field="o.date" t-options="{'widget': 'date'}"/>
</td>
</tr>
<tr>
<td>
Partner:
<span t-field="o.partner_id.display_name"/>
</td>
<td>
Reference:
<span t-field="o.ref"/>
</td>
</tr>
</table>
</div>
<div class="row">
<br></br>
<table width="100%" class="table-bordered">
<thead>
<tr>
<th>Account</th>
<th>Date</th>
<th>Partner</th>
<th>Label</th>
<th>Analytic Account</th>
<th>Debit</th>
<th>Credit</th>
</tr>
</thead>
<tbody>
<t t-set="total_credit" t-value="0"/>
<t t-set="total_debit" t-value="0"/>
<t t-foreach="o.line_ids" t-as="line">
<tr>
<td>
<span t-field="line.account_id.name"/>
</td>
<td>
<span t-field="line.date" t-options="{'widget': 'date'}"/>
</td>
<td>
<span t-field="line.partner_id.display_name"/>
</td>
<td>
<span t-field="line.name"/>
</td>
<td>
<span t-field="line.analytic_account_id.display_name"/>
</td>
<td class="text-end">
<span t-field="line.debit"
t-options="{'widget': 'monetary', 'display_currency': line.currency_id}"/>
</td>
<td class="text-end">
<span t-field="line.credit"
t-options="{'widget': 'monetary', 'display_currency': line.currency_id}"/>
</td>
<t t-set="total_credit" t-value="total_credit + line.credit"/>
<t t-set="total_debit" t-value="total_debit + line.debit"/>
</tr>
</t>
</tbody>
<tfooter>
<tr>
<td colspan="5"></td>
<td class="text-end">
<span t-esc="total_debit"
t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="total_credit"
t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</td>
</tr>
</tfooter>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,122 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportPartnerLedger(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_partnerledger'
_description = 'Partner Ledger Report'
def _lines(self, data, partner):
full_account = []
currency = self.env['res.currency']
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
params = [partner.id, tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
query = """
SELECT "account_move_line".id, "account_move_line".date, j.code, acc.name->>'en_US' as a_name, "account_move_line".ref, m.name as move_name, "account_move_line".name, "account_move_line".debit, "account_move_line".credit, "account_move_line".amount_currency,"account_move_line".currency_id, c.symbol AS currency_code
FROM """ + query_get_data[0] + """
LEFT JOIN account_journal j ON ("account_move_line".journal_id = j.id)
LEFT JOIN account_account acc ON ("account_move_line".account_id = acc.id)
LEFT JOIN res_currency c ON ("account_move_line".currency_id=c.id)
LEFT JOIN account_move m ON (m.id="account_move_line".move_id)
WHERE "account_move_line".partner_id = %s
AND m.state IN %s
AND "account_move_line".account_id IN %s AND """ + query_get_data[1] + reconcile_clause + """
ORDER BY "account_move_line".date"""
self.env.cr.execute(query, tuple(params))
res = self.env.cr.dictfetchall()
sum = 0.0
lang_code = self.env.context.get('lang') or 'en_US'
lang = self.env['res.lang']
lang_id = lang._lang_get(lang_code)
date_format = lang_id.date_format
for r in res:
r['date'] = r['date']
r['displayed_name'] = '-'.join(
r[field_name] for field_name in ('move_name', 'ref', 'name')
if r[field_name] not in (None, '', '/')
)
sum += r['debit'] - r['credit']
r['progress'] = sum
r['currency_id'] = currency.browse(r.get('currency_id'))
full_account.append(r)
return full_account
def _sum_partner(self, data, partner, field):
if field not in ['debit', 'credit', 'debit - credit']:
return
result = 0.0
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
params = [partner.id, tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
query = """SELECT sum(""" + field + """)
FROM """ + query_get_data[0] + """, account_move AS m
WHERE "account_move_line".partner_id = %s
AND m.id = "account_move_line".move_id
AND m.state IN %s
AND account_id IN %s
AND """ + query_get_data[1] + reconcile_clause
self.env.cr.execute(query, tuple(params))
contemp = self.env.cr.fetchone()
if contemp is not None:
result = contemp[0] or 0.0
return result
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Form content is missing, this report cannot be printed."))
data['computed'] = {}
obj_partner = self.env['res.partner']
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
data['computed']['move_state'] = ['draft', 'posted']
if data['form'].get('target_move', 'all') == 'posted':
data['computed']['move_state'] = ['posted']
result_selection = data['form'].get('result_selection', 'customer')
if result_selection == 'supplier':
data['computed']['ACCOUNT_TYPE'] = ['liability_payable']
elif result_selection == 'customer':
data['computed']['ACCOUNT_TYPE'] = ['asset_receivable']
else:
data['computed']['ACCOUNT_TYPE'] = ['asset_receivable', 'liability_payable']
self.env.cr.execute("""
SELECT a.id
FROM account_account a
WHERE a.account_type IN %s
AND a.active""", (tuple(data['computed']['ACCOUNT_TYPE']),))
data['computed']['account_ids'] = [a for (a,) in self.env.cr.fetchall()]
params = [tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
query = """
SELECT DISTINCT "account_move_line".partner_id
FROM """ + query_get_data[0] + """, account_account AS account, account_move AS am
WHERE "account_move_line".partner_id IS NOT NULL
AND "account_move_line".account_id = account.id
AND am.id = "account_move_line".move_id
AND am.state IN %s
AND "account_move_line".account_id IN %s
AND account.active
AND """ + query_get_data[1] + reconcile_clause
self.env.cr.execute(query, tuple(params))
if data['form']['partner_ids']:
partner_ids = data['form']['partner_ids']
else:
partner_ids = [res['partner_id'] for res in
self.env.cr.dictfetchall()]
partners = obj_partner.browse(partner_ids)
partners = sorted(partners, key=lambda x: (x.ref or '', x.name or ''))
return {
'doc_ids': partner_ids,
'doc_model': self.env['res.partner'],
'data': data,
'docs': partners,
'time': time,
'lines': self._lines,
'sum_partner': self._sum_partner,
}

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_partnerledger">
<t t-call="web.html_container">
<t t-call="web.internal_layout">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<div class="page">
<h2>Partner Ledger</h2>
<div class="row">
<div class="col-3">
<strong>Company:</strong>
<p t-esc="res_company.name"/>
</div>
<div class="col-3">
<t t-if="data['form']['date_from']">
<strong>Date from :</strong>
<span t-esc="data['form']['date_from']"/>
<br/>
</t>
<t t-if="data['form']['date_to']">
<strong>Date to :</strong>
<span t-esc="data['form']['date_to']"/>
</t>
</div>
<div class="col-3">
<strong>Target Moves:</strong>
<p t-if="data['form']['target_move'] == 'all'">All Entries</p>
<p t-if="data['form']['target_move'] == 'posted'">All Posted Entries</p>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr>
<th>Date</th>
<th>JRNL</th>
<th>Account</th>
<th>Ref</th>
<th>Debit</th>
<th>Credit</th>
<th>Balance</th>
<th t-if="data['form']['amount_currency']">Currency</th>
</tr>
</thead>
<t t-foreach="docs" t-as="o">
<tbody>
<tr>
<td colspan="4">
<strong t-esc="o.ref"/>
-
<strong t-esc="o.name"/>
</td>
<td class="text-end">
<strong t-esc="sum_partner(data, o, 'debit')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<strong t-esc="sum_partner(data, o, 'credit')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<strong t-esc="sum_partner(data, o, 'debit - credit')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</tr>
<tr t-foreach="lines(data, o)" t-as="line">
<td>
<span t-esc="line['date']"/>
</td>
<td>
<span t-esc="line['code']"/>
</td>
<td>
<span t-esc="line['a_name']"/>
</td>
<td>
<span t-esc="line['displayed_name']"/>
</td>
<td class="text-end">
<span t-esc="line['debit']"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="line['credit']"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-esc="line['progress']"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end" t-if="data['form']['amount_currency']">
<t t-if="line['currency_id']">
<span t-esc="line['amount_currency']"
t-options="{'widget': 'monetary', 'display_currency': line['currency_id']}"/>
</t>
</td>
</tr>
</tbody>
</t>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,70 @@
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportTax(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_tax'
_description = 'Tax Report'
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form'):
raise UserError(_("Form content is missing, this report cannot be printed."))
return {
'data': data['form'],
'lines': self.get_lines(data.get('form')),
}
def _sql_from_amls_one(self):
sql = """SELECT "account_move_line".tax_line_id, COALESCE(SUM("account_move_line".debit-"account_move_line".credit), 0)
FROM %s
WHERE %s GROUP BY "account_move_line".tax_line_id"""
return sql
def _sql_from_amls_two(self):
sql = """SELECT r.account_tax_id, COALESCE(SUM("account_move_line".debit-"account_move_line".credit), 0)
FROM %s
INNER JOIN account_move_line_account_tax_rel r ON ("account_move_line".id = r.account_move_line_id)
INNER JOIN account_tax t ON (r.account_tax_id = t.id)
WHERE %s GROUP BY r.account_tax_id"""
return sql
def _compute_from_amls(self, options, taxes):
#compute the tax amount
sql = self._sql_from_amls_one()
tables, where_clause, where_params = self.env['account.move.line']._query_get()
query = sql % (tables, where_clause)
self.env.cr.execute(query, where_params)
results = self.env.cr.fetchall()
for result in results:
if result[0] in taxes:
taxes[result[0]]['tax'] = abs(result[1])
#compute the net amount
sql2 = self._sql_from_amls_two()
query = sql2 % (tables, where_clause)
self.env.cr.execute(query, where_params)
results = self.env.cr.fetchall()
for result in results:
if result[0] in taxes:
taxes[result[0]]['net'] = abs(result[1])
@api.model
def get_lines(self, options):
taxes = {}
for tax in self.env['account.tax'].search([('type_tax_use', '!=', 'none')]):
if tax.children_tax_ids:
for child in tax.children_tax_ids:
if child.type_tax_use != 'none':
continue
taxes[child.id] = {'tax': 0, 'net': 0, 'name': child.name, 'type': tax.type_tax_use}
else:
taxes[tax.id] = {'tax': 0, 'net': 0, 'name': tax.name, 'type': tax.type_tax_use}
self.with_context(date_from=options['date_from'], date_to=options['date_to'],
state=options['target_move'],
strict_range=True)._compute_from_amls(options, taxes)
groups = dict((tp, []) for tp in ['sale', 'purchase'])
for tax in taxes.values():
if tax['tax']:
groups[tax['type']].append(tax)
return groups

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_tax">
<t t-call="web.html_container">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<t t-call="web.internal_layout">
<div class="page">
<h3>Tax Report</h3>
<div class="row">
<div class="col-4">
<strong>Company:</strong>
<p t-esc="res_company.name"/>
</div>
<div class="col-4">
<t>
<strong>Date from :</strong>
<span t-esc="data['date_from']"/>
</t>
<br/>
<t>
<strong>Date to :</strong>
<span t-esc="data['date_to']"/>
</t>
</div>
<div class="col-4">
<strong>Target Moves:</strong>
<p>
<span t-if="data['target_move'] == 'all'">All Entries</span>
<span t-if="data['target_move'] == 'posted'">All Posted Entries</span>
</p>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr align="left">
<th>Sale</th>
<th>Net</th>
<th>Tax</th>
</tr>
</thead>
<tr align="left" t-foreach="lines['sale']" t-as="line">
<td>
<span t-esc="line.get('name')"/>
</td>
<td>
<span t-att-style="style" t-esc="line.get('net')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td>
<span t-att-style="style" t-esc="line.get('tax')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</tr>
<br/>
<tr align="left">
<td>
<strong>Purchase</strong>
</td>
<td></td>
<td></td>
</tr>
<tr align="left" t-foreach="lines['purchase']" t-as="line">
<td>
<span t-esc="line.get('name')"/>
</td>
<td>
<span t-att-style="style" t-esc="line.get('net')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td>
<span t-att-style="style" t-esc="line.get('tax')"
t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</tr>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,90 @@
import time
from odoo import api, models, _
from odoo.exceptions import UserError
class ReportTrialBalance(models.AbstractModel):
_name = 'report.accounting_pdf_reports.report_trialbalance'
_description = 'Trial Balance Report'
def _get_accounts(self, accounts, display_account):
""" compute the balance, debit and credit for the provided accounts
:Arguments:
`accounts`: list of accounts record,
`display_account`: it's used to display either all accounts or those accounts which balance is > 0
:Returns a list of dictionary of Accounts with following key and value
`name`: Account name,
`code`: Account code,
`credit`: total amount of credit,
`debit`: total amount of debit,
`balance`: total amount of balance,
"""
account_result = {}
# Prepare sql query base on selected parameters from wizard
tables, where_clause, where_params = self.env['account.move.line']._query_get()
tables = tables.replace('"','')
if not tables:
tables = 'account_move_line'
wheres = [""]
if where_clause.strip():
wheres.append(where_clause.strip())
filters = " AND ".join(wheres)
# compute the balance, debit and credit for the provided accounts
request = ("SELECT account_id AS id, SUM(debit) AS debit, SUM(credit) AS credit, "
"(SUM(debit) - SUM(credit)) AS balance" +\
" FROM " + tables + " WHERE account_id IN %s " + filters + " GROUP BY account_id")
params = (tuple(accounts.ids),) + tuple(where_params)
self.env.cr.execute(request, params)
for row in self.env.cr.dictfetchall():
account_result[row.pop('id')] = row
account_res = []
for account in accounts:
res = dict((fn, 0.0) for fn in ['credit', 'debit', 'balance'])
currency = account.currency_id and account.currency_id or self.env.company.currency_id
res['code'] = account.code
res['name'] = account.name
if account.id in account_result:
res['debit'] = account_result[account.id].get('debit')
res['credit'] = account_result[account.id].get('credit')
res['balance'] = account_result[account.id].get('balance')
if display_account == 'all':
account_res.append(res)
if display_account == 'not_zero' and not currency.is_zero(res['balance']):
account_res.append(res)
if display_account == 'movement' and (not currency.is_zero(res['debit']) or not currency.is_zero(res['credit'])):
account_res.append(res)
return account_res
@api.model
def _get_report_values(self, docids, data=None):
if not data.get('form') or not self.env.context.get('active_model'):
raise UserError(_("Form content is missing, this report cannot be printed."))
model = self.env.context.get('active_model')
docs = self.env[model].browse(self.env.context.get('active_ids', []))
display_account = data['form'].get('display_account')
accounts = docs if model == 'account.account' else self.env['account.account'].search([])
context = data['form'].get('used_context')
analytic_accounts = []
if data['form'].get('analytic_account_ids'):
analytic_account_ids = self.env['account.analytic.account'].browse(data['form'].get('analytic_account_ids'))
context['analytic_account_ids'] = analytic_account_ids
analytic_accounts = [account.name for account in analytic_account_ids]
account_res = self.with_context(context)._get_accounts(accounts, display_account)
codes = []
if data['form'].get('journal_ids', False):
codes = [journal.code for journal in
self.env['account.journal'].search(
[('id', 'in', data['form']['journal_ids'])])]
return {
'doc_ids': self.ids,
'doc_model': model,
'data': data['form'],
'docs': docs,
'print_journal': codes,
'analytic_accounts': analytic_accounts,
'time': time,
'Accounts': account_res,
}

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_trialbalance">
<t t-call="web.html_container">
<t t-set="data_report_margin_top" t-value="12"/>
<t t-set="data_report_header_spacing" t-value="9"/>
<t t-set="data_report_dpi" t-value="110"/>
<t t-call="web.internal_layout">
<div class="page">
<h2><span t-esc="res_company.name"/>: Trial Balance</h2>
<div class="row mt32">
<div class="col-4">
<strong>Display Account:</strong>
<p>
<span t-if="data['display_account'] == 'all'">All accounts</span>
<span t-if="data['display_account'] == 'movement'">With movements</span>
<span t-if="data['display_account'] == 'not_zero'">With balance not equal to zero</span>
</p>
</div>
<div class="col-4">
<p>
<t t-if="data['date_from']"><strong>Date from :</strong> <span t-esc="data['date_from']"/><br/></t>
<t t-if="data['date_to']"><strong>Date to :</strong> <span t-esc="data['date_to']"/></t>
</p>
</div>
<div class="col-4">
<strong>Target Moves:</strong>
<p>
<span t-if="data['target_move'] == 'all'">All Entries</span>
<span t-if="data['target_move'] == 'posted'">All Posted Entries</span>
</p>
</div>
</div>
<div class="row mt32">
<div class="col-6">
<strong>Journals:</strong>
<p t-esc="', '.join([ lt or '' for lt in print_journal ])"/>
</div>
<div class="col-6">
<t t-if="analytic_accounts">
<strong>Analytic Accounts:</strong>
<p t-esc="', '.join([ analytic_account or '' for analytic_account in analytic_accounts ])"/>
</t>
</div>
</div>
<table class="table table-sm table-reports">
<thead>
<tr>
<th>Code</th>
<th>Account</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
<tr t-foreach="Accounts" t-as="account">
<td>
<span t-att-style="style" t-esc="account['code']"/>
</td>
<td>
<span style="color: white;" t-esc="'..'"/>
<span t-att-style="style" t-esc="account['name']"/>
</td>
<td class="text-end">
<span t-att-style="style" t-esc="account['debit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-att-style="style" t-esc="account['credit']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
<td class="text-end">
<span t-att-style="style" t-esc="account['balance']" t-options="{'widget': 'monetary', 'display_currency': res_company.currency_id}"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,25 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_financial_report_accountant,access.account.financial.report.manager,model_account_financial_report,account.group_account_user,1,1,1,1
access_account_report_general_ledger,access.account.report.general.ledger,model_account_report_general_ledger,account.group_account_user,1,1,1,1
access_account_balance_report,access.account.balance.report,model_account_balance_report,account.group_account_user,1,1,1,1
access_account_report_partner_ledger,access.account.report.partner.ledger,model_account_report_partner_ledger,account.group_account_invoice,1,1,1,1
access_accounting_report,access.accounting.report,model_accounting_report,account.group_account_user,1,1,1,1
access_account_aged_trial_balance,access.account.aged.trial.balance,model_account_aged_trial_balance,account.group_account_user,1,1,1,1
access_account_tax_report,access.account.tax.report.wizard,model_account_tax_report_wizard,account.group_account_user,1,1,1,1
access_account_financial_report_accountant_bm,access.account.financial.report.bmanager,model_account_financial_report,account.group_account_manager,1,1,1,1
access_account_report_general_ledger_bm,access.account.report.general.ledger.bmanager,model_account_report_general_ledger,account.group_account_manager,1,1,1,1
access_account_balance_report_bm,access.account.balance.report.bmanager,model_account_balance_report,account.group_account_manager,1,1,1,1
access_account_report_partner_ledger_bm,access.account.report.partner.ledger.bmanager,model_account_report_partner_ledger,account.group_account_manager,1,1,1,1
access_accounting_report_bm,access.accounting.report.bmanager,model_accounting_report,account.group_account_manager,1,1,1,1
access_account_aged_trial_balance_bm,access.account.aged.trial.balance.bmanager,model_account_aged_trial_balance,account.group_account_manager,1,1,1,1
access_account_tax_report_bm,access.account.tax.report.wizard.bmanager,model_account_tax_report_wizard,account.group_account_manager,1,1,1,1
access_account_print_journal_bm,access.account.account.print.journal.bmanager,model_account_print_journal,account.group_account_manager,1,1,1,1
access_account_common_journal_report,access.account.common.journal.report,model_account_common_journal_report,account.group_account_user,1,1,1,0
access_account_print_journal,access.account.print.journal,model_account_print_journal,account.group_account_user,1,1,1,0
access_account_common_account_report,access_account_common_account_report,model_account_common_account_report,base.group_user,1,0,0,0
access_account_common_partner_report,access_account_common_partner_report,model_account_common_partner_report,base.group_user,1,0,0,0
access_account_common_report,access_account_common_report,accounting_pdf_reports.model_account_common_report,base.group_user,1,0,0,0
access_account_account_type,access_account_account_type,accounting_pdf_reports.model_account_account_type,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_financial_report_accountant access.account.financial.report.manager model_account_financial_report account.group_account_user 1 1 1 1
3 access_account_report_general_ledger access.account.report.general.ledger model_account_report_general_ledger account.group_account_user 1 1 1 1
4 access_account_balance_report access.account.balance.report model_account_balance_report account.group_account_user 1 1 1 1
5 access_account_report_partner_ledger access.account.report.partner.ledger model_account_report_partner_ledger account.group_account_invoice 1 1 1 1
6 access_accounting_report access.accounting.report model_accounting_report account.group_account_user 1 1 1 1
7 access_account_aged_trial_balance access.account.aged.trial.balance model_account_aged_trial_balance account.group_account_user 1 1 1 1
8 access_account_tax_report access.account.tax.report.wizard model_account_tax_report_wizard account.group_account_user 1 1 1 1
9 access_account_financial_report_accountant_bm access.account.financial.report.bmanager model_account_financial_report account.group_account_manager 1 1 1 1
10 access_account_report_general_ledger_bm access.account.report.general.ledger.bmanager model_account_report_general_ledger account.group_account_manager 1 1 1 1
11 access_account_balance_report_bm access.account.balance.report.bmanager model_account_balance_report account.group_account_manager 1 1 1 1
12 access_account_report_partner_ledger_bm access.account.report.partner.ledger.bmanager model_account_report_partner_ledger account.group_account_manager 1 1 1 1
13 access_accounting_report_bm access.accounting.report.bmanager model_accounting_report account.group_account_manager 1 1 1 1
14 access_account_aged_trial_balance_bm access.account.aged.trial.balance.bmanager model_account_aged_trial_balance account.group_account_manager 1 1 1 1
15 access_account_tax_report_bm access.account.tax.report.wizard.bmanager model_account_tax_report_wizard account.group_account_manager 1 1 1 1
16 access_account_print_journal_bm access.account.account.print.journal.bmanager model_account_print_journal account.group_account_manager 1 1 1 1
17 access_account_common_journal_report access.account.common.journal.report model_account_common_journal_report account.group_account_user 1 1 1 0
18 access_account_print_journal access.account.print.journal model_account_print_journal account.group_account_user 1 1 1 0
19 access_account_common_account_report access_account_common_account_report model_account_common_account_report base.group_user 1 0 0 0
20 access_account_common_partner_report access_account_common_partner_report model_account_common_partner_report base.group_user 1 0 0 0
21 access_account_common_report access_account_common_report accounting_pdf_reports.model_account_common_report base.group_user 1 0 0 0
22 access_account_account_type access_account_account_type accounting_pdf_reports.model_account_account_type base.group_user 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,155 @@
<section class="oe_container oe_dark">
<div class="col-md-12">
<h2 class="oe_slogan" style="font-size: 35px;color:#2C0091"><b>Accounting Reports Odoo 18</b></h2>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<div style="align:center;">
<h1 style="text-align: center;">
<span align="center" style="color:#148963;">
<span class="fa fa-star fa-spin">
</span>
Added Financial Reports:</span>
</h1>
<div class="row" style="margin-top: 2rem;">
<div class="col-lg-12">
<div class="mt-3">
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Partner Ledger Report.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Journals Audit.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">General Ledger.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Trial Balance.</span>
</p><br/>
</div>
</div>
<div class="col-lg-12">
<div class="mt-3">
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Aged Partner Balance.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Profit and Loss.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Balance Sheet.</span>
</p><br/>
<p class="fa fa-check" style="color:green;font-size: 15px;">
<span style="color:#000000;font-size: 15px;">Tax Report.</span>
</p><br/>
</div>
</div>
</div>
</div>
<br/>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<div class="oe_centeralign oe_websiteonly">
<h4 class="oe_slogan"><a href="https://www.youtube.com/watch?v=yA4NLwOLZms" target="_blank" style="color: #FFFFFF !important; border-radius: 0; background-color: #9c676e; border-color: #005ca7; padding: 15px; font-weight: bold;">
<i class="fa fa-youtube">
Watch on YouTube
</i>
</a></h4>
</div>
</div>
</section>
<section class="oe_container oe_dark">
<div class="oe_row oe_spaced">
<h2 class="oe_slogan" style="color:olive;">Accounting Reports</h2>
<h3 class="oe_slogan" style="color:#000066;font-size: 24px;">All in one financial reports for odoo community edition</h3>
<div class="oe_demo oe_picture oe_screenshot">
<img src="account_reports.png" style="height:400px;">
</div>
<br/>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<h3 class="oe_slogan" style="color:#332c3c;font-size: 28px;">General Ledger</h3>
<h3 class="oe_slogan" style="color:#000066;font-size: 24px;">General ledger report with accounts, partners and analytic account filter</h3>
<div class="oe_demo oe_picture oe_screenshot">
<img src="general_ledger_filter.png" style="height:400px;">
</div>
<br/>
<h4 class="oe_slogan" style="color:#332c3c;font-size: 28px;">Report</h4>
<div class="oe_demo oe_picture oe_screenshot">
<img src="general_ledger_report.png" style="height:400px;">
</div>
</div>
</section>
<section class="oe_container oe_dark">
<div class="oe_row oe_spaced">
<h3 class="oe_slogan" style="color:#1b1d26;">Partner Ledger</h3>
<h3 class="oe_slogan" style="color:#000066;font-size: 24px;">Partner ledger report with partner filter.</h3>
<div class="oe_demo oe_picture oe_screenshot">
<img src="partner_ledger_filter.png" style="height:400px;">
</div>
<br/>
<h4 class="oe_slogan" style="color:#332c3c;font-size: 28px;">Report</h4>
<div class="oe_demo oe_picture oe_screenshot">
<img src="partner_ledger_report.png" style="height:400px;">
</div>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<h3 class="oe_slogan" style="color:#1b1d26;">Aged Partner Balance</h3>
<div class="oe_demo oe_picture oe_screenshot">
<img src="aged_partner_balance_filter.png" style="height:400px;">
</div>
<br/>
<h4 class="oe_slogan" style="color:#332c3c;font-size: 28px;">Report</h4>
<div class="oe_demo oe_picture oe_screenshot">
<img src="aged_partner_balance_report.png" style="height:400px;">
</div>
</div>
</section>
<br/>
<hr style="width: 100%;height: 4px;background: #2C0091;margin: 0px 0px;">
<hr style="width: 100%;height: 4px;background: #148963;margin: 0px 0px;">
<section class="oe_container oe_dark">
<div class="oe_row ">
<div class="oe_slogan text-center">
<img src="odoo_mates.png"/>
<div style="color:#269900;">
<h3 style="color:#2C0091;font-size: 25px;">If you need any support or want more features, just contact us:</h3><br>
<h3 style="color:#2C0091;font-size: 20px;">Email: <a href="odoomates@gmail.com">odoomates@gmail.com</a> <br></h3>
</div>
<div class="oe_slogan">
<h2>
<a target="_blank" href="https://www.facebook.com/odoomate/" target="new">
<i class="fa fa-facebook-square" style="font-size:38px;"></i>
</a>
<a target="_blank" href="https://twitter.com/odoomates/" target="new">
<i class="fa fa-twitter" style="font-size:38px;"></i>
</a>
<a href="#" target="_blank">
<i class="fa fa-linkedin" style="font-size:38px;"></i>
</a>
<a target="_blank" href="https://www.youtube.com/channel/UCVKlUZP7HAhdQgs-9iTJklQ">
<i class="fa fa-youtube-play" style="font-size:38px;"></i>
</a>
</h2>
</div>
</div>
</div>
</section>
<hr style="width: 100%;height: 4px;background: #148963;margin: 0px 0px;">
<hr style="width: 100%;height: 4px;background: #2C0091;margin: 0px 0px;">

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_financial_report_form" model="ir.ui.view">
<field name="name">account.financial.report.form</field>
<field name="model">account.financial.report</field>
<field name="arch" type="xml">
<form string="Account Report">
<sheet>
<group>
<group>
<field name="name"/>
<field name="parent_id"/>
<field name="sequence"/>
</group>
<group>
<field name="type"/>
<field name="sign"/>
<field name="style_overwrite"/>
</group>
</group>
<notebook>
<page string="Report"
invisible="type not in ['accounts', 'account_type', 'account_report']">
<group>
<field name="display_detail"
invisible="type not in ['accounts', 'account_type', 'account_report']"/>
<field name="account_report_id"
invisible="type != 'account_report'"/>
</group>
<field name="account_ids" invisible="type != 'accounts'"/>
<field name="account_type_ids" invisible="type != 'account_type'"/>
</page>
<page string="Childrens">
<field name="children_ids" nolabel="1">
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_account_financial_report_tree" model="ir.ui.view">
<field name="name">account.financial.report.list</field>
<field name="model">account.financial.report</field>
<field name="arch" type="xml">
<list string="Account Report">
<field name="name"/>
<field name="parent_id" invisible="1"/>
<field name="type"/>
<field name="account_report_id"/>
</list>
</field>
</record>
<record id="view_account_financial_report_search" model="ir.ui.view">
<field name="name">account.financial.report.search</field>
<field name="model">account.financial.report</field>
<field name="arch" type="xml">
<search string="Account Report">
<field name="name" string="Account Report"/>
<field name="type"/>
<field name="account_report_id"/>
<filter string="Reports" name="filter_parent_id" domain="[('parent_id','=', False)]"/>
<group>
<filter name="parent_report" string="Parent Report"
context="{'group_by':'parent_id'}"/>
<filter name="report_type" string="Report Type" context="{'group_by':'type'}"/>
</group>
</search>
</field>
</record>
<record id="action_account_financial_report_tree" model="ir.actions.act_window">
<field name="name">Financial Reports</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.financial.report</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_filter_parent_id': True}</field>
<field name="search_view_id" ref="view_account_financial_report_search"/>
<field name="view_id" ref="view_account_financial_report_tree"/>
</record>
<menuitem id="menu_finance_reports_settings"
name="Financial Reports"
sequence="9"
groups="account.group_account_user,account.group_account_manager"
parent="account.menu_finance_configuration"/>
<menuitem id="menu_account_reports"
name="Account Reports"
action="action_account_financial_report_tree"
groups="account.group_account_user,account.group_account_manager"
parent="menu_finance_reports_settings"/>
</odoo>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_account_moves_ledger_general" model="ir.actions.act_window">
<field name="context">{'journal_type':'general', 'search_default_group_by_account': 1, 'search_default_posted':1}</field>
<field name="name">General Ledger</field>
<field name="res_model">account.move.line</field>
<field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field>
<field name="view_id" ref="account.view_move_line_tree_grouped_general"/>
<field name="search_view_id" ref="account.view_account_move_line_filter"/>
<field name="view_mode">list,pivot,graph</field>
</record>
<record id="action_account_moves_ledger_partner" model="ir.actions.act_window">
<field name="context">{'journal_type':'general', 'search_default_group_by_partner': 1,
'search_default_posted':1, 'search_default_payable':1, 'search_default_receivable':1,
'search_default_unreconciled':1}
</field>
<field name="name">Partner Ledger</field>
<field name="res_model">account.move.line</field>
<field name="domain">[('display_type', 'not in', ('line_section', 'line_note'))]</field>
<field name="view_id" ref="account.view_move_line_tree_grouped_partner"/>
<field name="search_view_id" ref="account.view_account_move_line_filter"/>
<field name="view_mode">list,pivot,graph</field>
</record>
<menuitem id="menu_finance_entries_accounting_ledgers" name="Ledgers" parent="account.menu_finance_entries"
sequence="3">
<menuitem id="menu_action_account_moves_ledger_general" action="action_account_moves_ledger_general"
groups="account.group_account_readonly" sequence="1"/>
<menuitem id="menu_action_account_moves_ledger_partner" action="action_account_moves_ledger_partner"
groups="account.group_account_readonly" sequence="2"/>
</menuitem>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_finance_legal_statement"
name="Financial Reports"
sequence="10"
parent="account.menu_finance_reports"/>
<menuitem id="menu_finance_partner_reports"
name="Partner Reports"
sequence="20"
parent="account.menu_finance_reports"/>
<menuitem id="menu_finance_audit_reports"
name="Audit Reports"
sequence="30"
parent="account.menu_finance_reports"/>
<record id="account.account_reports_management_menu" model="ir.ui.menu">
<field name="sequence" eval="40"/>
</record>
</odoo>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.accountant</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<app name="account" position="inside">
<h2>Enhanced Financial Reports</h2>
<div>
<div class="row mt16 o_settings_container" name="report_setting_container">
<div class="col-6 col-lg-6 o_setting_box" id="enhanced_reports">
<div>
Preview financial reports without downloading
</div>
<div class="content-group">
<a target="_blank" href="https://apps.odoo.com/apps/modules/19.0/om_accounting_reports/"
style="text-decoration: underline;">Enhanced Financial Reports</a>
</div>
</div>
<div class="col-6 col-lg-6 o_setting_box" id="excel_reports">
<div>
Financial Reports in Excel
</div>
<div class="content-group">
<a target="_blank" href="https://apps.odoo.com/apps/modules/19.0/accounting_excel_reports/"
style="text-decoration: underline;">Excel Reports</a>
</div>
</div>
</div>
</div>
</app>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,12 @@
from . import account_report_common
from . import account_report_common_journal
from . import account_report_print_journal
from . import account_report
from . import account_report_common_partner
from . import account_report_common_account
from . import account_partner_ledger
from . import account_general_ledger
from . import account_trial_balance
from . import account_tax_report
from . import aged_partner
from . import account_journal_audit

View File

@@ -0,0 +1,35 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class AccountReportGeneralLedger(models.TransientModel):
_name = "account.report.general.ledger"
_inherit = "account.common.account.report"
_description = "General Ledger Report"
initial_balance = fields.Boolean(
string='Include Initial Balances',
help='If you selected date, this field allow you to add a row '
'to display the amount of debit/credit/balance that precedes '
'the filter you have set.'
)
sortby = fields.Selection(
[('sort_date', 'Date'), ('sort_journal_partner', 'Journal & Partner')],
string='Sort by', required=True, default='sort_date'
)
journal_ids = fields.Many2many(
'account.journal', 'account_report_general_ledger_journal_rel',
'account_id', 'journal_id', string='Journals', required=True
)
def _get_report_data(self, data):
data = self.pre_print_report(data)
data['form'].update(self.read(['initial_balance', 'sortby'])[0])
if data['form'].get('initial_balance') and not data['form'].get('date_from'):
raise UserError(_("You must define a Start Date"))
records = self.env[data['model']].browse(data.get('ids', []))
return records, data
def _print_report(self, data):
records, data = self._get_report_data(data)
return self.env.ref('accounting_pdf_reports.action_report_general_ledger').with_context(landscape=True).report_action(records, data=data)

View File

@@ -0,0 +1,21 @@
from odoo import fields, models, api
class AccountPrintJournal(models.TransientModel):
_name = "account.print.journal"
_inherit = "account.common.journal.report"
_description = "Account Print Journal"
sort_selection = fields.Selection([('date', 'Date'), ('move_name', 'Journal Entry Number')],
'Entries Sorted by', required=True, default='move_name')
journal_ids = fields.Many2many('account.journal', string='Journals', required=True,
default=lambda self: self.env['account.journal'].search([('type', 'in', ['sale', 'purchase'])]))
def _get_report_data(self, data):
data = self.pre_print_report(data)
data['form'].update({'sort_selection': self.sort_selection})
return data
def _print_report(self, data):
data = self._get_report_data(data)
return self.env.ref('accounting_pdf_reports.action_report_journal').with_context(landscape=True).report_action(self, data=data)

View File

@@ -0,0 +1,24 @@
from odoo import fields, models, api, _
class AccountPartnerLedger(models.TransientModel):
_name = "account.report.partner.ledger"
_inherit = "account.common.partner.report"
_description = "Account Partner Ledger"
amount_currency = fields.Boolean("With Currency",
help="It adds the currency column on "
"report if the currency differs from "
"the company currency.")
reconciled = fields.Boolean('Reconciled Entries')
def _get_report_data(self, data):
data = self.pre_print_report(data)
data['form'].update({'reconciled': self.reconciled,
'amount_currency': self.amount_currency})
return data
def _print_report(self, data):
data = self._get_report_data(data)
return self.env.ref('accounting_pdf_reports.action_report_partnerledger').with_context(landscape=True).\
report_action(self, data=data)

View File

@@ -0,0 +1,55 @@
from odoo import api, fields, models
class AccountingReport(models.TransientModel):
_name = "accounting.report"
_inherit = "account.common.report"
_description = "Accounting Report"
@api.model
def _get_account_report(self):
reports = []
if self.env.context.get('active_id'):
menu = self.env['ir.ui.menu'].browse(self.env.context.get('active_id')).name
reports = self.env['account.financial.report'].search([('name', 'ilike', menu)])
return reports and reports[0] or False
enable_filter = fields.Boolean(string='Enable Comparison')
account_report_id = fields.Many2one('account.financial.report', string='Account Reports',
required=True, default=_get_account_report)
label_filter = fields.Char(string='Column Label', help="This label will be displayed on report to "
"show the balance computed for the given comparison filter.")
filter_cmp = fields.Selection([('filter_no', 'No Filters'), ('filter_date', 'Date')],
string='Filter by', required=True, default='filter_no')
date_from_cmp = fields.Date(string='Date From')
date_to_cmp = fields.Date(string='Date To')
debit_credit = fields.Boolean(string='Display Debit/Credit Columns',
help="This option allows you to get more details about "
"the way your balances are computed."
" Because it is space consuming, we do not allow to"
" use it while doing a comparison.")
def _build_comparison_context(self, data):
result = {}
result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False
result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or ''
if data['form']['filter_cmp'] == 'filter_date':
result['date_from'] = data['form']['date_from_cmp']
result['date_to'] = data['form']['date_to_cmp']
result['strict_range'] = True
return result
def check_report(self):
res = super(AccountingReport, self).check_report()
data = {}
data['form'] = self.read(['account_report_id', 'date_from_cmp', 'date_to_cmp', 'journal_ids', 'filter_cmp', 'target_move'])[0]
for field in ['account_report_id']:
if isinstance(data['form'][field], tuple):
data['form'][field] = data['form'][field][0]
comparison_context = self._build_comparison_context(data)
res['data']['form']['comparison_context'] = comparison_context
return res
def _print_report(self, data):
data['form'].update(self.read(['date_from_cmp', 'debit_credit', 'date_to_cmp', 'filter_cmp', 'account_report_id', 'enable_filter', 'label_filter', 'target_move'])[0])
return self.env.ref('accounting_pdf_reports.action_report_financial').report_action(self, data=data, config=False)

View File

@@ -0,0 +1,52 @@
from odoo import api, fields, models, _
from odoo.tools.misc import get_lang
class AccountCommonReport(models.TransientModel):
_name = "account.common.report"
_description = "Account Common Report"
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, default=lambda self: self.env.company)
journal_ids = fields.Many2many(
comodel_name='account.journal',
string='Journals',
required=True,
default=lambda self: self.env['account.journal'].search([('company_id', '=', self.company_id.id)]),
domain="[('company_id', '=', company_id)]",
)
date_from = fields.Date(string='Start Date')
date_to = fields.Date(string='End Date')
target_move = fields.Selection([('posted', 'All Posted Entries'),
('all', 'All Entries'),
], string='Target Moves', required=True, default='posted')
@api.onchange('company_id')
def _onchange_company_id(self):
if self.company_id:
self.journal_ids = self.env['account.journal'].search(
[('company_id', '=', self.company_id.id)])
else:
self.journal_ids = self.env['account.journal'].search([])
def _build_contexts(self, data):
result = {}
result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False
result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or ''
result['date_from'] = data['form']['date_from'] or False
result['date_to'] = data['form']['date_to'] or False
result['strict_range'] = True if result['date_from'] else False
result['company_id'] = data['form']['company_id'][0] or False
return result
def _print_report(self, data):
raise NotImplementedError()
def check_report(self):
self.ensure_one()
data = {}
data['ids'] = self.env.context.get('active_ids', [])
data['model'] = self.env.context.get('active_model', 'ir.ui.menu')
data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0]
used_context = self._build_contexts(data)
data['form']['used_context'] = dict(used_context, lang=get_lang(self.env).code)
return self.with_context(discard_logo_check=True)._print_report(data)

View File

@@ -0,0 +1,26 @@
from odoo import api, fields, models
class AccountCommonAccountReport(models.TransientModel):
_name = 'account.common.account.report'
_inherit = "account.common.report"
_description = 'Account Common Account Report'
display_account = fields.Selection([('all', 'All'),
('movement', 'With movements'),
('not_zero', 'With balance is not equal to 0'), ],
string='Display Accounts',
required=True, default='movement')
analytic_account_ids = fields.Many2many('account.analytic.account',
string='Analytic Accounts')
account_ids = fields.Many2many('account.account', string='Accounts')
partner_ids = fields.Many2many('res.partner', string='Partners')
def pre_print_report(self, data):
data['form'].update(self.read(['display_account'])[0])
data['form'].update({
'analytic_account_ids': self.analytic_account_ids.ids,
'partner_ids': self.partner_ids.ids,
'account_ids': self.account_ids.ids,
})
return data

View File

@@ -0,0 +1,13 @@
from odoo import api, fields, models
class AccountCommonJournalReport(models.TransientModel):
_name = 'account.common.journal.report'
_description = 'Common Journal Report'
_inherit = "account.common.report"
amount_currency = fields.Boolean('With Currency', help="Print Report with the currency column if the currency differs from the company currency.")
def pre_print_report(self, data):
data['form'].update({'amount_currency': self.amount_currency})
return data

View File

@@ -0,0 +1,18 @@
from odoo import fields, models
class AccountingCommonPartnerReport(models.TransientModel):
_name = 'account.common.partner.report'
_inherit = "account.common.report"
_description = 'Account Common Partner Report'
result_selection = fields.Selection([('customer', 'Receivable Accounts'),
('supplier', 'Payable Accounts'),
('customer_supplier', 'Receivable and Payable Accounts')
], string="Partner's", required=True, default='customer')
partner_ids = fields.Many2many('res.partner', string='Partners')
def pre_print_report(self, data):
data['form'].update(self.read(['result_selection'])[0])
data['form'].update({'partner_ids': self.partner_ids.ids})
return data

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_common_report_view" model="ir.ui.view">
<field name="name">Common Report</field>
<field name="model">account.common.report</field>
<field name="arch" type="xml">
<form string="Report Options">
<group col="4">
<field name="target_move" widget="radio"/>
<field name="date_from"/>
<field name="date_to"/>
</group>
<group>
<field name="journal_ids" widget="many2many_tags" options="{'no_create': True}"/>
<field name="company_id" invisible="1"/>
</group>
<footer>
<button name="check_report" string="Print" type="object" default_focus="1" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,21 @@
from odoo import fields, models
class AccountPrintJournal(models.TransientModel):
_inherit = "account.common.journal.report"
_name = "account.print.journal"
_description = "Account Print Journal"
sort_selection = fields.Selection(
[('date', 'Date'), ('move_name', 'Journal Entry Number')],
'Entries Sorted by', required=True, default='move_name'
)
journal_ids = fields.Many2many(
'account.journal', string='Journals', required=True,
default=lambda self: self.env['account.journal'].search([('type', 'in', ['sale', 'purchase'])])
)
def _print_report(self, data):
data = self.pre_print_report(data)
data['form'].update({'sort_selection': self.sort_selection})
return self.env.ref('account.action_report_journal').with_context(landscape=True).report_action(self, data=data)

View File

@@ -0,0 +1,20 @@
from odoo import models, api, fields
from datetime import date
class AccountTaxReport(models.TransientModel):
_name = 'account.tax.report.wizard'
_inherit = "account.common.report"
_description = 'Tax Report'
date_from = fields.Date(
string='Date From', required=True,
default=lambda self: fields.Date.to_string(date.today().replace(day=1))
)
date_to = fields.Date(
string='Date To', required=True,
default=lambda self: fields.Date.to_string(date.today())
)
def _print_report(self, data):
return self.env.ref('accounting_pdf_reports.action_report_account_tax').report_action(self, data=data)

View File

@@ -0,0 +1,26 @@
from odoo import fields, models, api
class AccountBalanceReport(models.TransientModel):
_name = 'account.balance.report'
_inherit = "account.common.account.report"
_description = 'Trial Balance Report'
journal_ids = fields.Many2many(
'account.journal', 'account_balance_report_journal_rel',
'account_id', 'journal_id',
string='Journals', required=True, default=[]
)
analytic_account_ids = fields.Many2many(
'account.analytic.account',
'account_trial_balance_analytic_rel', string='Analytic Accounts'
)
def _get_report_data(self, data):
data = self.pre_print_report(data)
records = self.env[data['model']].browse(data.get('ids', []))
return records, data
def _print_report(self, data):
records, data = self._get_report_data(data)
return self.env.ref('accounting_pdf_reports.action_report_trial_balance').report_action(records, data=data)

View File

@@ -0,0 +1,41 @@
import time
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAgedTrialBalance(models.TransientModel):
_name = 'account.aged.trial.balance'
_inherit = 'account.common.partner.report'
_description = 'Account Aged Trial balance Report'
period_length = fields.Integer(string='Period Length (days)', required=True, default=30)
journal_ids = fields.Many2many('account.journal', string='Journals', required=True)
date_from = fields.Date(default=lambda *a: time.strftime('%Y-%m-%d'))
def _get_report_data(self, data):
res = {}
data = self.pre_print_report(data)
data['form'].update(self.read(['period_length'])[0])
period_length = data['form']['period_length']
if period_length <= 0:
raise UserError(_('You must set a period length greater than 0.'))
if not data['form']['date_from']:
raise UserError(_('You must set a start date.'))
start = data['form']['date_from']
for i in range(5)[::-1]:
stop = start - relativedelta(days=period_length - 1)
res[str(i)] = {
'name': (i != 0 and (str((5 - (i + 1)) * period_length) + '-' + str((5 - i) * period_length)) or (
'+' + str(4 * period_length))),
'stop': start.strftime('%Y-%m-%d'),
'start': (i != 0 and stop.strftime('%Y-%m-%d') or False),
}
start = stop - relativedelta(days=1)
data['form'].update(res)
return data
def _print_report(self, data):
data = self._get_report_data(data)
return self.env.ref('accounting_pdf_reports.action_report_aged_partner_balance').\
with_context(landscape=True).report_action(self, data=data)

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_aged_balance_view" model="ir.ui.view">
<field name="name">Aged Partner Balance</field>
<field name="model">account.aged.trial.balance</field>
<field name="arch" type="xml">
<form string="Report Options">
<group col="4">
<field name="date_from"/>
<field name="period_length"/>
<field name="company_id" invisible="1"/>
<newline/>
<field name="result_selection" widget="radio"
invisible="context.get('hide_result_selection')"/>
<field name="target_move" widget="radio"/>
</group>
<field name="journal_ids" required="0" invisible="1"/>
<xpath expr="//field[@name='journal_ids']" position="before">
<group>
<field name="partner_ids" widget="many2many_tags"
options="{'no_open': True, 'no_create': True}"/>
</group>
</xpath>
<footer>
<button name="check_report" class="oe_highlight"
string="Print" type="object"/>
<button string="Cancel" class="btn btn-default" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_account_aged_balance_view" model="ir.actions.act_window">
<field name="name">Aged Partner Balance</field>
<field name="res_model">account.aged.trial.balance</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="account_aged_balance_view"/>
<field name="context"></field>
<field name="target">new</field>
</record>
<menuitem id="menu_aged_trial_balance"
name="Aged Partner Balance"
sequence="10"
action="action_account_aged_balance_view"
parent="menu_finance_partner_reports"/>
<record id="action_account_aged_receivable" model="ir.actions.act_window">
<field name="name">Aged Receivable</field>
<field name="res_model">account.aged.trial.balance</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="account_aged_balance_view"/>
<field name="context">{'default_result_selection': 'customer',
'hide_result_selection': 1}</field>
<field name="target">new</field>
</record>
<menuitem id="menu_aged_receivable"
name="Aged Receivable"
sequence="20"
action="action_account_aged_receivable"
parent="menu_finance_partner_reports"/>
<record id="action_account_aged_payable" model="ir.actions.act_window">
<field name="name">Aged Payable</field>
<field name="res_model">account.aged.trial.balance</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="account_aged_balance_view"/>
<field name="context">{'default_result_selection': 'supplier',
'hide_result_selection': 1}</field>
<field name="target">new</field>
</record>
<menuitem id="menu_aged_payable"
name="Aged Payable"
sequence="30"
action="action_account_aged_payable"
parent="menu_finance_partner_reports"/>
</odoo>

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_financial_report_profitandloss0" model="account.financial.report">
<field name="name">Profit and Loss</field>
<field name="sign">-1</field>
<field name="type">sum</field>
</record>
<record id="account_financial_report_income0" model="account.financial.report">
<field name="name">Income</field>
<field name="sign">-1</field>
<field name="parent_id" ref="account_financial_report_profitandloss0"/>
<field name="display_detail">detail_with_hierarchy</field>
<field name="type">account_type</field>
<field name="account_type_ids" eval="[(4,ref('accounting_pdf_reports.data_account_type_other_income')), (4,ref('accounting_pdf_reports.data_account_type_revenue'))]"/>
</record>
<record id="account_financial_report_expense0" model="account.financial.report">
<field name="name">Expense</field>
<field name="sign">-1</field>
<field name="parent_id" ref="account_financial_report_profitandloss0"/>
<field name="display_detail">detail_with_hierarchy</field>
<field name="type">account_type</field>
<field name="account_type_ids" eval="[(4,ref('accounting_pdf_reports.data_account_type_expenses')),(4,ref('accounting_pdf_reports.data_account_type_direct_costs')), (4,ref('accounting_pdf_reports.data_account_type_depreciation'))]"/>
</record>
<record id="account_financial_report_balancesheet0" model="account.financial.report">
<field name="name">Balance Sheet</field>
<field name="type">sum</field>
</record>
<record id="account_financial_report_assets0" model="account.financial.report">
<field name="name">Assets</field>
<field name="parent_id" ref="account_financial_report_balancesheet0"/>
<field name="display_detail">detail_with_hierarchy</field>
<field name="type">account_type</field>
<field name="account_type_ids" eval="[(4,ref('accounting_pdf_reports.data_account_type_receivable')),
(4,ref('accounting_pdf_reports.data_account_type_liquidity')), (4,ref('accounting_pdf_reports.data_account_type_current_assets')),
(4,ref('accounting_pdf_reports.data_account_type_non_current_assets'), (4,ref('accounting_pdf_reports.data_account_type_prepayments'))),
(4,ref('accounting_pdf_reports.data_account_type_fixed_assets'))]"/>
</record>
<record id="account_financial_report_liabilitysum0" model="account.financial.report">
<field name="name">Liability</field>
<field name="parent_id" ref="account_financial_report_balancesheet0"/>
<field name="display_detail">no_detail</field>
<field name="type">sum</field>
</record>
<record id="account_financial_report_liability0" model="account.financial.report">
<field name="name">Liability</field>
<field name="parent_id" ref="account_financial_report_liabilitysum0"/>
<field name="display_detail">detail_with_hierarchy</field>
<field name="type">account_type</field>
<field name="account_type_ids" eval="[(4,ref('accounting_pdf_reports.data_account_type_payable')),
(4,ref('accounting_pdf_reports.data_account_type_equity')), (4,ref('accounting_pdf_reports.data_account_type_current_liabilities')),
(4,ref('accounting_pdf_reports.data_account_type_non_current_liabilities'))]"/>
</record>
<record id="account_financial_report_profitloss_toreport0" model="account.financial.report">
<field name="name">Profit (Loss) to report</field>
<field name="parent_id" ref="account_financial_report_liabilitysum0"/>
<field name="display_detail">no_detail</field>
<field name="type">account_report</field>
<field name="account_report_id" ref="account_financial_report_profitandloss0"/>
</record>
<record id="accounting_report_view" model="ir.ui.view">
<field name="name">Accounting Report</field>
<field name="model">accounting.report</field>
<field name="inherit_id" ref="accounting_pdf_reports.account_common_report_view"/>
<field name="arch" type="xml">
<field name="target_move" position="before">
<field name="account_report_id" domain="[('parent_id','=',False)]"/>
</field>
<field name="target_move" position="after">
<field name="enable_filter"/>
<field name="debit_credit" invisible="enable_filter == True"/>
</field>
<field name="journal_ids" position="after">
<notebook tabpos="up" colspan="4">
<page string="Comparison" name="comparison" invisible="enable_filter == False">
<group>
<field name="label_filter" required="enable_filter == True"/>
<field name="filter_cmp"/>
</group>
<group string="Dates" invisible="filter_cmp != 'filter_date'">
<field name="date_from_cmp" required="filter_cmp == 'filter_date'"/>
<field name="date_to_cmp" required="filter_cmp == 'filter_date'"/>
</group>
</page>
</notebook>
</field>
</field>
</record>
<record id="action_account_report_bs" model="ir.actions.act_window">
<field name="name">Balance Sheet</field>
<field name="res_model">accounting.report</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="accounting_report_view"/>
<field name="target">new</field>
<field name="context" eval="{'default_account_report_id':ref('accounting_pdf_reports.account_financial_report_balancesheet0')}"/>
</record>
<menuitem id="menu_account_report_bs"
name="Balance Sheet"
sequence="5"
action="action_account_report_bs"
parent="menu_finance_legal_statement"
groups="account.group_account_user,account.group_account_manager"/>
</odoo>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_report_general_ledger_view" model="ir.ui.view">
<field name="name">General Ledger</field>
<field name="model">account.report.general.ledger</field>
<field name="inherit_id" ref="accounting_pdf_reports.account_common_report_view"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='journal_ids']" position="after">
<field name="analytic_account_ids" widget="many2many_tags"
options="{'no_open': True, 'no_create': True}"
invisible="1"
groups="analytic.group_analytic_accounting"/>
<field name="account_ids" widget="many2many_tags"
options="{'no_open': True, 'no_create': True}"/>
<field name="partner_ids" widget="many2many_tags"
options="{'no_open': True, 'no_create': True}"/>
</xpath>
<xpath expr="//field[@name='target_move']" position="after">
<field name="sortby" widget="radio"/>
<field name="display_account" widget="radio"/>
<field name="initial_balance"/>
<newline/>
</xpath>
</data>
</field>
</record>
<record id="action_account_general_ledger_menu" model="ir.actions.act_window">
<field name="name">General Ledger</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.report.general.ledger</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_report_general_ledger_view"/>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_account" />
<field name="binding_type">report</field>
</record>
<menuitem id="menu_general_ledger"
name="General Ledger"
sequence="10"
parent="menu_finance_audit_reports"
action="action_account_general_ledger_menu"
groups="account.group_account_user,account.group_account_manager"/>
</odoo>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_report_print_journal_view" model="ir.ui.view">
<field name="name">Journals Audit</field>
<field name="model">account.print.journal</field>
<field name="inherit_id" ref="accounting_pdf_reports.account_common_report_view"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='target_move']" position="after">
<field name="amount_currency" groups="base.group_multi_currency"/>
<field name="sort_selection" widget="radio"/>
<newline/>
</xpath>
</data>
</field>
</record>
<record id="action_account_print_journal_menu" model="ir.actions.act_window">
<field name="name">Journals Audit</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.print.journal</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_report_print_journal_view"/>
<field name="target">new</field>
</record>
<menuitem id="menu_print_journal"
name="Journals Audit"
sequence="40"
parent="menu_finance_audit_reports"
action="action_account_print_journal_menu"
groups="account.group_account_manager,account.group_account_user"/>
</odoo>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_report_partner_ledger_view" model="ir.ui.view">
<field name="name">Partner Ledger</field>
<field name="model">account.report.partner.ledger</field>
<field name="inherit_id" ref="accounting_pdf_reports.account_common_report_view"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='journal_ids']" position="before">
<field name="partner_ids" widget="many2many_tags"
options="{'no_open': True, 'no_create': True}"/>
</xpath>
<xpath expr="//field[@name='target_move']" position="after">
<field name="result_selection"/>
<field name="amount_currency" groups="base.group_multi_currency"/>
<newline/>
<field name="reconciled"/>
<newline/>
</xpath>
</data>
</field>
</record>
<record id="action_account_partner_ledger_menu" model="ir.actions.act_window">
<field name="name">Partner Ledger</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.report.partner.ledger</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_report_partner_ledger_view"/>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_account" />
<field name="binding_type">report</field>
</record>
<menuitem id="menu_partner_ledger"
name="Partner Ledger"
sequence="5"
parent="menu_finance_partner_reports"
action="action_account_partner_ledger_menu"
groups="account.group_account_invoice"/>
<!-- Add to Partner Print button -->
<record id="action_partner_report_partnerledger" model="ir.actions.act_window">
<field name="name">Balance Statement (Partner Ledger)</field>
<field name="res_model">account.report.partner.ledger</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_report_partner_ledger_view" />
<field name="target">new</field>
<field name="binding_model_id" ref="base.model_res_partner" />
<field name="binding_type">report</field>
<field name="context">{
'default_partner_ids':active_ids,
'default_target_move': 'posted',
'default_result_selection': 'customer_supplier',
'default_reconciled': True,
'hide_partner':1,
}</field>
<field name="group_ids" eval="[(4, ref('account.group_account_invoice'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_account_report_pl" model="ir.actions.act_window">
<field name="name">Profit and Loss</field>
<field name="res_model">accounting.report</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="accounting_report_view"/>
<field name="target">new</field>
<field name="context" eval="{'default_account_report_id':ref('accounting_pdf_reports.account_financial_report_profitandloss0')}"/>
</record>
<menuitem id="menu_account_report_pl"
name="Profit and Loss"
sequence="6"
action="action_account_report_pl"
parent="accounting_pdf_reports.menu_finance_legal_statement"
groups="account.group_account_user,account.group_account_manager"/>
</odoo>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="accounting_tax_report_view" model="ir.ui.view">
<field name="name">Tax Reports</field>
<field name="model">account.tax.report.wizard</field>
<field name="inherit_id" eval="False"/>
<field name="arch" type="xml">
<form string="Report Options">
<group>
<group>
<field name="target_move" widget="radio"/>
<field name="date_from"/>
</group>
<group>
<field name="company_id" invisible="1"/>
<field name="date_to" />
</group>
</group>
<footer>
<button name="check_report" string="Print" type="object" default_focus="1" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="action_account_tax_report" model="ir.actions.act_window">
<field name="name">Tax Reports</field>
<field name="res_model">account.tax.report.wizard</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="view_id" ref="accounting_tax_report_view"/>
<field name="context">{}</field>
<field name="target">new</field>
</record>
<menuitem id="menu_account_report"
name="Tax Report"
sequence="30"
action="action_account_tax_report"
parent="menu_finance_audit_reports"
groups="account.group_account_manager,account.group_account_user"/>
</odoo>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_report_balance_view" model="ir.ui.view">
<field name="name">Trial Balance</field>
<field name="model">account.balance.report</field>
<field name="inherit_id" ref="accounting_pdf_reports.account_common_report_view"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='target_move']" position="after">
<field name="display_account" widget="radio"/>
<newline/>
</xpath>
<xpath expr="//field[@name='journal_ids']" position="after">
<field name="analytic_account_ids" widget="many2many_tags"
invisible="1"
options="{'no_open': True, 'no_create': True}"/>
</xpath>
</data>
</field>
</record>
<record id="action_account_balance_menu" model="ir.actions.act_window">
<field name="name">Trial Balance</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.balance.report</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_report_balance_view"/>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_account" />
<field name="binding_type">report</field>
</record>
<menuitem id="menu_general_balance_report"
name="Trial Balance"
sequence="20"
parent="menu_finance_audit_reports"
action="action_account_balance_menu"
groups="account.group_account_user,account.group_account_manager"/>
</odoo>

View File

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

View File

@@ -0,0 +1,151 @@
{
'name': 'Laundry Management',
'version': '19.0.19.0.4',
'summary': 'Laundry Management',
'description': 'Laundry Management',
'author': 'Laundry Management',
'category': 'Services',
'license': 'LGPL-3',
'depends': [
'base',
'mail',
'sale', # sale.order (legacy refs in commission/dashboard)
'account', # account.move, account.payment (legacy refs)
'product', # product.template, product.product
'base_setup', # res.config.settings integration
'sales_team', # group_sale_salesman, group_sale_manager
'point_of_sale', # pos.order ??? source of truth
],
'data': [
# 1. Security ??? groups must load before ACLs and rules
'security/res_groups.xml',
'security/ir_rule.xml',
'security/ir.model.access.csv',
# 2. Sequences
'data/sequence.xml',
# 3. Master data
'data/laundry_data.xml',
'data/service_catalog_data.xml',
# 4. Reports ??? must load before views that reference report actions
'report/laundry_order_report.xml',
'report/laundry_thermal_report.xml',
'report/laundry_work_order_report.xml',
# 5. Action-defining views ??? must load before menus + reporting,
# because both reference these actions by xml id.
'views/product_template_views.xml',
'views/laundry_order_type_views.xml',
'views/laundry_order_attribute_views.xml',
'views/laundry_order_views.xml',
'views/pos_order_views.xml',
'views/laundry_commission_views.xml',
# 6. Configuration views
'views/laundry_payment_method_views.xml',
'views/laundry_settings_views.xml',
'views/pos_config_views.xml',
# 7. Wizards (their actions are referenced from menus / forms)
'views/laundry_print_wizard_views.xml',
'wizard/laundry_order_unlock_wizard_views.xml',
# 8. MENUS ??? must load BEFORE any file that adds a child menu
# under menu_laundry_root (e.g. laundry_reporting_views.xml).
'views/laundry_menus.xml',
# 9. Reporting ??? adds child menus under menu_laundry_root
# (defined in the file above) and under core
# sale.menu_sale_report / account.menu_finance_reports.
'views/laundry_reporting_views.xml',
],
'assets': {
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
# POS asset bundle ??? EXPLICIT FILE LIST (no broad globs).
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
# Lists only the production-required POS workflow files.
# Suspect / experimental files are commented OUT individually
# rather than disabling the whole bundle. Toggle a single line
# to add or remove a feature.
#
# The defensive guard for the `doHaveConflictWith` crash lives
# in pos_store_patch.js ??? that crash is fixed without removing
# any feature.
# ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
'point_of_sale._assets_pos': [
# ?????? Styling (shared with backend kanban) ??????????????????????????????????????????????????????
'laundry_management/static/src/scss/laundry_pos.scss',
# ?????? Reactive store (must load first; other patches use it)
'laundry_management/static/src/js/laundry_context_store.js',
# ?????? Model + screen patches (workflow core) ????????????????????????????????????????????????
'laundry_management/static/src/js/pos_order_patch.js',
'laundry_management/static/src/js/pos_store_patch.js',
'laundry_management/static/src/js/payment_screen_patch.js',
'laundry_management/static/src/js/order_payment_validation.js',
# ?????? Settle-Due (production-required) ??????????????????????????????????????????????????????????????????
'laundry_management/static/src/js/settle_dues.js',
'laundry_management/static/src/js/settlement_receipt.js',
'laundry_management/static/src/js/laundry_settle_banner.js',
'laundry_management/static/src/js/closing_popup_patch.js',
# ?????? Customer Laundry Orders popup (production-required) ??????
'laundry_management/static/src/js/view_laundry_orders.js',
'laundry_management/static/src/js/quick_create_partner.js',
# ?????? Legacy order-type / attribute / delivery flow ???????????????????????????
'laundry_management/static/src/js/laundry_order_context_panel.js',
'laundry_management/static/src/js/popups/laundry_delivery_details_popup.js',
'laundry_management/static/src/js/popups/laundry_order_attribute_popup.js',
'laundry_management/static/src/js/popups/laundry_order_type_popup.js',
# ?????? Cashier UI helpers ????????????????????????????????????????????????????????????????????????????????????????????????????????????
'laundry_management/static/src/js/control_buttons_patch.js',
'laundry_management/static/src/js/navbar_patch.js',
'laundry_management/static/src/js/order_summary_patch.js',
'laundry_management/static/src/js/order_tabs_patch.js',
'laundry_management/static/src/js/ticket_screen_patch.js',
'laundry_management/static/src/js/order_receipt_patch.js',
'laundry_management/static/src/js/laundry_receipt_details.js',
'laundry_management/static/src/js/laundry_pricing_hook.js',
# ?????? XML templates for the JS files above ??????????????????????????????????????????????????????
'laundry_management/static/src/xml/closing_popup_ext.xml',
'laundry_management/static/src/xml/control_buttons.xml',
'laundry_management/static/src/xml/laundry_order_context_panel.xml',
'laundry_management/static/src/xml/laundry_settle_banner.xml',
'laundry_management/static/src/xml/order_summary_patch.xml',
'laundry_management/static/src/xml/partner_line.xml',
'laundry_management/static/src/xml/popups/laundry_delivery_details_popup.xml',
'laundry_management/static/src/xml/popups/laundry_order_attribute_popup.xml',
'laundry_management/static/src/xml/popups/laundry_order_type_popup.xml',
'laundry_management/static/src/xml/quick_create_partner.xml',
'laundry_management/static/src/xml/receipt.xml',
'laundry_management/static/src/xml/settle_dues.xml',
'laundry_management/static/src/xml/settlement_receipt.xml',
'laundry_management/static/src/xml/view_laundry_orders.xml',
# ?????? Improved laundry configurator UX (XML-only) ?????????????????????????????????
# Pure XML inheritance on Odoo's ProductConfiguratorPopup
# that adds two CSS classes to the Dialog's contentClass so
# the existing SCSS in laundry_pos.scss enhances the popup
# for laundry-flagged products only. NO JS override, NO
# logic change. The defensive doHaveConflictWith guard in
# pos_store_patch.js handles the data-shape edge case
# independently ??? re-enabling this is safe.
'laundry_management/static/src/xml/product_configurator_popup.xml',
# ?????? STILL EXCLUDED ??? recent / experimental ????????????????????????????????????????????????
# Thermal-receipt component is kept off until an explicit
# printer-side validation. PDF fallback remains the path.
# 'laundry_management/static/src/js/laundry_thermal_receipt.js',
# 'laundry_management/static/src/xml/laundry_thermal_receipt.xml',
],
'web.assets_backend': [
# SCSS shared with backend kanban / dashboard styling.
'laundry_management/static/src/scss/laundry_pos.scss',
],
},
'demo': [
'demo/demo.xml',
],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,349 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- ═══════════════════════════════════════════════════════════════
SETTLEMENT PRODUCT — used for collecting outstanding laundry dues.
Service type, no tax, NOT a laundry service (no laundry.order created).
The income account override happens in pos.order.line Python code
to use the partner's receivable account → no revenue recognised.
═══════════════════════════════════════════════════════════════ -->
<record id="product_laundry_settlement" model="product.product">
<field name="name">Laundry Settlement</field>
<field name="type">service</field>
<field name="lst_price">0.00</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="available_in_pos">True</field>
<field name="is_laundry_settlement">True</field>
<field name="is_laundry_service">False</field>
<field name="taxes_id" eval="[(5, 0, 0)]"/>
<field name="description_sale">Settlement of outstanding laundry dues.</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
PRODUCT CATEGORIES (Laundry Services tree)
═══════════════════════════════════════════════════════════════ -->
<record id="product_categ_laundry_root" model="product.category">
<field name="name">Laundry Services</field>
</record>
<record id="product_categ_washing" model="product.category">
<field name="name">Washing</field>
<field name="parent_id" ref="product_categ_laundry_root"/>
</record>
<record id="product_categ_dry_cleaning" model="product.category">
<field name="name">Dry Cleaning</field>
<field name="parent_id" ref="product_categ_laundry_root"/>
</record>
<record id="product_categ_ironing" model="product.category">
<field name="name">Ironing &amp; Pressing</field>
<field name="parent_id" ref="product_categ_laundry_root"/>
</record>
<record id="product_categ_special_care" model="product.category">
<field name="name">Special Care</field>
<field name="parent_id" ref="product_categ_laundry_root"/>
</record>
<record id="product_categ_express" model="product.category">
<field name="name">Express Service</field>
<field name="parent_id" ref="product_categ_laundry_root"/>
</record>
<!-- ═══════════════════════════════════════════════════════════════
LAUNDRY SERVICE PRODUCTS — WASHING
Using product.product so XML IDs are directly referenceable.
Template fields (name, categ_id, type, lst_price) are set via
product.product's delegation inheritance to product.template.
═══════════════════════════════════════════════════════════════ -->
<record id="svc_wash_shirt" model="product.product">
<field name="name">Shirt — Wash &amp; Iron</field>
<field name="type">service</field>
<field name="lst_price">5.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Standard wash and press for dress shirts and casual shirts.</field>
</record>
<record id="svc_wash_trousers" model="product.product">
<field name="name">Trousers — Wash &amp; Iron</field>
<field name="type">service</field>
<field name="lst_price">6.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_wash_tshirt" model="product.product">
<field name="name">T-Shirt / Polo — Wash</field>
<field name="type">service</field>
<field name="lst_price">4.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_wash_jeans" model="product.product">
<field name="name">Jeans — Wash</field>
<field name="type">service</field>
<field name="lst_price">7.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_wash_abaya" model="product.product">
<field name="name">Abaya — Wash &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">10.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_wash_thobe" model="product.product">
<field name="name">Thobe / Dishdasha — Wash &amp; Iron</field>
<field name="type">service</field>
<field name="lst_price">9.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_wash_blanket" model="product.product">
<field name="name">Blanket / Duvet — Wash</field>
<field name="type">service</field>
<field name="lst_price">18.00</field>
<field name="categ_id" ref="product_categ_washing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
LAUNDRY SERVICE PRODUCTS — DRY CLEANING
═══════════════════════════════════════════════════════════════ -->
<record id="svc_dc_suit" model="product.product">
<field name="name">Suit (2-Piece) — Dry Clean</field>
<field name="type">service</field>
<field name="lst_price">25.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Full dry cleaning for 2-piece suits.</field>
</record>
<record id="svc_dc_jacket" model="product.product">
<field name="name">Jacket / Blazer — Dry Clean</field>
<field name="type">service</field>
<field name="lst_price">15.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_dc_dress" model="product.product">
<field name="name">Dress / Gown — Dry Clean</field>
<field name="type">service</field>
<field name="lst_price">20.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_dc_abaya" model="product.product">
<field name="name">Abaya — Dry Clean &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">18.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_dc_thobe" model="product.product">
<field name="name">Thobe / Dishdasha — Dry Clean</field>
<field name="type">service</field>
<field name="lst_price">16.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_dc_wedding_dress" model="product.product">
<field name="name">Wedding Dress — Dry Clean &amp; Preserve</field>
<field name="type">service</field>
<field name="lst_price">60.00</field>
<field name="categ_id" ref="product_categ_dry_cleaning"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Premium dry cleaning and preservation for wedding dresses.</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
LAUNDRY SERVICE PRODUCTS — IRONING & PRESSING
═══════════════════════════════════════════════════════════════ -->
<record id="svc_iron_shirt" model="product.product">
<field name="name">Shirt — Iron &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">3.00</field>
<field name="categ_id" ref="product_categ_ironing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_iron_trousers" model="product.product">
<field name="name">Trousers — Iron &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">4.00</field>
<field name="categ_id" ref="product_categ_ironing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_iron_thobe" model="product.product">
<field name="name">Thobe / Dishdasha — Iron &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">5.00</field>
<field name="categ_id" ref="product_categ_ironing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_iron_suit" model="product.product">
<field name="name">Suit — Iron &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">8.00</field>
<field name="categ_id" ref="product_categ_ironing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_iron_dress" model="product.product">
<field name="name">Dress / Gown — Iron &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">7.00</field>
<field name="categ_id" ref="product_categ_ironing"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
LAUNDRY SERVICE PRODUCTS — SPECIAL CARE
═══════════════════════════════════════════════════════════════ -->
<record id="svc_special_carpet" model="product.product">
<field name="name">Carpet / Rug — Deep Clean</field>
<field name="type">service</field>
<field name="lst_price">35.00</field>
<field name="categ_id" ref="product_categ_special_care"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Deep steam cleaning for carpets and rugs.</field>
</record>
<record id="svc_special_curtain" model="product.product">
<field name="name">Curtain — Wash &amp; Press</field>
<field name="type">service</field>
<field name="lst_price">25.00</field>
<field name="categ_id" ref="product_categ_special_care"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_special_leather" model="product.product">
<field name="name">Leather Jacket — Clean &amp; Condition</field>
<field name="type">service</field>
<field name="lst_price">40.00</field>
<field name="categ_id" ref="product_categ_special_care"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
</record>
<record id="svc_special_stain" model="product.product">
<field name="name">Stain Treatment (per item)</field>
<field name="type">service</field>
<field name="lst_price">12.00</field>
<field name="categ_id" ref="product_categ_special_care"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Targeted stain removal treatment applied before washing.</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
LAUNDRY SERVICE PRODUCTS — EXPRESS SERVICE
═══════════════════════════════════════════════════════════════ -->
<record id="svc_express_4hr" model="product.product">
<field name="name">Express Turnaround (4-Hour)</field>
<field name="type">service</field>
<field name="lst_price">10.00</field>
<field name="categ_id" ref="product_categ_express"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Priority processing — ready within 4 hours.</field>
</record>
<record id="svc_express_sameday" model="product.product">
<field name="name">Same-Day Delivery Surcharge</field>
<field name="type">service</field>
<field name="lst_price">8.00</field>
<field name="categ_id" ref="product_categ_express"/>
<field name="is_laundry_service">True</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="description_sale">Add-on fee for same-day home delivery.</field>
</record>
<!-- ═══════════════════════════════════════════════════════════════
POLISHED EXAMPLE — Service Speed variant.
Demonstrates the cleanest workflow: ONE template, two timing
variants (Normal / Express). Cashier picks variant in POS.
Uses native product.attribute machinery.
═══════════════════════════════════════════════════════════════ -->
<record id="lm_attr_service_speed" model="product.attribute">
<field name="name">Service Speed</field>
<field name="display_type">radio</field>
<field name="create_variant">always</field>
</record>
<record id="lm_attr_speed_normal" model="product.attribute.value">
<field name="name">Normal</field>
<field name="attribute_id" ref="lm_attr_service_speed"/>
<field name="sequence">1</field>
<field name="default_extra_price">0.00</field>
</record>
<record id="lm_attr_speed_express" model="product.attribute.value">
<field name="name">Express</field>
<field name="attribute_id" ref="lm_attr_service_speed"/>
<field name="sequence">2</field>
<field name="default_extra_price">3.00</field>
</record>
<!-- Demo product removed; canonical catalog lives in
data/service_catalog_data.xml (Abaya / Thobe / T-Shirt). -->
</odoo>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Laundry Order sequence: LND/2025/04/0001 -->
<record id="seq_laundry_order" model="ir.sequence">
<field name="name">Laundry Order</field>
<field name="code">laundry.order</field>
<field name="prefix">LND/%(year)s/%(month)s/</field>
<field name="padding">4</field>
<field name="number_increment">1</field>
<field name="number_next">1</field>
<field name="use_date_range" eval="False"/>
<field name="company_id" eval="False"/>
</record>
<!-- Laundry Session sequence (kept for backward compat) -->
<record id="seq_laundry_session" model="ir.sequence">
<field name="name">Laundry Session</field>
<field name="code">laundry.session</field>
<field name="prefix">LS/%(year)s/%(month)s/</field>
<field name="padding">4</field>
<field name="number_increment">1</field>
<field name="number_next">1</field>
<field name="use_date_range" eval="False"/>
<field name="company_id" eval="False"/>
</record>
<!-- Per-item tracking code: LI-000001 (6-digit pad, no date).
Used as the scannable barcode on each laundry.order.line. -->
<record id="seq_laundry_order_line_tracking" model="ir.sequence">
<field name="name">Laundry Item Tracking Code</field>
<field name="code">laundry.order.line.tracking</field>
<field name="prefix">LI-</field>
<field name="padding">6</field>
<field name="number_increment">1</field>
<field name="number_next">1</field>
<field name="use_date_range" eval="False"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Placeholder to satisfy __manifest__.py.
Add service catalog seed data here later if needed. -->
</odoo>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Laundry orders themselves are created automatically from POS
(pos_order_id is required), so we don't seed laundry.order rows.
Below: a realistic demo service product with Normal / Express
variants so cashiers see something cleaner than a blank catalog. -->
<!-- ── Product attribute: Service Speed ─────────────────────────── -->
<record id="demo_attr_service_speed" model="product.attribute">
<field name="name">Service Speed</field>
<field name="display_type">radio</field>
<field name="create_variant">always</field>
<field name="sequence">10</field>
</record>
<record id="demo_attr_value_normal" model="product.attribute.value">
<field name="name">Normal</field>
<field name="attribute_id" ref="demo_attr_service_speed"/>
<field name="sequence">10</field>
</record>
<record id="demo_attr_value_express" model="product.attribute.value">
<field name="name">Express</field>
<field name="attribute_id" ref="demo_attr_service_speed"/>
<field name="default_extra_price">3.0</field>
<field name="sequence">20</field>
</record>
<!-- ── Demo product: T-Shirt / Polo Wash ───────────────────────── -->
<record id="demo_product_tshirt_wash" model="product.template">
<field name="name">T-Shirt / Polo Wash</field>
<field name="type">service</field>
<field name="sale_ok">True</field>
<field name="purchase_ok">False</field>
<field name="available_in_pos">True</field>
<field name="is_laundry_service">True</field>
<field name="list_price">8.0</field>
<field name="taxes_id" eval="[(5,)]"/>
<field name="description_sale">Wash + iron for tops. Express adds 3 SAR.</field>
</record>
<record id="demo_product_tshirt_wash_attr_line" model="product.template.attribute.line">
<field name="product_tmpl_id" ref="demo_product_tshirt_wash"/>
<field name="attribute_id" ref="demo_attr_service_speed"/>
<field name="value_ids" eval="[(6, 0, [
ref('demo_attr_value_normal'),
ref('demo_attr_value_express'),
])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
## Module <laundry_management>
#### 26.01.2026
#### Version 19.0.1.0.0
#### ADD
- Initial commit for Laundry Management

View File

@@ -0,0 +1,413 @@
# Arabic translation for laundry_management
# Copyright (C) 2026
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-01 00:00+0000\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: laundry_management
msgid "Laundry"
msgstr "المغسلة"
#. module: laundry_management
msgid "Laundry Management"
msgstr "إدارة المغسلة"
#. module: laundry_management
msgid "Laundry Order"
msgstr "طلب مغسلة"
#. module: laundry_management
msgid "Laundry Orders"
msgstr "طلبات المغسلة"
#. module: laundry_management
msgid "Orders"
msgstr "الطلبات"
#. module: laundry_management
msgid "All Orders"
msgstr "جميع الطلبات"
#. module: laundry_management
msgid "Operations Pipeline"
msgstr "خط سير العمليات"
#. module: laundry_management
msgid "Ready for Pickup"
msgstr "جاهز للاستلام"
#. module: laundry_management
msgid "Configuration"
msgstr "الإعدادات"
#. module: laundry_management
msgid "Service Categories"
msgstr "تصنيفات الخدمات"
#. module: laundry_management
msgid "Item Types"
msgstr "أنواع القطع"
#. module: laundry_management
msgid "Order No."
msgstr "رقم الطلب"
#. module: laundry_management
msgid "Customer"
msgstr "العميل"
#. module: laundry_management
msgid "Mobile"
msgstr "الجوال"
#. module: laundry_management
msgid "Order Date"
msgstr "تاريخ الطلب"
#. module: laundry_management
msgid "Expected Delivery"
msgstr "موعد التسليم المتوقع"
#. module: laundry_management
msgid "Order Type"
msgstr "نوع الطلب"
#. module: laundry_management
msgid "Walk-In / Drop-Off"
msgstr "مراجعة مباشرة"
#. module: laundry_management
msgid "We Pick Up"
msgstr "نستلم من عندك"
#. module: laundry_management
msgid "Home Delivery"
msgstr "توصيل للمنزل"
#. module: laundry_management
msgid "Priority"
msgstr "الأولوية"
#. module: laundry_management
msgid "Normal"
msgstr "عادي"
#. module: laundry_management
msgid "Urgent"
msgstr "عاجل"
#. module: laundry_management
msgid "Express"
msgstr "سريع جداً"
#. module: laundry_management
msgid "Status"
msgstr "الحالة"
#. module: laundry_management
msgid "Draft"
msgstr "مسودة"
#. module: laundry_management
msgid "Received"
msgstr "مستلم"
#. module: laundry_management
msgid "In Process"
msgstr "تحت المعالجة"
#. module: laundry_management
msgid "Ready"
msgstr "جاهز"
#. module: laundry_management
msgid "Handed Over"
msgstr "تم التسليم"
#. module: laundry_management
msgid "Cancelled"
msgstr "ملغي"
#. module: laundry_management
msgid "Confirm Receipt"
msgstr "تأكيد الاستلام"
#. module: laundry_management
msgid "Start Processing"
msgstr "بدء المعالجة"
#. module: laundry_management
msgid "Ready for Pickup"
msgstr "جاهز للاستلام"
#. module: laundry_management
msgid "Hand Over to Customer"
msgstr "تسليم للعميل"
#. module: laundry_management
msgid "Cancel Order"
msgstr "إلغاء الطلب"
#. module: laundry_management
msgid "Reopen"
msgstr "إعادة فتح"
#. module: laundry_management
msgid "Items & Services"
msgstr "القطع والخدمات"
#. module: laundry_management
msgid "Item"
msgstr "القطعة"
#. module: laundry_management
msgid "Item Type"
msgstr "نوع القطعة"
#. module: laundry_management
msgid "Service"
msgstr "الخدمة"
#. module: laundry_management
msgid "Category"
msgstr "التصنيف"
#. module: laundry_management
msgid "Color / Description"
msgstr "اللون / الوصف"
#. module: laundry_management
msgid "Stain / Damage"
msgstr "بقعة / ضرر"
#. module: laundry_management
msgid "Special Instructions"
msgstr "تعليمات خاصة"
#. module: laundry_management
msgid "Qty"
msgstr "الكمية"
#. module: laundry_management
msgid "Unit Price"
msgstr "سعر الوحدة"
#. module: laundry_management
msgid "Subtotal"
msgstr "المجموع الجزئي"
#. module: laundry_management
msgid "Total"
msgstr "الإجمالي"
#. module: laundry_management
msgid "Total Amount"
msgstr "المبلغ الإجمالي"
#. module: laundry_management
msgid "Items"
msgstr "القطع"
#. module: laundry_management
msgid "Payment"
msgstr "الدفع"
#. module: laundry_management
msgid "Unpaid"
msgstr "غير مدفوع"
#. module: laundry_management
msgid "Partial"
msgstr "مدفوع جزئياً"
#. module: laundry_management
msgid "Paid"
msgstr "مدفوع"
#. module: laundry_management
msgid "Invoice"
msgstr "الفاتورة"
#. module: laundry_management
msgid "Received By"
msgstr "استُلم بواسطة"
#. module: laundry_management
msgid "Handed Over By"
msgstr "سُلّم بواسطة"
#. module: laundry_management
msgid "Customer Notes"
msgstr "ملاحظات العميل"
#. module: laundry_management
msgid "Internal Notes"
msgstr "ملاحظات داخلية"
#. module: laundry_management
msgid "Processing Info"
msgstr "معلومات المعالجة"
#. module: laundry_management
msgid "Notes"
msgstr "الملاحظات"
#. module: laundry_management
msgid "Washing"
msgstr "غسيل"
#. module: laundry_management
msgid "Dry Cleaning"
msgstr "تنظيف جاف"
#. module: laundry_management
msgid "Ironing & Pressing"
msgstr "كوي وضغط"
#. module: laundry_management
msgid "Special Care"
msgstr "عناية خاصة"
#. module: laundry_management
msgid "Express Service"
msgstr "خدمة سريعة"
#. module: laundry_management
msgid "Shirt"
msgstr "قميص"
#. module: laundry_management
msgid "Trousers / Pants"
msgstr "بنطلون"
#. module: laundry_management
msgid "Suit (2-Piece)"
msgstr "بدلة (قطعتان)"
#. module: laundry_management
msgid "Abaya"
msgstr "عباية"
#. module: laundry_management
msgid "Thobe / Dishdasha"
msgstr "ثوب / دشداشة"
#. module: laundry_management
msgid "Dress / Gown"
msgstr "فستان / ثوب سهرة"
#. module: laundry_management
msgid "Jacket / Blazer"
msgstr "جاكيت / بليزر"
#. module: laundry_management
msgid "T-Shirt / Polo"
msgstr "تيشيرت / بولو"
#. module: laundry_management
msgid "Jeans"
msgstr "جينز"
#. module: laundry_management
msgid "Blanket / Duvet"
msgstr "بطانية / لحاف"
#. module: laundry_management
msgid "Carpet / Rug"
msgstr "سجادة / موكيت"
#. module: laundry_management
msgid "Curtain"
msgstr "ستارة"
#. module: laundry_management
msgid "Uniform / Workwear"
msgstr "يونيفورم / ملابس عمل"
#. module: laundry_management
msgid "Wedding Dress"
msgstr "فستان زفاف"
#. module: laundry_management
msgid "Other / Custom Item"
msgstr "أخرى / قطعة مخصصة"
#. module: laundry_management
msgid "Laundry Order Receipt"
msgstr "إيصال طلب المغسلة"
#. module: laundry_management
msgid "Search Orders"
msgstr "البحث في الطلبات"
#. module: laundry_management
msgid "Today"
msgstr "اليوم"
#. module: laundry_management
msgid "Pending"
msgstr "قيد التنفيذ"
#. module: laundry_management
msgid "Delivered"
msgstr "مسلّم"
#. module: laundry_management
msgid "Laundry Service Category"
msgstr "تصنيف خدمة المغسلة"
#. module: laundry_management
msgid "Laundry Item Type"
msgstr "نوع قطعة المغسلة"
#. module: laundry_management
msgid "Laundry Order Line"
msgstr "سطر طلب المغسلة"
#. module: laundry_management
msgid "Default Service"
msgstr "الخدمة الافتراضية"
#. module: laundry_management
msgid "Default Unit Price"
msgstr "السعر الافتراضي للوحدة"
#. module: laundry_management
msgid "Active"
msgstr "نشط"
# ── Reporting menus integrated into Sales/Accounting dashboards ──────
#. module: laundry_management
msgid "Laundry Orders Analysis"
msgstr "تحليل طلبات المغسلة"
#. module: laundry_management
msgid "Laundry Operations (Live)"
msgstr "عمليات المغسلة (مباشر)"
#. module: laundry_management
msgid "Laundry Invoices"
msgstr "فواتير المغسلة"
#. module: laundry_management
msgid "Reports"
msgstr "التقارير"
#. module: laundry_management
msgid "Orders Analysis"
msgstr "تحليل الطلبات"
#. module: laundry_management
msgid "Operations (Live)"
msgstr "العمليات (مباشر)"
#. module: laundry_management
msgid "Invoices Analysis"
msgstr "تحليل الفواتير"

View File

@@ -0,0 +1,228 @@
"""
Pre-migration script for laundry_management 19.0.11.0.0
Architecture change: standalone laundry.order / laundry.order.line / laundry.payment
models are replaced by _inherit = 'sale.order' / 'sale.order.line'.
This script runs BEFORE Odoo's model sync so that:
1. FK constraints from old wizard tables referencing laundry_order are dropped
2. Old wizard transient records are purged (they reference non-existent rows)
3. Stale ir.model.fields records pointing to old models are removed
4. Old ir.model entries are deleted (unblocking the ORM delete check)
5. Old physical tables are dropped
Without this, the ORM raises:
"Another model is using the record you are trying to delete.
Blocking model: Laundry Print Wizard (laundry.print.wizard),
Blocking field: order_id"
"""
import logging
_logger = logging.getLogger(__name__)
# Old standalone models being removed in this version
_OLD_MODELS = [
'laundry.order',
'laundry.order.line',
'laundry.payment',
'laundry.order.line.addon',
'laundry.register.payment.wizard',
'laundry.product.wizard',
'laundry.product.wizard.line',
'laundry.category',
'laundry.item.type',
]
# Physical tables that correspond to the old models
_OLD_TABLES = [
'laundry_order',
'laundry_order_line',
'laundry_payment',
'laundry_order_line_addon',
'laundry_register_payment_wizard',
'laundry_product_wizard',
'laundry_product_wizard_line',
'laundry_category',
'laundry_item_type',
]
# Transient/wizard tables that may hold rows with FK refs to laundry_order
_WIZARD_TABLES = [
'laundry_print_wizard',
'laundry_session_wizard',
'laundry_whatsapp_wizard',
'laundry_register_payment_wizard',
'laundry_product_wizard',
'laundry_product_wizard_line',
]
def _table_exists(cr, table):
cr.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = %s",
(table,),
)
return bool(cr.fetchone())
def _column_exists(cr, table, column):
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = %s AND column_name = %s",
(table, column),
)
return bool(cr.fetchone())
def _drop_fk_constraints_referencing(cr, referenced_table):
"""Drop all FK constraints in the DB that point at referenced_table."""
cr.execute(
"""
SELECT tc.table_name, tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
JOIN information_schema.table_constraints tc2
ON rc.unique_constraint_name = tc2.constraint_name
AND rc.unique_constraint_schema = tc2.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc2.table_name = %s
AND tc.table_schema = 'public'
""",
(referenced_table,),
)
rows = cr.fetchall()
for src_table, constraint_name in rows:
_logger.info(
'pre_migrate: dropping FK constraint %s on %s (referenced %s)',
constraint_name, src_table, referenced_table,
)
cr.execute(
'ALTER TABLE "%s" DROP CONSTRAINT IF EXISTS "%s"' %
(src_table, constraint_name)
)
def migrate(cr, version):
if not version:
# Fresh install — nothing to clean up
return
_logger.info('pre_migrate laundry_management %s → 19.0.11.0.0 : start', version)
# ── Step 1: Purge all transient wizard records ────────────────────────────
# TransientModel rows expire naturally but DB rows persist during upgrade;
# they hold FK refs that block constraint drops and table drops.
for tbl in _WIZARD_TABLES:
if _table_exists(cr, tbl):
_logger.info('pre_migrate: truncating wizard table %s', tbl)
cr.execute('TRUNCATE TABLE "%s" CASCADE' % tbl)
# ── Step 2: Drop FK constraints pointing at old tables ────────────────────
for tbl in _OLD_TABLES:
if _table_exists(cr, tbl):
_drop_fk_constraints_referencing(cr, tbl)
# Also drop FKs on wizard tables that point at laundry_order (the primary blocker)
# Example: laundry_print_wizard.order_id -> laundry_order.id
for wizard_tbl in _WIZARD_TABLES:
if not _table_exists(cr, wizard_tbl):
continue
# Find and drop any FK on this wizard table that references an old table
cr.execute(
"""
SELECT kcu.column_name, ccu.table_name AS foreign_table_name, tc.constraint_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name = %s
AND ccu.table_name = ANY(%s)
""",
(wizard_tbl, _OLD_TABLES),
)
for col, ref_tbl, constraint_name in cr.fetchall():
_logger.info(
'pre_migrate: dropping FK %s on %s.%s -> %s',
constraint_name, wizard_tbl, col, ref_tbl,
)
cr.execute(
'ALTER TABLE "%s" DROP CONSTRAINT IF EXISTS "%s"' %
(wizard_tbl, constraint_name)
)
# ── Step 3: Remove stale ir.model.fields that ref old models ─────────────
# These are the records that cause "Blocking model: laundry.print.wizard,
# Blocking field: order_id" — the field record itself still has
# relation = 'laundry.order', which makes the ORM think laundry.print.wizard
# still depends on laundry.order.
cr.execute(
"""
DELETE FROM ir_model_fields
WHERE relation = ANY(%s)
""",
(_OLD_MODELS,),
)
deleted = cr.rowcount
_logger.info('pre_migrate: deleted %d stale ir.model.fields rows', deleted)
# Also remove fields whose model itself is one of the old models
cr.execute(
"""
DELETE FROM ir_model_fields
WHERE model IN %s
""",
(tuple(_OLD_MODELS),),
)
_logger.info('pre_migrate: deleted %d ir.model.fields for old models', cr.rowcount)
# ── Step 4: Remove stale ir.model.fields_by_name cache ───────────────────
# In some Odoo versions there is a separate constraint/index table.
# Safe to attempt; ignore if table doesn't exist.
if _table_exists(cr, 'ir_model_constraint'):
cr.execute(
"""
DELETE FROM ir_model_constraint imc
USING ir_model im
WHERE imc.model = im.id
AND im.model = ANY(%s)
""",
(_OLD_MODELS,),
)
_logger.info('pre_migrate: removed %d ir.model.constraint rows', cr.rowcount)
if _table_exists(cr, 'ir_model_relation'):
cr.execute(
"""
DELETE FROM ir_model_relation imr
USING ir_model im
WHERE imr.model = im.id
AND im.model = ANY(%s)
""",
(_OLD_MODELS,),
)
_logger.info('pre_migrate: removed %d ir.model.relation rows', cr.rowcount)
# ── Step 5: Remove ir.model entries for the old models ───────────────────
cr.execute(
"DELETE FROM ir_model WHERE model = ANY(%s)",
(_OLD_MODELS,),
)
_logger.info('pre_migrate: deleted %d ir.model rows', cr.rowcount)
# ── Step 6: Drop old physical tables ─────────────────────────────────────
for tbl in reversed(_OLD_TABLES): # reverse to respect FK order
if _table_exists(cr, tbl):
_logger.info('pre_migrate: dropping table %s', tbl)
cr.execute('DROP TABLE IF EXISTS "%s" CASCADE' % tbl)
_logger.info('pre_migrate laundry_management: complete')

View File

@@ -0,0 +1,48 @@
"""
Pre-migration for laundry_management 19.0.12.0.0
Changes handled:
1. Commission states: 'paid' only → now 'pending', 'confirmed', 'paid'
Existing 'paid' rows remain 'paid'. Existing 'pending' rows remain 'pending'.
'confirmed' is a new state — no existing rows use it, so no data migration needed.
2. New model: laundry.payment.wizard — just a new table, nothing to clean.
3. New groups: group_laundry_operator, group_laundry_cashier added to hierarchy.
Existing users with group_laundry_user keep all their permissions (implied).
4. Access control: new CSV entries for payment wizard and new groups.
Handled automatically by Odoo on upgrade.
No destructive operations needed in this migration.
The pre_migrate script from 19.0.11.0.0 already cleaned the legacy tables.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install
_logger.info('pre_migrate laundry_management 19.0.12.0.0: checking commission states')
# Ensure commission state column allows new 'confirmed' value.
# In Odoo, Selection fields are stored as VARCHAR — no schema change needed.
# Just verify the column exists and log the existing state distribution.
cr.execute("""
SELECT state, COUNT(*) FROM laundry_commission
GROUP BY state
ORDER BY state
""")
rows = cr.fetchall()
if rows:
_logger.info(
'pre_migrate: commission state distribution: %s',
{state: count for state, count in rows}
)
else:
_logger.info('pre_migrate: laundry_commission table is empty — fresh data')
_logger.info('pre_migrate laundry_management 19.0.12.0.0: complete')

View File

@@ -0,0 +1,103 @@
"""Pre-migration: 19.0.12.0.0 → 19.0.13.0.0
Removes three models that are being retired in this version:
- laundry.product.wizard (replaced by native sale.order.line product selection)
- laundry.product.wizard.line (child of above)
- laundry.whatsapp.wizard (replaced by one-click wa.me URL action)
If these ir.model records are left in the DB while the Python classes no longer
exist, Odoo will log warnings or fail on field-level checks during upgrade.
We clean them here, before the ORM loads.
"""
import logging
_logger = logging.getLogger(__name__)
_REMOVED_MODELS = [
'laundry.product.wizard',
'laundry.product.wizard.line',
'laundry.whatsapp.wizard',
]
_REMOVED_TABLES = [
'laundry_product_wizard',
'laundry_product_wizard_line',
'laundry_whatsapp_wizard',
]
def migrate(cr, version):
if not version:
return
_logger.info('pre_migrate 19.0.13.0.0: cleaning retired wizard models %s', _REMOVED_MODELS)
# 1. Drop physical tables (TransientModels do have real tables in Odoo)
for tbl in _REMOVED_TABLES:
cr.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
_logger.info(' dropped table: %s', tbl)
# 2. Remove ir.model.access entries for these models
cr.execute("""
DELETE FROM ir_model_access
WHERE model_id IN (
SELECT id FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
_logger.info(' deleted %d ir.model.access rows', cr.rowcount)
# 3. Remove ir.rule entries
cr.execute("""
DELETE FROM ir_rule
WHERE model_id IN (
SELECT id FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
# 4. Remove ir.model.fields for these models
cr.execute("""
DELETE FROM ir_model_fields
WHERE model_id IN (
SELECT id FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
_logger.info(' deleted ir.model.fields rows')
# 5. Remove ir.model.constraint
cr.execute("""
DELETE FROM ir_model_constraint
WHERE model_id IN (
SELECT id FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
# 6. Remove ir.model.relation
cr.execute("""
DELETE FROM ir_model_relation
WHERE model_id IN (
SELECT id FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
# 7. Remove ir.model.data (XML IDs) for these models
cr.execute("""
DELETE FROM ir_model_data
WHERE model = 'ir.model' AND name IN (
SELECT REPLACE(model, '.', '_') || '_' || id::text
FROM ir_model WHERE model = ANY(%s)
)
""", (_REMOVED_MODELS,))
# Also delete by res_id
cr.execute("""
DELETE FROM ir_model_data
WHERE model = 'ir.model'
AND res_id IN (SELECT id FROM ir_model WHERE model = ANY(%s))
""", (_REMOVED_MODELS,))
# 8. Finally remove ir.model entries themselves
cr.execute("""
DELETE FROM ir_model WHERE model = ANY(%s)
""", (_REMOVED_MODELS,))
_logger.info(' deleted ir.model entries for retired wizards')
_logger.info('pre_migrate 19.0.13.0.0: complete')

View File

@@ -0,0 +1,75 @@
"""Phase 1 financial model migration.
Before: laundry_order.amount_paid was blindly copied from pos_order.amount_paid,
which includes Customer Account / pay-later payments. Every deferred sale
appeared fully paid → amount_due was always 0 → Settle Dues was non-functional.
After: amount_paid_cash + amount_deferred are computed from pos_payment rows,
classified by pos_payment_method.split_transactions. amount_due is recomputed
as amount_deferred - amount_settled.
This script rebuilds the split for every existing laundry.order by replaying
its linked pos_order's payment history.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
_logger.info('laundry_management: rebuilding financial split for existing orders')
# Ensure the new columns exist (ORM will have added them, but be defensive)
cr.execute("""
ALTER TABLE laundry_order
ADD COLUMN IF NOT EXISTS amount_paid_cash numeric DEFAULT 0,
ADD COLUMN IF NOT EXISTS amount_deferred numeric DEFAULT 0;
""")
# Rebuild amount_paid_cash / amount_deferred per laundry order
# from pos_payment rows classified by pos_payment_method.split_transactions.
cr.execute("""
WITH classified AS (
SELECT
lo.id AS lo_id,
COALESCE(SUM(CASE WHEN pm.split_transactions = FALSE THEN pp.amount ELSE 0 END), 0) AS cash,
COALESCE(SUM(CASE WHEN pm.split_transactions = TRUE THEN pp.amount ELSE 0 END), 0) AS deferred
FROM laundry_order lo
LEFT JOIN pos_payment pp ON pp.pos_order_id = lo.pos_order_id
LEFT JOIN pos_payment_method pm ON pm.id = pp.payment_method_id
GROUP BY lo.id
)
UPDATE laundry_order lo
SET amount_paid_cash = c.cash,
amount_deferred = c.deferred,
amount_settled = COALESCE(lo.amount_settled, 0)
FROM classified c
WHERE c.lo_id = lo.id;
""")
# amount_due is a stored compute (amount_deferred - amount_settled).
# Populate it directly here so the values are correct before the ORM
# recomputes (ORM recompute on install will overwrite with same result).
cr.execute("""
UPDATE laundry_order
SET amount_due = GREATEST(
COALESCE(amount_deferred, 0) - COALESCE(amount_settled, 0),
0
);
""")
cr.execute("""
SELECT COUNT(*),
SUM(amount_paid_cash),
SUM(amount_deferred),
SUM(amount_due)
FROM laundry_order
""")
row = cr.fetchone()
_logger.info(
'laundry_management migration: %s orders — cash=%.2f, deferred=%.2f, due=%.2f',
row[0] or 0, row[1] or 0.0, row[2] or 0.0, row[3] or 0.0,
)

View File

@@ -0,0 +1,16 @@
from . import product_template_ext # adds is_laundry_service to product.template
from . import laundry_order_type # standalone: laundry.order.type
from . import laundry_order_attribute # standalone: laundry.order.attribute
from . import laundry_order # standalone: laundry.order
from . import laundry_order_line # standalone: laundry.order.line
from . import pos_order # extends pos.order with sync_from_ui hook
from . import pos_config_ext # extends pos.config with laundry-pos settings
from . import res_partner # extends res.partner with laundry unpaid count
from . import laundry_order_line_addon # add-on services per order line
from . import laundry_commission # standalone: commission tracking
# NOTE: laundry_dashboard removed — depends on laundry.session (POS-owned)
from . import laundry_payment_method # standalone: configurable payment methods
from . import laundry_settings # extends res.config.settings
from . import account_payment_ext # stamps pos_session_id on settlement payments
from . import pos_session_ext # ships new POS models to the client
# NOTE: laundry_session and account_move removed — session/accounting is POS-owned

View File

@@ -0,0 +1,21 @@
from odoo import models, fields, api
class AccountMoveLaundryExt(models.Model):
"""Flag invoices originating from laundry orders."""
_inherit = 'account.move'
is_laundry_invoice = fields.Boolean(
string='Laundry Invoice',
compute='_compute_is_laundry_invoice',
store=True,
)
@api.depends('invoice_line_ids.sale_line_ids.order_id.is_laundry_order')
def _compute_is_laundry_invoice(self):
for move in self:
move.is_laundry_invoice = any(
sol.order_id.is_laundry_order
for line in move.invoice_line_ids
for sol in line.sale_line_ids
)

View File

@@ -0,0 +1,22 @@
from odoo import models, fields
class AccountPaymentLaundryExt(models.Model):
"""Informational stamp — links settlement payments to the POS session
that was open when the cashier collected the money.
This field is purely for visibility (closing-screen summary). It does
NOT inject settlement totals into POS cash-control math.
"""
_inherit = 'account.payment'
pos_session_id = fields.Many2one(
'pos.session', string='POS Session',
readonly=True, copy=False, index=True,
help='POS session that was open when this settlement was created.',
)
settlement_pos_pm_id = fields.Many2one(
'pos.payment.method', string='Settlement Payment Method',
readonly=True, copy=False, index=True,
help='Original POS payment method chosen by the cashier during settlement.',
)

View File

@@ -0,0 +1,125 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class LaundryCommission(models.Model):
"""Staff commission tracking (PART 3).
States:
pending — auto-created when order progresses; awaiting manager review
confirmed — manager has verified and approved the commission
paid — commission has been settled/paid to the staff member
The commission_account_id (from settings) is informational for now.
Managers can bulk-confirm and bulk-mark-paid from the list view.
"""
_name = 'laundry.commission'
_description = 'Laundry Staff Commission'
_inherit = ['mail.thread']
_order = 'date desc, id desc'
name = fields.Char(
string='Reference',
compute='_compute_name', store=True, readonly=True,
)
order_id = fields.Many2one(
'sale.order', string='Order',
required=True, ondelete='cascade', index=True,
)
company_id = fields.Many2one(
'res.company',
related='order_id.company_id', store=True, index=True,
)
employee_id = fields.Many2one(
'res.users', string='Staff Member',
required=True, domain=[('share', '=', False)],
tracking=True,
)
role = fields.Selection([
('reception', 'Reception / Intake'),
('processing', 'Processing / Cleaning'),
('delivery', 'Delivery / Handover'),
], string='Role', required=True, tracking=True)
commission_type = fields.Selection([
('percentage', 'Percentage (%)'),
('fixed', 'Fixed Amount'),
], string='Type', required=True, default='percentage')
rate = fields.Float(string='Rate / Amount', digits=(10, 2))
base_amount = fields.Float(string='Order Total', digits=(10, 2))
commission_amount = fields.Float(
string='Commission',
compute='_compute_commission_amount', store=True, digits=(10, 2),
)
date = fields.Date(string='Date', required=True, default=fields.Date.today)
state = fields.Selection([
('pending', 'Pending'),
('confirmed', 'Confirmed'),
('paid', 'Paid'),
], default='pending', required=True, tracking=True, copy=False, index=True)
notes = fields.Text(string='Notes')
_ROLE_LABELS = {
'reception': 'Reception',
'processing': 'Processing',
'delivery': 'Delivery',
}
@api.depends('order_id', 'order_id.name', 'role')
def _compute_name(self):
for rec in self:
order = rec.order_id.name or 'NEW'
role = self._ROLE_LABELS.get(rec.role, rec.role or '')
rec.name = f'COM/{order}/{role}'
@api.depends('commission_type', 'rate', 'base_amount')
def _compute_commission_amount(self):
for rec in self:
if rec.commission_type == 'percentage':
rec.commission_amount = rec.base_amount * rec.rate / 100.0
else:
rec.commission_amount = rec.rate
# ── State transitions ─────────────────────────────────────────────
def action_confirm(self):
"""Manager confirms commission is valid and approved."""
for rec in self:
if rec.state != 'pending':
raise UserError(
f'"{rec.name}" cannot be confirmed — current state: {rec.state}.'
)
rec.write({'state': 'confirmed'})
rec.message_post(
body=f'Commission confirmed by {self.env.user.name}. '
f'Amount: {rec.commission_amount:.2f}'
)
def action_mark_paid(self):
"""Mark commission as settled/paid to staff."""
for rec in self:
if rec.state == 'paid':
raise UserError(f'"{rec.name}" is already paid.')
if rec.state == 'pending':
# Allow paying directly from pending (manager shortcut)
rec.write({'state': 'paid'})
else:
rec.write({'state': 'paid'})
rec.message_post(
body=f'Marked as paid by {self.env.user.name}.'
)
def action_reset_pending(self):
"""Reset commission back to pending (manager only)."""
for rec in self:
if rec.state == 'paid':
raise UserError(
f'Cannot reset "{rec.name}" — it has already been paid.'
)
rec.write({'state': 'pending'})
rec.message_post(
body=f'Reset to pending by {self.env.user.name}.'
)

View File

@@ -0,0 +1,180 @@
from odoo import models, fields, api
class LaundryDashboard(models.TransientModel):
"""Live KPI dashboard — queries sale.order with is_laundry_order = True."""
_name = 'laundry.dashboard'
_description = 'Laundry Dashboard'
today_orders = fields.Integer(string="Today's Orders")
today_revenue = fields.Monetary(string="Today's Revenue", currency_field='currency_id')
today_collected = fields.Monetary(string='Collected Today', currency_field='currency_id')
today_outstanding = fields.Monetary(string='Outstanding Today', currency_field='currency_id')
pending_count = fields.Integer(string='Pending Orders')
ready_count = fields.Integer(string='Ready for Pickup')
in_progress_count = fields.Integer(string='In Processing')
draft_count = fields.Integer(string='Quotes / Draft')
session_is_open = fields.Boolean(string='Session Open')
session_name = fields.Char(string='Session')
session_opening_cash = fields.Monetary(string='Opening Float', currency_field='currency_id')
session_sales = fields.Monetary(string='Session Sales', currency_field='currency_id')
session_cash = fields.Monetary(string='Session Cash', currency_field='currency_id')
session_bank = fields.Monetary(string='Session Bank', currency_field='currency_id')
session_id = fields.Many2one('laundry.session', string='Session Link')
month_orders = fields.Integer(string='Orders This Month')
month_revenue = fields.Monetary(string='Revenue This Month', currency_field='currency_id')
month_paid = fields.Monetary(string='Collected This Month', currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
@api.model
def _build(self):
today = fields.Date.today()
month_start = today.replace(day=1)
company = self.env.company
Order = self.env['sale.order']
Payment = self.env['account.payment']
_base_domain = [
('is_laundry_order', '=', True),
('company_id', '=', company.id),
]
# ── Today ──────────────────────────────────────────────────────
today_orders = Order.search(_base_domain + [
('date_order', '>=', fields.Datetime.to_datetime(today)),
('state', 'not in', ['cancel', 'draft']),
])
today_invoices = today_orders.mapped('invoice_ids').filtered(
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
)
today_revenue = sum(today_orders.mapped('amount_total'))
today_outstanding = sum(
max(i.amount_residual, 0.0) for i in today_invoices
)
today_collected = today_revenue - today_outstanding
# ── Pipeline (all active laundry orders) ──────────────────────
pipeline = Order.search(_base_domain + [
('state', '=', 'sale'),
])
pending_count = len(pipeline)
ready_count = len(pipeline.filtered(lambda o: o.laundry_state == 'ready'))
in_progress_count = len(pipeline.filtered(lambda o: o.laundry_state == 'processing'))
draft_count = len(Order.search(_base_domain + [('state', '=', 'draft')]))
# ── Session ────────────────────────────────────────────────────
session = self.env['laundry.session'].search([
('state', '=', 'opened'),
('company_id', '=', company.id),
], limit=1)
# ── Month ──────────────────────────────────────────────────────
month_orders = Order.search(_base_domain + [
('date_order', '>=', fields.Datetime.to_datetime(month_start)),
('state', 'not in', ['cancel', 'draft']),
])
month_invoices = month_orders.mapped('invoice_ids').filtered(
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
)
month_revenue = sum(month_orders.mapped('amount_total'))
month_outstanding = sum(max(i.amount_residual, 0.0) for i in month_invoices)
month_paid = month_revenue - month_outstanding
return self.create({
'today_orders' : len(today_orders),
'today_revenue' : today_revenue,
'today_collected' : max(today_collected, 0.0),
'today_outstanding' : today_outstanding,
'pending_count' : pending_count,
'ready_count' : ready_count,
'in_progress_count' : in_progress_count,
'draft_count' : draft_count,
'session_is_open' : bool(session),
'session_name' : session.name if session else '',
'session_opening_cash' : session.opening_cash if session else 0.0,
'session_sales' : session.total_sales if session else 0.0,
'session_cash' : session.total_cash if session else 0.0,
'session_bank' : session.total_bank if session else 0.0,
'session_id' : session.id if session else False,
'month_orders' : len(month_orders),
'month_revenue' : month_revenue,
'month_paid' : max(month_paid, 0.0),
})
@api.model
def action_open_dashboard(self):
rec = self._build()
return {
'type': 'ir.actions.act_window',
'name': 'Dashboard',
'res_model': 'laundry.dashboard',
'res_id': rec.id,
'view_mode': 'form',
'target': 'main',
'flags': {'mode': 'readonly'},
}
def action_refresh(self):
return self.action_open_dashboard()
def action_new_order(self):
return {
'type': 'ir.actions.act_window',
'name': 'New Laundry Order',
'res_model': 'sale.order',
'view_mode': 'form',
'target': 'current',
'context': {
'default_is_laundry_order': True,
},
}
def action_open_session(self):
if self.session_id:
return {
'type': 'ir.actions.act_window',
'name': 'Session',
'res_model': 'laundry.session',
'res_id': self.session_id.id,
'view_mode': 'form',
}
return {
'type': 'ir.actions.act_window',
'name': 'Sessions',
'res_model': 'laundry.session',
'view_mode': 'list,form',
}
def action_new_session(self):
return {
'type': 'ir.actions.act_window',
'name': 'New Session',
'res_model': 'laundry.session',
'view_mode': 'form',
}
def action_view_ready_orders(self):
return {
'type': 'ir.actions.act_window',
'name': 'Ready for Pickup',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('is_laundry_order', '=', True), ('laundry_state', '=', 'ready')],
}
def action_view_pending_orders(self):
return {
'type': 'ir.actions.act_window',
'name': 'Pending Orders',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('is_laundry_order', '=', True), ('state', '=', 'sale'),
('laundry_state', 'not in', ['delivered'])],
}

View File

@@ -0,0 +1,765 @@
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
STATES = [
('intake', 'Intake'),
('processing', 'Processing'),
('ready', 'Ready for Pickup'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
]
STATE_KEYS = [s[0] for s in STATES]
FINAL_STATES = {'delivered', 'cancelled'}
SOURCE_TYPES = [
('pos', 'Point of Sale'),
('manual', 'Manual / Backoffice'),
]
# Header fields the lock protects. NOT included on purpose:
# - state (workflow advance is always allowed via dedicated actions)
# - amount_settled (settlement engine writes after lock)
# - notes (managerial commentary always allowed)
# - manager_unlocked_* (the unlock wizard writes these)
# - tracking_enabled (Phase-4 prep, manager configuration)
LOCKED_HEADER_FIELDS = frozenset({
'partner_id',
'amount_total', 'amount_paid_cash', 'amount_deferred',
'order_type_id', 'attribute_ids',
'is_delivery', 'delivery_address', 'delivery_scheduled_at',
'priority_level',
'pos_order_id', 'pos_reference',
'source_type', 'name', 'company_id',
})
# Sentinel context flag set by the POS sync hook (and any other automated
# server-side path) to allow the create + write that bring a fresh
# laundry.order to life. Without this flag, locked orders refuse mutations.
POS_SYNC_CTX = 'laundry_pos_sync'
class LaundryOrder(models.Model):
"""Standalone laundry order — created from POS.
POS owns payments/sessions/accounting. This model handles operational
workflow only: intake -> processing -> ready -> delivered.
Financial model (Phase 1 fix):
amount_total = mirror of pos.order.amount_total
amount_paid_cash = real money collected at origin (cash/card)
amount_deferred = Customer Account / pay-later amount at origin
amount_settled = money collected later via settlement engine
amount_due = amount_deferred - amount_settled
`amount_due > 0` means the customer still owes real money.
"""
_name = 'laundry.order'
_description = 'Laundry Order'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
name = fields.Char(
string='Order No.',
required=True, copy=False, readonly=True,
default='New', tracking=True,
)
# -- POS link --
# Optional: manual/backoffice orders have no POS origin. The uniqueness
# constraint below still enforces "one laundry order per pos.order" for
# POS-sourced rows.
pos_order_id = fields.Many2one(
'pos.order', string='POS Order',
index=True, readonly=True, copy=False,
ondelete='restrict',
)
pos_reference = fields.Char(
string='POS Reference',
readonly=True, copy=False, index=True,
)
# -- Customer --
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, index=True, tracking=True,
)
partner_phone = fields.Char(
related='partner_id.phone', string='Phone', readonly=True,
)
# -- Company / Currency --
company_id = fields.Many2one(
'res.company', string='Company',
required=True, default=lambda self: self.env.company,
index=True,
)
currency_id = fields.Many2one(
related='company_id.currency_id', store=True,
)
# -- Workflow --
state = fields.Selection(
STATES,
string='Status',
default='intake',
required=True,
tracking=True,
copy=False,
index=True,
)
# -- Lines --
line_ids = fields.One2many(
'laundry.order.line', 'order_id', string='Order Lines',
)
# -- Financial snapshot --
amount_total = fields.Monetary(
string='Total',
currency_field='currency_id',
readonly=True, copy=False, store=True,
)
amount_paid_cash = fields.Monetary(
string='Paid (Cash/Card)',
currency_field='currency_id',
readonly=True, copy=False, store=True,
default=0.0,
help='Amount collected at origin via non-deferred payment methods '
'(cash, card — any pos.payment.method with split_transactions=False).',
)
amount_deferred = fields.Monetary(
string='Deferred',
currency_field='currency_id',
readonly=True, copy=False, store=True,
default=0.0,
help='Amount deferred at origin via Customer Account / pay-later '
'(any pos.payment.method with split_transactions=True).',
)
amount_settled = fields.Monetary(
string='Settled',
currency_field='currency_id',
readonly=True, copy=False, store=True,
default=0.0,
help='Amount collected later via the settlement engine '
'(account.payment + reconciliation).',
)
amount_due = fields.Monetary(
string='Due',
currency_field='currency_id',
compute='_compute_amount_due',
store=True,
help='Remaining balance the customer owes: amount_deferred - amount_settled.',
)
# Back-compat alias — views/reports still reference `amount_paid`.
# Computed, non-stored; reflects real cash collected at origin.
amount_paid = fields.Monetary(
string='Paid',
currency_field='currency_id',
compute='_compute_amount_paid_alias',
)
# -- Computed --
item_count = fields.Integer(
string='Items',
compute='_compute_item_count',
store=True,
)
# -- Order type / attributes / delivery --
order_type_id = fields.Many2one(
'laundry.order.type', string='Order Type',
index=True, tracking=True,
)
attribute_ids = fields.Many2many(
'laundry.order.attribute',
'laundry_order_attribute_rel',
'order_id', 'attribute_id',
string='Attributes',
)
is_delivery = fields.Boolean(string='Delivery', tracking=True, index=True)
delivery_address = fields.Text(string='Delivery Address')
delivery_scheduled_at = fields.Datetime(string='Scheduled At')
priority_level = fields.Selection([
('normal', 'Normal'),
('urgent', 'Urgent'),
], string='Priority', default='normal', tracking=True, index=True)
# -- Notes --
notes = fields.Text(string='Notes')
# ── Source / locking (Phase 3) ────────────────────────────────────
# source_type is the truth-bearing identity. is_from_pos is a stored
# mirror used in domains, list filters, and rule conditions where a
# selection field would be awkward.
source_type = fields.Selection(
SOURCE_TYPES,
string='Source',
required=True,
default='manual',
readonly=True,
copy=False,
index=True,
tracking=True,
help='POS-sourced orders are hard-locked: lines, prices and the '
'customer cannot be edited unless a manager grants a '
'temporary unlock window. Manual orders are editable until '
'they reach a final state (delivered / cancelled).',
)
is_from_pos = fields.Boolean(
string='From POS',
compute='_compute_is_from_pos',
store=True, index=True,
)
# Phase-4 prep — flag only, no logic wired yet.
tracking_enabled = fields.Boolean(
string='Per-Item Tracking',
default=False,
copy=False,
help='When enabled, each laundry.order.line will be advanced '
'through its own state machine. Phase 4 wires the '
'synchronization between order state and item state.',
)
# Computed: True when the order refuses mutation of LOCKED_HEADER_FIELDS
# and any line write/create/unlink. Not stored — cheap to recompute and
# depends on a Datetime that ages out without a write.
locked = fields.Boolean(
string='Locked',
compute='_compute_locked',
help='Order is read-only when True. POS-sourced orders are '
'always locked. Final-state orders (delivered, cancelled) '
'are always locked. Managers can grant a temporary unlock '
'window via the "Unlock for Editing" action.',
)
manager_unlocked_until = fields.Datetime(
string='Unlock Window Expires',
copy=False, readonly=True,
help='When set in the future, the lock guard is suspended. '
'Auto-expires; no manual re-lock required.',
)
manager_unlocked_by = fields.Many2one(
'res.users', string='Last Unlocked By',
copy=False, readonly=True,
)
manager_unlock_reason = fields.Char(
string='Last Unlock Reason',
copy=False, readonly=True,
)
# Stamped when the order moves to delivered. Powers the avg-processing
# and on-time KPIs on the Operations Dashboard. Outside
# LOCKED_HEADER_FIELDS so action_deliver can write it on POS-locked
# orders without needing the bypass context.
delivered_at = fields.Datetime(
string='Delivered At',
readonly=True, copy=False, index=True,
help='Timestamp set automatically when the order moves to '
'Delivered. Used by the analytics dashboard to compute '
'processing time and on-time delivery percentage.',
)
# -- Constraints --
_pos_order_uniq = models.Constraint(
'UNIQUE(pos_order_id)',
'A laundry order already exists for this POS order.',
)
@api.constrains('source_type', 'pos_order_id')
def _check_source_type_consistency(self):
for order in self:
if order.source_type == 'pos' and not order.pos_order_id:
raise UserError(_(
'Order "%s" is marked as POS-sourced but has no '
'linked POS order.', order.name or order.id,
))
if order.source_type == 'manual' and order.pos_order_id:
raise UserError(_(
'Order "%s" is marked as manual but has a linked '
'POS order. Set source_type="pos" to keep them '
'consistent.', order.name or order.id,
))
# -- Computed --
@api.depends('amount_deferred', 'amount_settled')
def _compute_amount_due(self):
for order in self:
order.amount_due = max(
(order.amount_deferred or 0.0) - (order.amount_settled or 0.0),
0.0,
)
@api.depends('amount_paid_cash')
def _compute_amount_paid_alias(self):
for order in self:
order.amount_paid = order.amount_paid_cash or 0.0
@api.depends('source_type')
def _compute_is_from_pos(self):
for order in self:
order.is_from_pos = order.source_type == 'pos'
@api.depends('source_type', 'state', 'manager_unlocked_until')
def _compute_locked(self):
now = fields.Datetime.now()
for order in self:
unlock_active = bool(
order.manager_unlocked_until
and order.manager_unlocked_until > now
)
base_locked = (
order.source_type == 'pos'
or order.state in FINAL_STATES
)
order.locked = base_locked and not unlock_active
# ── Lock enforcement helpers ──────────────────────────────────────
def _is_pos_sync(self):
"""True when the call originates from the POS sync hook (or any
explicit server path that opts in via the context flag).
Both create() and write() honour this so the bridge from
pos.order can build / refresh the laundry.order without fighting
its own lock guard. This is checked BEFORE anything else in
write() so indirect POS writes (stored-compute flushes, cascades)
can never raise from the guard.
"""
return bool(self.env.context.get(POS_SYNC_CTX))
def _check_lock_for_write(self, vals):
"""Raise UserError when `vals` would mutate a protected header
field on a currently-locked order. Workflow advances (state,
amount_settled, notes, manager_unlocked_*) are excluded by
whitelist (LOCKED_HEADER_FIELDS).
Note: the POS-sync bypass is already applied at the top of
`write()` — this helper is only invoked for non-bypassed paths.
"""
protected = LOCKED_HEADER_FIELDS.intersection(vals.keys())
if not protected:
return
for order in self:
if order.locked:
raise UserError(_(
'Order "%(name)s" is locked. Editable fields: state '
'transitions, internal notes, settlement amount.\n'
'To edit %(fields)s, ask a manager to use '
'"Unlock for Editing" first.',
name=order.name,
fields=', '.join(sorted(protected)),
))
def _check_lock_for_unlink(self):
"""POS-sourced and final-state orders cannot be unlinked. The
manager unlock wizard is intentionally NOT honored here — deletion
requires a stronger affordance (cancellation + audit trail), not
a temporary edit window.
"""
for order in self:
if order.source_type == 'pos':
raise UserError(_(
'Order "%(name)s" was created from POS and cannot '
'be deleted. Cancel the underlying POS order instead.',
name=order.name,
))
if order.state in FINAL_STATES:
raise UserError(_(
'Order "%(name)s" is in a final state (%(state)s) '
'and cannot be deleted.',
name=order.name,
state=dict(STATES).get(order.state, order.state),
))
@api.depends('line_ids.qty')
def _compute_item_count(self):
for order in self:
order.item_count = int(sum(order.line_ids.mapped('qty')))
# -- ORM --
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = (
self.env['ir.sequence'].next_by_code('laundry.order')
or 'New'
)
self._apply_type_attribute_inference(vals)
return super().create(vals_list)
def write(self, vals):
# STEP 1 — POS sync bypass.
# Must be the very first thing we do. Any code path that opts
# into the context flag (POS create/sync, settlement engine,
# stored-compute flushes triggered from inside a bypassed write)
# MUST sail through unconditionally. No locking check, no
# side-effects, no iteration over self — just delegate to super.
# This guarantee is what makes POS payment/settlement flows
# immune to this model's lock guard.
if self._is_pos_sync():
if _logger.isEnabledFor(logging.DEBUG):
_logger.debug(
'laundry.order.write BYPASS ids=%s keys=%s',
self.ids, list(vals.keys()),
)
return super().write(vals)
# STEP 2 — Forensic trace (DEBUG-level; off by default in prod,
# enable with `--log-level=debug` or `--log-handler=odoo.addons
# .laundry_management.models.laundry_order:DEBUG`).
if _logger.isEnabledFor(logging.DEBUG):
keys = list(vals.keys())
for order in self:
_logger.debug(
'laundry.order.write id=%s source=%s state=%s '
'locked=%s ctx_keys=%s vals_keys=%s',
order.id, order.source_type, order.state, order.locked,
sorted(self.env.context.keys()), keys,
)
# STEP 3 — Lock guard for non-POS callers.
self._check_lock_for_write(vals)
return super().write(vals)
def unlink(self):
# Explicit: even with POS-sync context, refuse to delete a locked
# order. Deletion is not a mutation the sync path ever issues.
self._check_lock_for_unlink()
return super().unlink()
@api.model
def _apply_type_attribute_inference(self, vals):
"""Fill in priority_level / is_delivery from the selected order
type and attributes when the caller did not explicitly set them.
Rules:
- type.priority='urgent' OR any attribute with
is_priority_related=True → priority_level='urgent'
- type.is_delivery=True OR any attribute with
is_delivery_related=True → is_delivery=True
- Do NOT overwrite explicit incoming delivery_address /
delivery_scheduled_at with blank values.
"""
type_id = vals.get('order_type_id')
order_type = (
self.env['laundry.order.type'].browse(type_id) if type_id else None
)
attribute_ids = []
raw_attrs = vals.get('attribute_ids') or []
for cmd in raw_attrs:
if isinstance(cmd, (list, tuple)) and len(cmd) >= 3:
# Odoo x2m commands: (6,0,[ids]), (4,id), etc.
if cmd[0] == 6 and isinstance(cmd[2], (list, tuple)):
attribute_ids.extend(cmd[2])
elif cmd[0] == 4 and cmd[1]:
attribute_ids.append(cmd[1])
elif isinstance(cmd, int):
attribute_ids.append(cmd)
attributes = (
self.env['laundry.order.attribute'].browse(attribute_ids)
if attribute_ids else self.env['laundry.order.attribute']
)
# Priority
if 'priority_level' not in vals:
urgent = (
(order_type and order_type.priority == 'urgent')
or any(a.is_priority_related for a in attributes)
)
vals['priority_level'] = 'urgent' if urgent else 'normal'
# Delivery
if 'is_delivery' not in vals:
delivery = (
(order_type and order_type.is_delivery)
or any(a.is_delivery_related for a in attributes)
)
vals['is_delivery'] = bool(delivery)
# -- Workflow actions --
def action_process(self):
for order in self:
if order.state != 'intake':
raise UserError(_(
'Order "%(name)s" is not in Intake state.',
name=order.name,
))
order.state = 'processing'
def action_ready(self):
for order in self:
if order.state != 'processing':
raise UserError(_(
'Order "%(name)s" is not in Processing state.',
name=order.name,
))
order.state = 'ready'
def action_deliver(self):
"""Guards: must be Ready + fully paid (amount_due == 0).
Also stamps `delivered_at` so the dashboard KPIs (avg processing
time, on-time delivery %) can be computed from real data instead
of the heuristic on `write_date`.
"""
for order in self:
if order.state != 'ready':
raise UserError(_(
'Order "%(name)s" must be Ready before delivery.',
name=order.name,
))
if order.amount_due > 0:
raise UserError(_(
'Order "%(name)s" has %(due).2f outstanding. '
'Collect payment in POS before delivery.',
name=order.name,
due=order.amount_due,
))
order.write({
'state': 'delivered',
'delivered_at': fields.Datetime.now(),
})
def action_cancel(self):
"""Cancel an order. Allowed for manual orders only — POS-sourced
orders must be voided through the POS workflow to keep the sale
and the operational record in sync.
"""
for order in self:
if order.source_type == 'pos':
raise UserError(_(
'Order "%(name)s" was created from POS and cannot '
'be cancelled here. Cancel the underlying POS order '
'instead.',
name=order.name,
))
if order.state in FINAL_STATES:
raise UserError(_(
'Order "%(name)s" is already %(state)s.',
name=order.name,
state=dict(STATES).get(order.state, order.state),
))
order.state = 'cancelled'
# -- Smart button --
def action_open_pos_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'pos.order',
'res_id': self.pos_order_id.id,
'view_mode': 'form',
'target': 'current',
}
# ═════════════════════════════════════════════════════════════════
# POS "Laundry Orders" popup — server-side RPCs
# ---------------------------------------------------------------
# These methods are the ONLY way the POS popup interacts with the
# model. Each action delegates to the corresponding workflow method
# (`action_process` / `action_ready` / `action_deliver`) which already
# enforces state + amount_due guards server-side. Direct writes to
# LOCKED_HEADER_FIELDS are NOT exposed here — the Phase 3 lock
# remains the sole authority for business-edit protection.
# ═════════════════════════════════════════════════════════════════
def _pos_allowed_actions(self):
"""Return the list of action keys the popup may render for this
order. Pure function of state + amount_due. Final states only
allow printing.
"""
self.ensure_one()
actions = ['print_work_order']
if self.state in FINAL_STATES:
return actions
if self.state == 'intake':
actions.append('start_processing')
elif self.state == 'processing':
actions.append('mark_ready')
elif self.state == 'ready':
if self.amount_due <= 0:
actions.append('deliver')
else:
actions.append('collect_payment')
return actions
def _pos_payment_state(self):
self.ensure_one()
if self.amount_due > 0:
return 'due'
if self.amount_deferred > 0 and self.amount_settled >= self.amount_deferred:
return 'settled'
if self.amount_deferred > 0:
return 'deferred'
return 'paid'
def _pos_payload(self):
"""Compact, UI-ready dict. Single source of truth for the popup
shape — every RPC returns exactly this structure."""
self.ensure_one()
names = self.line_ids.mapped('product_id.name')
if not names:
names = self.line_ids.mapped('description')
state_selection = dict(self._fields['state'].selection)
return {
'id': self.id,
'name': self.name,
'state': self.state,
'state_label': state_selection.get(self.state) or self.state,
'pos_reference': self.pos_reference or '',
'is_from_pos': self.is_from_pos,
'create_date': fields.Datetime.to_string(self.create_date) if self.create_date else False,
'item_count': int(self.item_count or 0),
'service_summary': ', '.join(dict.fromkeys(names))[:80],
'amount_total': self.amount_total,
'amount_paid': self.amount_paid_cash,
'amount_deferred': self.amount_deferred,
'amount_settled': self.amount_settled,
'amount_due': self.amount_due,
'payment_state': self._pos_payment_state(),
'is_delivery': self.is_delivery,
'allowed_actions': self._pos_allowed_actions(),
}
@api.model
def pos_search_customer_orders(self, partner_id, search_query=False, limit=20):
"""Partner-scoped search for the POS popup.
• Always filters by partner_id (no global search in this phase).
• Optional search_query ilike-matches on name / pos_reference /
partner_phone.
• Hard-capped at 50 rows regardless of the caller's limit.
"""
if not partner_id:
return []
limit = max(1, min(int(limit or 20), 50))
q = (search_query or '').strip()
domain = [('partner_id', '=', partner_id)]
if q:
domain = [
'&',
('partner_id', '=', partner_id),
'|', '|',
('name', 'ilike', q),
('pos_reference', 'ilike', q),
('partner_phone', 'ilike', q),
]
orders = self.search(
domain, order='create_date desc, id desc', limit=limit,
)
return [o._pos_payload() for o in orders]
def pos_action_start_processing(self):
"""POS popup: advance intake → processing. Returns refreshed payload."""
self.ensure_one()
self.action_process()
return self._pos_payload()
def pos_action_mark_ready(self):
"""POS popup: advance processing → ready. Returns refreshed payload."""
self.ensure_one()
self.action_ready()
return self._pos_payload()
def pos_action_deliver(self):
"""POS popup: advance ready → delivered. Returns refreshed payload.
`action_deliver` raises UserError when amount_due > 0 — the popup
surfaces that error; no client-side duplication of the rule.
"""
self.ensure_one()
self.action_deliver()
return self._pos_payload()
@api.model
def pos_get_thermal_data(self, order_id):
"""Build a self-contained payload for the thermal Work-Order
receipt rendered by `laundry_management.LaundryWorkOrderThermal`.
Independent from `_pos_payload` — that one is for the popup list
(compact); this one carries every line + delivery meta the
cashier needs on the printed slip.
"""
order = self.browse(int(order_id))
if not order.exists():
return False
state_label = dict(order._fields['state'].selection).get(
order.state, order.state,
)
return {
'id': order.id,
'name': order.name,
'state': order.state,
'state_label': state_label,
'payment_state': order._pos_payment_state(),
'pos_reference': order.pos_reference or '',
'partner_name': order.partner_id.name or '',
# `mobile` is provided by the optional `phone` add-on; fall
# back gracefully when it isn't present in the install.
'partner_phone': (
order.partner_id.phone
or getattr(order.partner_id, 'mobile', '')
or ''
),
'company_name': order.company_id.name or '',
'create_date': fields.Datetime.to_string(order.create_date),
'lines': [{
'qty': line.qty,
'description': (
line.description
or (line.product_id.name if line.product_id else '')
),
'price_unit': line.price_unit,
'subtotal': line.subtotal,
'tracking_code': line.tracking_code or '',
} for line in order.line_ids],
'item_count': int(order.item_count or 0),
'amount_total': order.amount_total,
'amount_paid': order.amount_paid_cash,
'amount_deferred': order.amount_deferred,
'amount_settled': order.amount_settled,
'amount_due': order.amount_due,
'is_delivery': order.is_delivery,
'delivery_address': order.delivery_address or '',
'delivery_scheduled_at': (
fields.Datetime.to_string(order.delivery_scheduled_at)
if order.delivery_scheduled_at else ''
),
'currency_symbol': order.currency_id.symbol or '',
'currency_position': order.currency_id.position or 'after',
}
def action_open_unlock_wizard(self):
"""Open the manager unlock wizard pre-filled with this order.
Access is enforced inside the wizard's action method (group
check + reason required), but we also short-circuit here so
the button itself is silent for non-managers.
"""
self.ensure_one()
if not self.locked:
raise UserError(_(
'Order "%s" is already editable.', self.name,
))
return {
'type': 'ir.actions.act_window',
'res_model': 'laundry.order.unlock.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_order_id': self.id,
},
}

View File

@@ -0,0 +1,62 @@
from odoo import models, fields, api
class LaundryOrderAttribute(models.Model):
"""Optional per-order badge (Urgent / Hanger / Fold / Delicate / ...).
Multi-selectable on a laundry order. Semantic flags drive behavior
without name matching, so admins can rename freely.
"""
_name = 'laundry.order.attribute'
_inherit = ['pos.load.mixin']
_description = 'Laundry Order Attribute'
_order = 'sequence, id'
name = fields.Char(string='Name', required=True, translate=True)
code = fields.Char(string='Code')
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
color = fields.Char(string='Color')
icon_image = fields.Binary(string='Icon')
description = fields.Text(string='Description', translate=True)
extra_price = fields.Float(
string='Extra Price',
help='Reserved for future pricing rules. Not applied automatically.',
)
pos_available = fields.Boolean(string='Available in POS', default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company, index=True,
)
is_delivery_related = fields.Boolean(
string='Delivery Related',
help='Selecting this attribute marks the order as delivery and '
'triggers the delivery-details prompt.',
)
is_priority_related = fields.Boolean(
string='Priority Related',
help='Selecting this attribute promotes the order to urgent priority.',
)
@api.model
def _load_pos_data_domain(self, data, config):
return [
('pos_available', '=', True),
('active', '=', True),
'|',
('company_id', '=', False),
('company_id', 'in', config.company_id.ids),
]
@api.model
def _load_pos_data_fields(self, config):
return [
'id', 'name', 'code', 'sequence',
'color', 'description',
'extra_price',
'is_delivery_related', 'is_priority_related',
]

View File

@@ -0,0 +1,288 @@
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from .laundry_order import POS_SYNC_CTX
_logger = logging.getLogger(__name__)
LINE_STATES = [
('received', 'Received'),
('processing', 'Processing'),
('ready', 'Ready'),
('delivered', 'Delivered'),
]
# Line fields the lock protects. NOT included on purpose:
# - state (per-item workflow advance is always allowed)
# - customer_note (operator commentary)
# - tracking_code (auto-assigned; cannot change after create either way)
LOCKED_LINE_FIELDS = frozenset({
'product_id', 'description', 'qty', 'price_unit',
})
class LaundryOrderLine(models.Model):
"""Line item on a laundry order — maps from pos.order.line.
Each line carries a unique scannable tracking_code (barcode) and its
own per-item workflow state. The order-level state on laundry.order
remains the source of truth for financial gates; the per-line state
is an operational overlay that supports items moving through the
workflow at different speeds.
"""
_name = 'laundry.order.line'
_description = 'Laundry Order Line'
_order = 'order_id, id'
order_id = fields.Many2one(
'laundry.order', string='Order',
required=True, ondelete='cascade', index=True,
)
# Mirror order-level partner/state so list + kanban views can filter/group
# without costly cross-model joins.
order_partner_id = fields.Many2one(
related='order_id.partner_id', store=True, index=True, readonly=True,
)
order_state = fields.Selection(
related='order_id.state', store=True, index=True, readonly=True,
)
product_id = fields.Many2one(
'product.product', string='Product',
)
description = fields.Char(
string='Description',
)
qty = fields.Float(
string='Quantity',
default=1.0, digits=(10, 2),
)
price_unit = fields.Float(
string='Unit Price',
digits=(10, 2), readonly=True,
)
customer_note = fields.Char(
string='Customer Note',
)
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal',
store=True, digits=(10, 2),
)
# -- Per-item tracking --
tracking_code = fields.Char(
string='Tracking Code',
copy=False, readonly=True, index=True,
help='Unique scannable barcode for this item.',
)
state = fields.Selection(
LINE_STATES,
string='Item Status',
default='received',
required=True, copy=False, index=True,
)
_tracking_code_uniq = models.Constraint(
'UNIQUE(tracking_code)',
'Tracking code must be unique across all laundry items.',
)
@api.depends('qty', 'price_unit')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.qty * line.price_unit
# Sequence code for the auto-generated tracking_code (barcode).
_TRACKING_SEQ_CODE = 'laundry.order.line.tracking'
@api.model
def _next_tracking_code(self):
"""Allocate a tracking_code that is GUARANTEED unique across the
existing laundry_order_line table.
Why this exists
───────────────
Postgres sequences are NON-transactional: a `nextval()` advances
the sequence even when the surrounding ORM transaction rolls
back. Repeated POS validates that fail (lock, missing partner,
anything) eat sequence values without consuming them in real
rows. Conversely, a partial reseed / data import that inserts
rows with manual tracking_codes leaves the sequence BEHIND the
table's MAX. Either way, `next_by_code()` can return a code
that already exists → UniqueViolation → POS sale silently
misses its laundry-order link (the savepoint in pos_order.py
catches the SQL error to protect the POS commit).
How this fixes it
─────────────────
On collision, repair the sequence to (max_tracking_num + 1)
using a direct SQL nextval-skip, then ask the sequence again.
Capped at a few attempts so a real bug (e.g. malformed schema)
still surfaces instead of looping forever.
"""
seq = self.env['ir.sequence']
for attempt in range(5):
code = seq.next_by_code(self._TRACKING_SEQ_CODE) or False
if not code:
return False
# Cheap collision check; the underlying UNIQUE constraint is
# the real safety net — this just avoids paying the round-trip.
existing = self.sudo().search_count([('tracking_code', '=', code)])
if not existing:
return code
# Collision — repair sequence past the current MAX, then retry.
self._repair_tracking_sequence()
_logger.warning(
"laundry.order.line tracking sequence collided on %s "
"(attempt %d); repaired and retrying.", code, attempt + 1,
)
# If we still can't get a unique code after 5 tries, surface the
# problem instead of writing an empty code.
raise UserError(_(
'Could not allocate a unique tracking code after 5 attempts. '
'Check the laundry_management sequence configuration.'
))
@api.model
def _repair_tracking_sequence(self):
"""Advance the tracking-code sequence past the actual MAX in the
table. Idempotent — safe to call repeatedly. SQL-level so it
works even when the ORM env context is unusual (sudo, sync hook).
"""
self.env.cr.execute("""
SELECT COALESCE(MAX(
CAST(NULLIF(REGEXP_REPLACE(tracking_code, '[^0-9]', '', 'g'), '')
AS INTEGER)
), 0)
FROM laundry_order_line
WHERE tracking_code IS NOT NULL;
""")
max_existing = self.env.cr.fetchone()[0] or 0
# `ir.sequence` writes update number_next; we use the API for
# safety (handles ranges, prefixes, padding).
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', self._TRACKING_SEQ_CODE)], limit=1,
)
if seq:
seq.write({'number_next': max_existing + 1})
@api.model_create_multi
def create(self, vals_list):
# POS-sync bypass FIRST — POS creates the order and its lines in a
# single create_vals payload; both must sail through the guard.
pos_sync = bool(self.env.context.get(POS_SYNC_CTX))
if not pos_sync:
order_ids = {v.get('order_id') for v in vals_list if v.get('order_id')}
if order_ids:
orders = self.env['laundry.order'].browse(list(order_ids))
for order in orders:
if order.locked:
raise UserError(_(
'Cannot add a line to locked order "%s".',
order.name,
))
for vals in vals_list:
if not vals.get('tracking_code'):
vals['tracking_code'] = self._next_tracking_code()
if _logger.isEnabledFor(logging.DEBUG):
_logger.debug(
'laundry.order.line.create pos_sync=%s count=%d',
pos_sync, len(vals_list),
)
return super().create(vals_list)
def write(self, vals):
if self.env.context.get(POS_SYNC_CTX):
if _logger.isEnabledFor(logging.DEBUG):
_logger.debug(
'laundry.order.line.write BYPASS ids=%s keys=%s',
self.ids, list(vals.keys()),
)
return super().write(vals)
protected = LOCKED_LINE_FIELDS.intersection(vals.keys())
if protected:
for line in self:
if line.order_id.locked:
raise UserError(_(
'Line on locked order "%(order)s" cannot edit '
'%(fields)s. Ask a manager to use "Unlock for '
'Editing" first.',
order=line.order_id.name,
fields=', '.join(sorted(protected)),
))
return super().write(vals)
def unlink(self):
# No bypass: deletion is never issued by the POS sync path.
for line in self:
if line.order_id.locked:
raise UserError(_(
'Cannot delete a line from locked order "%s".',
line.order_id.name,
))
return super().unlink()
# -- Per-line workflow actions (1-click) --
def action_line_process(self):
for line in self:
if line.state != 'received':
raise UserError(_(
'Item %(code)s is not in Received state.',
code=line.tracking_code or line.id,
))
line.state = 'processing'
def action_line_ready(self):
for line in self:
if line.state != 'processing':
raise UserError(_(
'Item %(code)s is not in Processing state.',
code=line.tracking_code or line.id,
))
line.state = 'ready'
def action_line_deliver(self):
for line in self:
if line.state != 'ready':
raise UserError(_(
'Item %(code)s must be Ready before delivery.',
code=line.tracking_code or line.id,
))
line.state = 'delivered'
@api.model
def action_scan_advance(self, tracking_code):
"""Advance an item one stage by its scanned tracking code.
Intended for barcode scanner workflow: scanner types the code,
this method finds the line and bumps its state to the next stage.
Returns the new state or raises UserError if terminal / unknown.
"""
if not tracking_code:
raise UserError(_('Scan a tracking code.'))
line = self.search([('tracking_code', '=', tracking_code.strip())], limit=1)
if not line:
raise UserError(_('No item with tracking code %s.', tracking_code))
transitions = {
'received': line.action_line_process,
'processing': line.action_line_ready,
'ready': line.action_line_deliver,
}
action = transitions.get(line.state)
if not action:
raise UserError(_(
'Item %(code)s is already %(state)s.',
code=line.tracking_code, state=line.state,
))
action()
return {
'id': line.id,
'tracking_code': line.tracking_code,
'state': line.state,
'order_name': line.order_id.name,
'partner_name': line.order_partner_id.name,
'product_name': line.product_id.display_name,
}

View File

@@ -0,0 +1,53 @@
from odoo import models, fields, api
class LaundryOrderLineAddon(models.Model):
"""Optional add-on service attached to one sale.order.line.
Examples per item:
- Express handling (+10 SAR)
- Starch / تقطير (+3 SAR)
- Packaging (+2 SAR)
- Perfume scent (+5 SAR)
The parent line's subtotal does NOT automatically include add-on prices
(sale.order.line controls its own subtotal). Add-ons are tracked here for
printing and reporting purposes; staff can create a separate order line for
the add-on product if billing is required.
"""
_name = 'laundry.order.line.addon'
_description = 'Order Line Add-on'
_order = 'line_id, id'
line_id = fields.Many2one(
'sale.order.line', string='Order Line',
required=True, ondelete='cascade', index=True,
)
# Denormal for easy reporting — derived from sale.order.line.order_id
order_id = fields.Many2one(
'sale.order',
related='line_id.order_id', store=True, index=True,
)
name = fields.Char(
string='Add-on / الإضافة',
required=True,
help='Name of the additional service (e.g. Express, Starch, Packaging).',
)
price = fields.Float(
string='Price / السعر',
required=True, digits=(10, 2), default=0.0,
)
quantity = fields.Float(
string='Qty',
default=1.0, digits=(10, 2),
)
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal', store=True, digits=(10, 2),
)
@api.depends('price', 'quantity')
def _compute_subtotal(self):
for addon in self:
addon.subtotal = addon.price * addon.quantity

Some files were not shown because too many files have changed in this diff Show More