Compare commits
8 Commits
om_account
...
19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d7bc4a4b88 | |||
| ee9b1958f1 | |||
| 48db592326 | |||
| 8f834373b7 | |||
| c6765e04f7 | |||
| fc97d80c2c | |||
| 46ec5c998d | |||
| f6f341c372 |
46
addons/accounting_pdf_reports/README.rst
Normal 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
|
||||
7
addons/accounting_pdf_reports/__init__.py
Normal 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""")
|
||||
45
addons/accounting_pdf_reports/__manifest__.py
Normal 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'],
|
||||
}
|
||||
96
addons/accounting_pdf_reports/data/account_account_type.xml
Normal 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>
|
||||
1101
addons/accounting_pdf_reports/i18n/ar.po
Normal file
1198
addons/accounting_pdf_reports/i18n/ar_001.po
Normal file
1192
addons/accounting_pdf_reports/i18n/ar_SY.po
Normal file
1038
addons/accounting_pdf_reports/i18n/de.po
Normal file
1201
addons/accounting_pdf_reports/i18n/es.po
Normal file
1349
addons/accounting_pdf_reports/i18n/es_AR.po
Normal file
1087
addons/accounting_pdf_reports/i18n/fr.po
Normal file
1173
addons/accounting_pdf_reports/i18n/tr.po
Normal file
1155
addons/accounting_pdf_reports/i18n/uk.po
Normal file
1391
addons/accounting_pdf_reports/i18n/zh_TW.po
Normal file
3
addons/accounting_pdf_reports/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import account_account_type
|
||||
from . import account_financial_report
|
||||
from . import account_move_line
|
||||
34
addons/accounting_pdf_reports/models/account_account_type.py
Normal 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."
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
117
addons/accounting_pdf_reports/models/account_move_line.py
Normal 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
|
||||
12
addons/accounting_pdf_reports/report/__init__.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
71
addons/accounting_pdf_reports/report/report.xml
Normal 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>
|
||||
263
addons/accounting_pdf_reports/report/report_aged_partner.py
Normal 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,
|
||||
}
|
||||
100
addons/accounting_pdf_reports/report/report_aged_partner.xml
Normal 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>
|
||||
163
addons/accounting_pdf_reports/report/report_financial.py
Normal 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,
|
||||
}
|
||||
116
addons/accounting_pdf_reports/report/report_financial.xml
Normal 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')) > 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
|
||||
<t t-if="not int(a.get('level')) > 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')) > 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
|
||||
<t t-if="not int(a.get('level')) > 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')) > 3"><t t-set="style" t-value="'font-weight: normal;'"/></t>
|
||||
<t t-if="not int(a.get('level')) > 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>
|
||||
184
addons/accounting_pdf_reports/report/report_general_ledger.py
Normal 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,
|
||||
}
|
||||
124
addons/accounting_pdf_reports/report/report_general_ledger.xml
Normal 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>
|
||||
117
addons/accounting_pdf_reports/report/report_journal.py
Normal 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,
|
||||
}
|
||||
105
addons/accounting_pdf_reports/report/report_journal_audit.xml
Normal 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>
|
||||
108
addons/accounting_pdf_reports/report/report_journal_entries.xml
Normal 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>
|
||||
122
addons/accounting_pdf_reports/report/report_partner_ledger.py
Normal 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,
|
||||
}
|
||||
109
addons/accounting_pdf_reports/report/report_partner_ledger.xml
Normal 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>
|
||||
70
addons/accounting_pdf_reports/report/report_tax.py
Normal 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
|
||||
85
addons/accounting_pdf_reports/report/report_tax.xml
Normal 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>
|
||||
90
addons/accounting_pdf_reports/report/report_trial_balance.py
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
25
addons/accounting_pdf_reports/security/ir.model.access.csv
Normal 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
|
||||
|
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 34 KiB |
BIN
addons/accounting_pdf_reports/static/description/banner.gif
Normal file
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 104 KiB |
BIN
addons/accounting_pdf_reports/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
155
addons/accounting_pdf_reports/static/description/index.html
Normal 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;">
|
||||
|
||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 147 KiB |
BIN
addons/accounting_pdf_reports/static/description/odoo_mates.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 87 KiB |
98
addons/accounting_pdf_reports/views/financial_report.xml
Normal 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>
|
||||
|
||||
36
addons/accounting_pdf_reports/views/ledger_menu.xml
Normal 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>
|
||||
|
||||
24
addons/accounting_pdf_reports/views/menu.xml
Normal 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>
|
||||
|
||||
37
addons/accounting_pdf_reports/views/settings.xml
Normal 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>
|
||||
12
addons/accounting_pdf_reports/wizard/__init__.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
55
addons/accounting_pdf_reports/wizard/account_report.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
20
addons/accounting_pdf_reports/wizard/account_tax_report.py
Normal 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)
|
||||
@@ -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)
|
||||
41
addons/accounting_pdf_reports/wizard/aged_partner.py
Normal 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)
|
||||
85
addons/accounting_pdf_reports/wizard/aged_partner.xml
Normal 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>
|
||||
116
addons/accounting_pdf_reports/wizard/balance_sheet.xml
Normal 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>
|
||||
48
addons/accounting_pdf_reports/wizard/general_ledger.xml
Normal 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>
|
||||
35
addons/accounting_pdf_reports/wizard/journal_audit.xml
Normal 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>
|
||||
62
addons/accounting_pdf_reports/wizard/partner_ledger.xml
Normal 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>
|
||||
21
addons/accounting_pdf_reports/wizard/profit_and_loss.xml
Normal 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>
|
||||
45
addons/accounting_pdf_reports/wizard/tax_report.xml
Normal 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>
|
||||
41
addons/accounting_pdf_reports/wizard/trial_balance.xml
Normal 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>
|
||||
2
addons/laundry_management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
151
addons/laundry_management/__manifest__.py
Normal 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,
|
||||
}
|
||||
349
addons/laundry_management/data/laundry_data.xml
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
41
addons/laundry_management/data/sequence.xml
Normal 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>
|
||||
5
addons/laundry_management/data/service_catalog_data.xml
Normal 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>
|
||||
52
addons/laundry_management/demo/demo.xml
Normal 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>
|
||||
7
addons/laundry_management/doc/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## Module <laundry_management>
|
||||
|
||||
#### 26.01.2026
|
||||
#### Version 19.0.1.0.0
|
||||
#### ADD
|
||||
|
||||
- Initial commit for Laundry Management
|
||||
413
addons/laundry_management/i18n/ar.po
Normal 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 "تحليل الفواتير"
|
||||
228
addons/laundry_management/migrations/19.0.11.0.0/pre_migrate.py
Normal 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')
|
||||
@@ -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')
|
||||
103
addons/laundry_management/migrations/19.0.13.0.0/pre_migrate.py
Normal 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')
|
||||
@@ -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,
|
||||
)
|
||||
0
addons/laundry_management/migrations/__init__.py
Normal file
16
addons/laundry_management/models/__init__.py
Normal 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
|
||||
21
addons/laundry_management/models/account_move.py
Normal 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
|
||||
)
|
||||
22
addons/laundry_management/models/account_payment_ext.py
Normal 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.',
|
||||
)
|
||||
125
addons/laundry_management/models/laundry_commission.py
Normal 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}.'
|
||||
)
|
||||
180
addons/laundry_management/models/laundry_dashboard.py
Normal 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'])],
|
||||
}
|
||||
765
addons/laundry_management/models/laundry_order.py
Normal 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,
|
||||
},
|
||||
}
|
||||
62
addons/laundry_management/models/laundry_order_attribute.py
Normal 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',
|
||||
]
|
||||
288
addons/laundry_management/models/laundry_order_line.py
Normal 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,
|
||||
}
|
||||
53
addons/laundry_management/models/laundry_order_line_addon.py
Normal 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
|
||||