Initial commit: OCA Report packages (45 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit 2f4db400df
2543 changed files with 469120 additions and 0 deletions

View file

@ -0,0 +1,47 @@
# MIS Builder
Odoo addon: mis_builder
## Installation
```bash
pip install odoo-bringout-oca-mis-builder-mis_builder
```
## Dependencies
This addon depends on:
- account
- board
- report_xlsx
- date_range
## Manifest Information
- **Name**: MIS Builder
- **Version**: 16.0.5.5.1
- **Category**: Reporting
- **License**: AGPL-3
- **Installable**: True
## Source
Based on [OCA/mis-builder](https://github.com/OCA/mis-builder) branch 16.0, addon `mis_builder`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Mis_builder Module - mis_builder
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for mis_builder. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,8 @@
# Dependencies
This addon depends on:
- [account](../../odoo-bringout-oca-ocb-account)
- [board](../../odoo-bringout-oca-ocb-board)
- [report_xlsx](../../odoo-bringout-oca-reporting-engine-report_xlsx)
- [date_range](../../odoo-bringout-oca-server-ux-date_range)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon mis_builder or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-mis-builder-mis_builder"
# or
uv pip install odoo-bringout-oca-mis-builder-mis_builder"
```

View file

@ -0,0 +1,24 @@
# Models
Detected core models and extensions in mis_builder.
```mermaid
classDiagram
class mis_kpi_data
class mis_report
class mis_report_instance
class mis_report_instance_annotation
class mis_report_instance_period
class mis_report_instance_period_sum
class mis_report_kpi
class mis_report_kpi_expression
class mis_report_query
class mis_report_style
class mis_report_subkpi
class mis_report_subreport
class prorata_read_group_mixin
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: mis_builder. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon mis_builder
- License: LGPL-3

View file

@ -0,0 +1,32 @@
# Reports
Report definitions and templates in mis_builder.
```mermaid
classDiagram
class Report
Model <|-- Report
class MisBuilderXlsx
AbstractModel <|-- MisBuilderXlsx
```
## Available Reports
### PDF/Document Reports
- **MIS report instance QWEB PDF report** (PDF/Print)
- **MIS report instance XLS report** (PDF/Print)
## Report Files
- **__init__.py** (Python logic)
- **mis_report_instance_qweb.py** (Python logic)
- **mis_report_instance_qweb.xml** (XML template/definition)
- **mis_report_instance_xlsx.py** (Python logic)
- **mis_report_instance_xlsx.xml** (XML template/definition)
## Notes
- Named reports above are accessible through Odoo's reporting menu
- Python files define report logic and data processing
- XML files contain report templates, definitions, and formatting
- Reports are integrated with Odoo's printing and email systems

View file

@ -0,0 +1,45 @@
# Security
Access control and security definitions in mis_builder.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../mis_builder/security/ir.model.access.csv)**
- 23 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[mis_builder_security.xml](../mis_builder/security/mis_builder_security.xml)**
- **[res_groups.xml](../mis_builder/security/res_groups.xml)**
- 2 security groups defined
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../mis_builder/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[mis_builder_security.xml](../mis_builder/security/mis_builder_security.xml)**
- Security groups, categories, and XML-based rules
- **[res_groups.xml](../mis_builder/security/res_groups.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon mis_builder
```

View file

@ -0,0 +1,8 @@
# Wizards
Transient models exposed as UI wizards in mis_builder.
```mermaid
classDiagram
class AddMisReportInstanceDashboard
```

View file

@ -0,0 +1,719 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
===========
MIS Builder
===========
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:9284d72ac55aea402b2ee7dbcbf9e3bcd406892939230843fdb5ddf37dffebee
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png
:target: https://odoo-community.org/page/development-status
:alt: Production/Stable
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmis--builder-lightgray.png?logo=github
:target: https://github.com/OCA/mis-builder/tree/16.0/mis_builder
:alt: OCA/mis-builder
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/mis-builder-16-0/mis-builder-16-0-mis_builder
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/mis-builder&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module allows you to build Management Information Systems dashboards.
Such style of reports presents KPI in rows and time periods in columns.
Reports mainly fetch data from account moves, but can also combine data coming
from arbitrary Odoo models. Reports can be exported to PDF, Excel and they
can be added to Odoo dashboards.
**Table of contents**
.. contents::
:local:
Installation
============
Your preferred way to install addons will work with MIS Builder.
An easy way to install it with all its dependencies is using pip:
* ``pip install --pre odoo12-addon-mis_builder``
* then restart Odoo, update the addons list in your database, and install
the MIS Builder application.
Usage
=====
To configure this module, you need to:
* Go to Accounting > Configuration > MIS Reporting > MIS Report Templates where
you can create report templates by defining KPI's. KPI's constitute the rows of your
reports. Such report templates are time independent.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_template.png
:alt: Sample report template
:width: 80 %
:align: center
* Then in Accounting > Reports > MIS Reporting > MIS Reports you can create report instance by
binding the templates to time periods, hence defining the columns of your reports.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_settings.png
:alt: Sample report configuration
:width: 80 %
:align: center
* From the MIS Reports view, you can preview the report, add it to and Odoo dashboard,
and export it to PDF or Excel.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_preview.png
:alt: Sample preview
:width: 80 %
:align: center
Development
===========
A typical extension is to provide a mechanism to filter reports on analytic dimensions
or operational units. To implement this, you can override _get_additional_move_line_filter
and _get_additional_filter to further filter move lines or queries based on a user
selection. A typical use case could be to add an analytic account field on mis.report.instance,
or even on mis.report.instance.period if you want different columns to show different
analytic accounts.
Known issues / Roadmap
======================
The mis_builder `roadmap <https://github.com/OCA/mis-builder/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement>`_
and `known issues <https://github.com/OCA/mis-builder/issues?q=is%3Aopen+is%3Aissue+label%3Abug>`_ can
be found on GitHub.
Changelog
=========
16.0.5.1.9 (2024-02-09)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Restore compatibility with python 3.9 (`#590 <https://github.com/OCA/mis-builder/issues/590>`_)
16.0.5.1.8 (2024-02-08)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Resolve a permission issue when creating report periods with a user without admin rights. (`#596 <https://github.com/OCA/mis-builder/issues/596>`_)
16.0.5.1.0 (2023-04-04)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Improve UX by adding the option to edit the pivot date directly on the view.
16.0.5.0.0 (2023-04-01)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Migration to 16.0
- Addition of a generic filter domain on reports and columns.
- Addition of a search bar to the widget. The corresponding search view is configurable
per report.
- Huge improvement of the widget style. This was long overdue.
- Make the MIS Report menu accessible to the Billing Administrator group
(instead of the hidden Show Full Accounting Features), to align with the access rules
and avoid giving a false sense of security. This also makes the menu discoverable to
new users.
- Removal of analytic fetures because the upstream ``analytic_distribution`` mechanism
is not compatible; support may be introduced in separate module, depending on use
cases.
- Abandon the ``mis_report_filters`` context key which had security implication.
It is replaced by a ``mis_analytic_domain`` context key which is ANDed with other
report-defined filters. (`#472 <https://github.com/OCA/mis-builder/issues/472>`_)
- Rename the ``get_filter_descriptions_from_context`` method to
``get_filter_descriptions``. This method may be overridden to provide additional
subtitles on the PDF or XLS report, representing user-selected filters.
- The ``hide_analytic_filters`` has been replaced by ``widget_show_filters``.
- The visibility of the settings button on the widget is now controlled by a
``show_settings_button``. Before it was visible only for the ``account_user`` group
but this was not flexible enough.
- The widget configuration settings are now grouped in a dedicated ``Widget`` tab in
the report configuration form.
**Bugfixes**
- Fix access error when previewing or printing report. (`#415 <https://github.com/OCA/mis-builder/issues/415>`_)
15.0.4.0.5 (2022-07-19)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Support users without timezone. (`#388 <https://github.com/OCA/mis-builder/issues/388>`_)
15.0.4.0.4 (2022-07-19)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Allow deleting a report that has subreports. (`#431 <https://github.com/OCA/mis-builder/issues/431>`_)
15.0.4.0.2 (2022-02-16)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix access right issue when clicking the "Save" button on a MIS Report Instance form. (`#410 <https://github.com/OCA/mis-builder/issues/410>`_)
14.0.4.0.0 (2022-01-08)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Remove various field size limits. (`#332 <https://github.com/OCA/mis-builder/issues/332>`_)
**Bugfixes**
- Support for the Odoo 13+ multi-company model. In multi-company mode, several allowed
companies can be declared on MIS Report instances, and the report operates on the
intersection of report companies and companies selected in the user context. (`#327 <https://github.com/OCA/mis-builder/issues/327>`_)
- The ``get_additional_query_filter`` argument of ``evaluate()`` is now propagated
correctly. (`#375 <https://github.com/OCA/mis-builder/issues/375>`_)
- Use the ``parent_state`` field of ``account.move.line`` to filter entries in ``posted``
and ``draft`` state only. Before, when reporting in draft mode, all entries were used
(i.e. there was no filter), and that started including the cancelled entries/invoices in
Odoo 13.+.
This change also contains a **breaking change** in the internal API. For quite a while
the ``target_move argument`` of AEP and other methods was not used by MIS Builder itself
and was kept for backward compatibility. To avoid rippling effects of the necessary
change to use ``parent_state``, we now remove this argument. (`#377 <https://github.com/OCA/mis-builder/issues/377>`_)
14.0.3.6.7 (2021-06-02)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- When on a MIS Report Instance, if you wanted to generate a new line of type comparison, you couldn't currently select any existing period to compare.
This happened because the field domain was searching in a NewId context, thus not finding a correct period.
Changing the domain and making it use a computed field with a search for the _origin record solves the problem. (`#361 <https://github.com/OCA/mis-builder/issues/361>`_)
14.0.3.6.6 (2021-04-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix drilldown action name when the account model has been customized. (`#350 <https://github.com/OCA/mis-builder/issues/350>`_)
14.0.3.6.5 (2021-04-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- While duplicating a MIS report instance, comparison columns are ignored because
they would raise an error otherwise, as they keep the old source_cmpcol_from_id
and source_cmpcol_to_id from the original record. (`#343 <https://github.com/OCA/mis-builder/issues/343>`_)
14.0.3.6.4 (2021-04-06)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- The drilldown action name displayed on the breadcrumb has been revised.
The kpi description and the account ``display_name`` are shown instead
of the kpi's technical definition. (`#304 <https://github.com/OCA/mis-builder/issues/304>`_)
- Add analytic group filters on report instance, periods and in the interactive
view. (`#320 <https://github.com/OCA/mis-builder/issues/320>`_)
13.0.3.6.3 (2020-08-28)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Having a "Compare columns" added on a KPI with an associated style using a
Factor/Divider did lead to the said factor being applied on the percentages
when exporting to XLSX. (`#300 <https://github.com/OCA/mis-builder/issues/300>`_)
**Misc**
- `#280 <https://github.com/OCA/mis-builder/issues/280>`_, `#296 <https://github.com/OCA/mis-builder/issues/296>`_
13.0.3.6.2 (2020-04-22)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- The "Settings" button is now displayed for users with the "Show full accounting features" right when previewing a report. (`#281 <https://github.com/OCA/mis-builder/issues/281>`_)
13.0.3.6.1 (2020-04-22)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix ``TypeError: 'module' object is not iterable`` when using
budgets by account. (`#276 <https://github.com/OCA/mis-builder/issues/276>`_)
13.0.3.6.0 (2020-03-28)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Add column-level filters on analytic account and analytic tags.
These filters are combined with a AND with the report-level filters
and cannot be modified in the preview. (`#138 <https://github.com/OCA/mis-builder/issues/138>`_)
- Access to KPI from other reports in KPI expressions, aka subreports. In a
report template, one can list named "subreports" (other report templates). When
evaluating expressions, you can access KPI's of subreports with a dot-prefix
notation. Example: you can define a MIS Report for a "Balance Sheet", and then
have another MIS Report "Balance Sheet Ratios" that fetches KPI's from "Balance
Sheet" to create new KPI's for the ratios (e.g. balance_sheet.current_assets /
balance_sheet.total_assets). (`#155 <https://github.com/OCA/mis-builder/issues/155>`_)
13.0.3.5.0 (2020-01-??)
~~~~~~~~~~~~~~~~~~~~~~~
Migration to odoo 13.0.
12.0.3.5.0 (2019-10-26)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- The ``account_id`` field of the model selected in 'Move lines source'
in the Period form can now be a Many2one
relationship with any model that has a ``code`` field (not only with
``account.account`` model). To this end, the model to be used for Actuals
move lines can be configured on the report template. It can be something else
than move lines and the only constraint is that its ``account_id`` field
has a ``code`` field. (`#149 <https://github.com/oca/mis-builder/issues/149>`_)
- Add ``source_aml_model_name`` field so extension modules providing
alternative data sources can more easily customize their data source. (`#214 <https://github.com/oca/mis-builder/issues/214>`_)
- Support analytic tag filters in the backend view and preview widget.
Selecting several tags in the filter means filtering on move lines which
have *all* these tags set. This is to support the most common use case of
using tags for different dimensions. The filter also makes a AND with the
analytic account filter. (`#228 <https://github.com/oca/mis-builder/issues/228>`_)
- Display company in account details rows in multi-company mode. (`#242 <https://github.com/oca/mis-builder/issues/242>`_)
**Bugfixes**
- Propagate context to xlsx report, so the analytic account filter
works when exporting to xslx too. This also requires a fix to
``report_xlsx`` (see https://github.com/OCA/reporting-engine/pull/259). (`#178 <https://github.com/oca/mis-builder/issues/178>`_)
- In columns of type Sum, preserve styles for KPIs that are not summable
(eg percentage values). Before this fix, such cells were displayed without
style. (`#219 <https://github.com/oca/mis-builder/issues/219>`_)
- In Excel export, keep the percentage point suffix (pp) instead of replacing it with %. (`#220 <https://github.com/oca/mis-builder/issues/220>`_)
12.0.3.4.0 (2019-07-09)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- New year-to-date mode for defining periods. (`#165 <https://github.com/oca/mis-builder/issues/165>`_)
- Add support for move lines with negative debit or credit.
Used by some for storno accounting. Not officially supported. (`#175 <https://github.com/oca/mis-builder/issues/175>`_)
- In Excel export, use a number format with thousands separator. The
specific separator used depends on the Excel configuration (eg regional
settings). (`#190 <https://github.com/oca/mis-builder/issues/190>`_)
- Add generation date/time at the end of the XLS export. (`#191 <https://github.com/oca/mis-builder/issues/191>`_)
- In presence of Sub KPIs, report more informative user errors when
non-multi expressions yield tuples of incorrect lenght. (`#196 <https://github.com/oca/mis-builder/issues/196>`_)
**Bugfixes**
- Fix rendering of percentage types in Excel export. (`#192 <https://github.com/oca/mis-builder/issues/192>`_)
12.0.3.3.0 (2019-01-26)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
*Dynamic analytic filters in report preview are not yet available in 11,
this requires an update to the JS widget that proved difficult to implement
so far. Help welcome.*
- Analytic account filters. On a report, an analytic
account can be selected for filtering. The filter will
be applied to move lines queries. A filter box is also
available in the widget to let the user select the analytic
account during report preview. (`#15 <https://github.com/oca/mis-builder/issues/15>`_)
- Control visibility of analytic filter combo box in widget.
This is useful to hide the analytic filters on reports where
they do not make sense, such as balance sheet reports. (`#42 <https://github.com/oca/mis-builder/issues/42>`_)
- Display analytic filters in the header of exported pdf and xls. (`#44 <https://github.com/oca/mis-builder/issues/44>`_)
- Replace the last old gtk icons with fontawesome icons. (`#104 <https://github.com/oca/mis-builder/issues/104>`_)
- Use active_test=False in AEP queries.
This is important for reports involving inactive taxes.
This should not negatively effect existing reports, because
an accounting report must take into account all existing move lines
even if they reference objects such as taxes, journals, accounts types
that have been deactivated since their creation. (`#107 <https://github.com/oca/mis-builder/issues/107>`_)
- int(), float() and round() support for AccountingNone. (`#108 <https://github.com/oca/mis-builder/issues/108>`_)
- Allow referencing subkpis by name by writing `kpi_x.subkpi_y` in expressions. (`#114 <https://github.com/oca/mis-builder/issues/114>`_)
- Add an option to control the display of the start/end dates in the
column headers. It is disabled by default (this is a change compared
to previous behaviour). (`#118 <https://github.com/oca/mis-builder/issues/118>`_)
- Add evaluate method to mis.report. This is a simplified
method to evaluate kpis of a report over a time period,
without creating a mis.report.instance. (`#123 <https://github.com/oca/mis-builder/issues/123>`_)
**Bugs**
- In the style form, hide the "Hide always" checkbox when "Hide always inherit"
is checked, as for all other syle elements. (`#121 <https://github.com/OCA/mis-builder/pull/121>_`)
**Upgrading from 3.2 (breaking changes)**
If you use ``Actuals (alternative)`` data source in combination with analytic
filters, the underlying model must now have an ``analytic_account_id`` field.
11.0.3.2.2 (2018-06-30)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] Fix bug in company_default_get call returning
id instead of recordset
(`#103 <https://github.com/OCA/mis-builder/pull/103>`_)
* [IMP] add "hide always" style property to make hidden KPI's
(for KPI that serve as basis for other formulas, but do not
need to be displayed).
(`#46 <https://github.com/OCA/mis-builder/issues/46>`_)
11.0.3.2.1 (2018-05-29)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] Missing comparison operator for AccountingNone
leading to errors in pbal computations
(`#93 <https://github.com/OCA/mis-builder/issue/93>`_)
10.0.3.2.0 (2018-05-02)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] make subkpi ordering deterministic
(`#71 <https://github.com/OCA/mis-builder/issues/71>`_)
* [ADD] report instance level option to disable account expansion,
enabling the creation of detailed templates while deferring the decision
of rendering the details or not to the report instance
(`#74 <https://github.com/OCA/mis-builder/issues/74>`_)
* [ADD] pbal and nbal accounting expressions, to sum positive
and negative balances respectively (ie ignoring accounts with negative,
resp positive balances)
(`#86 <https://github.com/OCA/mis-builder/issues/86>`_)
11.0.3.1.2 (2018-02-04)
~~~~~~~~~~~~~~~~~~~~~~~
Migration to Odoo 11. No new feature.
(`#67 <https://github.com/OCA/mis-builder/pull/67>`_)
10.0.3.1.1 (2017-11-14)
~~~~~~~~~~~~~~~~~~~~~~~
New features:
* [ADD] month and year relative periods, easier to use than
date ranges for the most common case.
(`#2 <https://github.com/OCA/mis-builder/issues/2>`_)
* [ADD] multi-company consolidation support, with currency conversion
(the conversion rate date is the end of the reporting period)
(`#7 <https://github.com/OCA/mis-builder/issues/7>`_,
`#3 <https://github.com/OCA/mis-builder/issues/3>`_)
* [ADD] provide ref, datetime, dateutil, time, user in the evaluation
context of move line domains; among other things, this allows using
references to xml ids (such as account types or tax tags) when
querying move lines
(`#26 <https://github.com/OCA/mis-builder/issues/26>`_).
* [ADD] extended account selectors: you can now select accounts using
any domain on account.account, not only account codes
``balp[('account_type', '=', 'asset_receivable')]``
(`#4 <https://github.com/OCA/mis-builder/issues/4>`_).
* [IMP] in the report instance configuration form, the filters are
now grouped in a notebook page, this improves readability and
extensibility
(`#39 <https://github.com/OCA/mis-builder/issues/39>`_).
Bug fixes:
* [FIX] fix error when saving periods in comparison mode on newly
created (not yet saved) report instances.
`#50 <https://github.com/OCA/mis-builder/pull/50>`_
* [FIX] improve display of Base Date report instance view.
`#51 <https://github.com/OCA/mis-builder/pull/51>`_
Upgrading from 3.0 (breaking changes):
* Alternative move line data sources must have a company_id field.
10.0.3.0.4 (2017-10-14)
~~~~~~~~~~~~~~~~~~~~~~~
Bug fix:
* [FIX] issue with initial balance rounding.
`#30 <https://github.com/OCA/mis-builder/issues/30>`_
10.0.3.0.3 (2017-10-03)
~~~~~~~~~~~~~~~~~~~~~~~
Bug fix:
* [FIX] fix error saving KPI on newly created reports.
`#18 <https://github.com/OCA/mis-builder/issues/18>`_
10.0.3.0.2 (2017-10-01)
~~~~~~~~~~~~~~~~~~~~~~~
New features:
* [ADD] Alternative move line source per report column.
This makes mis buidler accounting expressions work on any model
that has debit, credit, account_id and date fields. Provided you can
expose, say, committed purchases, or your budget as a view with
debit, credit and account_id, this opens up a lot of possibilities
* [ADD] Comparison column source (more flexible than the previous,
now deprecated, comparison mechanism).
CAVEAT: there is no automated migration to the new mechanism.
* [ADD] Sum column source, to create columns that add/subtract
other columns.
* [ADD] mis.kpi.data abstract model as a basis for manual KPI values
supporting automatic ajustment to the reporting time period (the basis
for budget item, but could also server other purposes, such as manually
entering some KPI values, such as number of employee)
* [ADD] mis_builder_budget module providing a new budget data source
* [ADD] new "hide empty" style property
* [IMP] new AEP method to get accounts involved in an expression
(this is useful to find which KPI relate to a given P&L
acount, to implement budget control)
* [IMP] many UI improvements
* [IMP] many code style improvements and some refactoring
* [IMP] add the column date_from, date_to in expression evaluation context,
as well as time, datetime and dateutil modules
Main bug fixes:
* [FIX] deletion of templates and reports (cascade and retricts)
(https://github.com/OCA/account-financial-reporting/issues/281)
* [FIX] copy of reports
(https://github.com/OCA/account-financial-reporting/issues/282)
* [FIX] better error message when periods have wrong/missing dates
(https://github.com/OCA/account-financial-reporting/issues/283)
* [FIX] xlsx export of string types KPI
(https://github.com/OCA/account-financial-reporting/issues/285)
* [FIX] sorting of detail by account
* [FIX] computation bug in detail by account when multiple accounting
expressions were used in a KPI
* [FIX] permission issue when adding report to dashboard with non admin user
10.0.2.0.3 (unreleased)
~~~~~~~~~~~~~~~~~~~~~~~
* [IMP] more robust behaviour in presence of missing expressions
* [FIX] indent style
* [FIX] local variable 'ctx' referenced before assignment when generating
reports with no objects
* [IMP] use fontawesome icons
* [MIG] migrate to 10.0
* [FIX] unicode error when exporting to Excel
* [IMP] provide full access to mis builder style for group Adviser.
9.0.2.0.2 (2016-09-27)
~~~~~~~~~~~~~~~~~~~~~~
* [IMP] Add refresh button in mis report preview.
* [IMP] Widget code changes to allow to add fields in the widget more easily.
9.0.2.0.1 (2016-05-26)
~~~~~~~~~~~~~~~~~~~~~~
* [IMP] remove unused argument in declare_and_compute_period()
for a cleaner API. This is a breaking API changing merged in
urgency before it is used by other modules.
9.0.2.0.0 (2016-05-24)
~~~~~~~~~~~~~~~~~~~~~~
Part of the work for this release has been done at the Sorrento sprint
April 26-29, 2016. The rest (ie a major refactoring) has been done in
the weeks after.
* [IMP] hide button box in edit mode on the report instance settings form
* [FIX] Fix sum aggregation of non-stored fields
(https://github.com/OCA/account-financial-reporting/issues/178)
* [IMP] There is now a default style at the report level
* [CHG] Number display properties (rounding, prefix, suffix, factor) are
now defined in styles
* [CHG] Percentage difference are rounded to 1 digit instead of the kpi's
rounding, as the KPI rounding does not make sense in this case
* [CHG] The divider suffix (k, M, etc) is not inserted automatically anymore
because it is inconsistent when working with prefixes; you need to add it
manually in the suffix
* [IMP] AccountingExpressionProcessor now supports 'balu' expressions
to obtain the unallocated profit/loss of previous fiscal years;
get_unallocated_pl is the corresponding convenience method
* [IMP] AccountingExpressionProcessor now has easy methods to obtain
balances by account: get_balances_initial, get_balances_end,
get_balances_variation
* [IMP] there is now an auto-expand feature to automatically display
a detail by account for selected kpis
* [IMP] the kpi and period lists are now manipulated through forms instead
of directly in the tree views
* [IMP] it is now possible to create a report through a wizard, such
reports are deemed temporary and available through a "Last Reports Generated"
menu, they are garbaged collected automatically, unless saved permanently,
which can be done using a Save button
* [IMP] there is now a beginner mode to configure simple reports with
only one period
* [IMP] it is now easier to configure periods with fixed start/end dates
* [IMP] the new sub-kpi mechanism allows the creation of columns
with multiple values, or columns with different values
* [IMP] thanks to the new style model, the Excel export is now styled
* [IMP] a new style model is now used to centralize style configuration
* [FIX] use =like instead of like to search for accounts, because
the % are added by the user in the expressions
* [FIX] Correctly compute the initial balance of income and expense account
based on the start of the fiscal year
* [IMP] Support date ranges (from OCA/server-tools/date_range) as a more
flexible alternative to fiscal periods
* v9 migration: fiscal periods are removed, account charts are removed,
consolidation accounts have been removed
8.0.1.0.0 (2016-04-27)
~~~~~~~~~~~~~~~~~~~~~~
* The copy of a MIS Report Instance now copies period.
https://github.com/OCA/account-financial-reporting/pull/181
* The copy of a MIS Report Template now copies KPIs and queries.
https://github.com/OCA/account-financial-reporting/pull/177
* Usability: the default view for MIS Report instances is now the rendered preview,
and the settings are accessible through a gear icon in the list view and
a button in the preview.
https://github.com/OCA/account-financial-reporting/pull/170
* Display blank cells instead of 0.0 when there is no data.
https://github.com/OCA/account-financial-reporting/pull/169
* Usability: better layout of the MIS Report periods settings on small screens.
https://github.com/OCA/account-financial-reporting/pull/167
* Include the download buttons inside the MIS Builder widget, and refactor
the widget to open the door to analytic filtering in the previews.
https://github.com/OCA/account-financial-reporting/pull/151
* Add KPI rendering prefixes (so you can print $ in front of the value).
https://github.com/OCA/account-financial-reporting/pull/158
* Add hooks for analytic filtering.
https://github.com/OCA/account-financial-reporting/pull/128
https://github.com/OCA/account-financial-reporting/pull/131
8.0.0.2.0
~~~~~~~~~
Pre-history. Or rather, you need to look at the git log.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/mis-builder/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/mis-builder/issues/new?body=module:%20mis_builder%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* ACSONE SA/NV
Contributors
~~~~~~~~~~~~
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Adrien Peiffer <adrien.peiffer@acsone.eu>
* Alexis de Lattre <alexis.delattre@akretion.com>
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
* Jordi Ballester <jordi.ballester@eficent.com>
* Thomas Binsfeld <thomas.binsfeld@gmail.com>
* Giovanni Capalbo <giovanni@therp.nl>
* Marco Calcagni <mcalcagni@dinamicheaziendali.it>
* Sébastien Beau <sebastien.beau@akretion.com>
* Laurent Mignon <laurent.mignon@acsone.eu>
* Luc De Meyer <luc.demeyer@noviat.com>
* Benjamin Willig <benjamin.willig@acsone.eu>
* Martronic SA <info@martronic.ch>
* nicomacr <nmr@adhoc.com.ar>
* Juan Jose Scarafia <jjs@adhoc.com.ar>
* Richard deMeester <richard@willowit.com.au>
* Eric Caudal <eric.caudal@elico-corp.com>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Maxence Groine <mgroine@fiefmanage.ch>
* Arnaud Pineux <arnaud.pineux@acsone.eu>
* Ernesto Tejeda <ernesto.tejeda@tecnativa.com>
* Pedro M. Baeza <pedro.baeza@tecnativa.com>
* Alexey Pelykh <alexey.pelykh@corphub.eu>
* Jairo Llopis (https://www.moduon.team/)
* Dzung Tran <dungtd@trobz.com>
* Hoang Diep <hoang@trobz.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-sbidoul| image:: https://github.com/sbidoul.png?size=40px
:target: https://github.com/sbidoul
:alt: sbidoul
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-sbidoul|
This module is part of the `OCA/mis-builder <https://github.com/OCA/mis-builder/tree/16.0/mis_builder>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,6 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import models
from . import wizard
from . import report

View file

@ -0,0 +1,47 @@
# Copyright 2014-2018 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "MIS Builder",
"version": "16.0.5.5.1",
"category": "Reporting",
"summary": """
Build 'Management Information System' Reports and Dashboards
""",
"author": "ACSONE SA/NV, " "Odoo Community Association (OCA)",
"website": "https://github.com/OCA/mis-builder",
"depends": [
"account",
"board",
"report_xlsx", # OCA/reporting-engine
"date_range", # OCA/server-ux
],
"data": [
"security/res_groups.xml",
"wizard/mis_builder_dashboard.xml",
"views/mis_report.xml",
"views/mis_report_instance.xml",
"views/mis_report_style.xml",
"datas/ir_cron.xml",
"security/ir.model.access.csv",
"security/mis_builder_security.xml",
"report/mis_report_instance_qweb.xml",
"report/mis_report_instance_xlsx.xml",
],
"assets": {
"web.assets_backend": [
"mis_builder/static/src/components/mis_report_widget.esm.js",
"mis_builder/static/src/components/mis_report_widget.xml",
"mis_builder/static/src/components/mis_report_widget.css",
],
"web.report_assets_common": [
"/mis_builder/static/src/css/report.css",
],
},
"qweb": ["static/src/xml/mis_report_widget.xml"],
"installable": True,
"application": True,
"license": "AGPL-3",
"development_status": "Production/Stable",
"maintainers": ["sbidoul"],
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record id="ir_cron_vacuum_temp_reports" model="ir.cron">
<field name="name">Vacuum temporary reports</field>
<field name="interval_number">4</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field ref="model_mis_report_instance" name="model_id" />
<field name="code">model._vacuum_report()</field>
<field name="active" eval="True" />
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
# Copyright 2024 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
"""Set the value of the analytic_domain field."""
openupgrade.logged_query(
env.cr,
"""
UPDATE mis_report_instance_period
SET analytic_domain = CONCAT('[("analytic_distribution_search", "in", [', analytic_account_id::VARCHAR, '])]')
WHERE analytic_account_id IS NOT NULL
""", # noqa: E501
)
openupgrade.logged_query(
env.cr,
"""
UPDATE mis_report_instance
SET analytic_domain = CONCAT('[("analytic_distribution_search", "in", [', analytic_account_id::VARCHAR, '])]')
WHERE analytic_account_id IS NOT NULL
""", # noqa: E501
)

View file

@ -0,0 +1,10 @@
# Copyright 2023 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo
from odoo import api
def migrate(cr, installed_version):
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
env["mis.report.instance.period"].search([])._compute_source_aml_model_id()

View file

@ -0,0 +1,11 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import mis_report
from . import mis_report_subreport
from . import mis_report_instance
from . import mis_report_style
from . import aep
from . import mis_kpi_data
from . import prorata_read_group_mixin
from . import mis_report_instance_annotation

View file

@ -0,0 +1,215 @@
# Copyright 2016 Thomas Binsfeld
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
"""
Provides the AccountingNone singleton.
AccountingNone is a null value that dissolves in basic arithmetic operations,
as illustrated in the examples below. In comparisons, AccountingNone behaves
the same as zero.
>>> 1 + 1
2
>>> 1 + AccountingNone
1
>>> AccountingNone + 1
1
>>> AccountingNone + None
AccountingNone
>>> None + AccountingNone
AccountingNone
>>> +AccountingNone
AccountingNone
>>> -AccountingNone
AccountingNone
>>> -(AccountingNone)
AccountingNone
>>> AccountingNone - 1
-1
>>> 1 - AccountingNone
1
>>> abs(AccountingNone)
AccountingNone
>>> AccountingNone - None
AccountingNone
>>> None - AccountingNone
AccountingNone
>>> AccountingNone / 2
0.0
>>> 2 / AccountingNone
Traceback (most recent call last):
...
ZeroDivisionError
>>> AccountingNone / AccountingNone
AccountingNone
>>> AccountingNone // 2
0.0
>>> 2 // AccountingNone
Traceback (most recent call last):
...
ZeroDivisionError
>>> AccountingNone // AccountingNone
AccountingNone
>>> AccountingNone * 2
0.0
>>> 2 * AccountingNone
0.0
>>> AccountingNone * AccountingNone
AccountingNone
>>> AccountingNone * None
AccountingNone
>>> None * AccountingNone
AccountingNone
>>> str(AccountingNone)
''
>>> bool(AccountingNone)
False
>>> AccountingNone > 0
False
>>> AccountingNone < 0
False
>>> AccountingNone < 1
True
>>> AccountingNone > 1
False
>>> 0 < AccountingNone
False
>>> 0 > AccountingNone
False
>>> 1 < AccountingNone
False
>>> 1 > AccountingNone
True
>>> AccountingNone == 0
True
>>> AccountingNone == 0.0
True
>>> AccountingNone == None
True
>>> AccountingNone >= AccountingNone
True
>>> AccountingNone <= AccountingNone
True
>>> round(AccountingNone, 2)
0.0
>>> float(AccountingNone)
0.0
>>> int(AccountingNone)
0
"""
__all__ = ["AccountingNone"]
class AccountingNoneType:
def __add__(self, other):
if other is None:
return AccountingNone
return other
__radd__ = __add__
def __sub__(self, other):
if other is None:
return AccountingNone
return -other
def __rsub__(self, other):
if other is None:
return AccountingNone
return other
def __iadd__(self, other):
if other is None:
return AccountingNone
return other
def __isub__(self, other):
if other is None:
return AccountingNone
return -other
def __abs__(self):
return self
def __pos__(self):
return self
def __neg__(self):
return self
def __div__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rdiv__(self, other):
raise ZeroDivisionError
def __floordiv__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rfloordiv__(self, other):
raise ZeroDivisionError
def __truediv__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rtruediv__(self, other):
raise ZeroDivisionError
def __mul__(self, other):
if other is None or other is AccountingNone:
return AccountingNone
return 0.0
__rmul__ = __mul__
def __repr__(self):
return "AccountingNone"
def __str__(self):
return ""
def __nonzero__(self):
return False
def __bool__(self):
return False
def __eq__(self, other):
return other == 0 or other is None or other is AccountingNone
def __lt__(self, other):
return other > 0
def __gt__(self, other):
return other < 0
def __le__(self, other):
return other >= 0
def __ge__(self, other):
return other <= 0
def __float__(self):
return 0.0
def __int__(self):
return 0
def __round__(self, ndigits):
return 0.0
AccountingNone = AccountingNoneType()
if __name__ == "__main__": # pragma: no cover
import doctest
doctest.testmod()

View file

@ -0,0 +1,660 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
import re
from collections import defaultdict
from odoo import _, fields
from odoo.exceptions import UserError
from odoo.models import expression
from odoo.tools.float_utils import float_is_zero
from odoo.tools.safe_eval import datetime, dateutil, safe_eval, time
from .accounting_none import AccountingNone
_logger = logging.getLogger(__name__)
_DOMAIN_START_RE = re.compile(r"\(|(['\"])[!&|]\1")
def _is_domain(s):
"""Test if a string looks like an Odoo domain"""
return _DOMAIN_START_RE.match(s)
class Accumulator:
"""A simple class to accumulate debit, credit and custom field values.
>>> acc1 = Accumulator(["f1", "f2"])
>>> acc1.debit
AccountingNone
>>> acc1.credit
AccountingNone
>>> acc1.custom_fields
{'f1': AccountingNone, 'f2': AccountingNone}
>>> acc1.add_debit_credit(10, 20)
>>> acc1.debit, acc1.credit
(10, 20)
>>> acc1.add_custom_field("f1", 10)
>>> acc1.custom_fields
{'f1': 10, 'f2': AccountingNone}
>>> acc2 = Accumulator(["f1", "f2"])
>>> acc2.add_debit_credit(21, 31)
>>> acc2.add_custom_field("f2", 41)
>>> acc1 += acc2
>>> acc1.debit, acc1.credit
(31, 51)
>>> acc1.custom_fields
{'f1': 10, 'f2': 41}
"""
def __init__(self, custom_field_names=()):
self.debit = AccountingNone
self.credit = AccountingNone
self.custom_fields = {
custom_field: AccountingNone for custom_field in custom_field_names
}
def has_data(self):
return (
self.debit is not AccountingNone
or self.credit is not AccountingNone
or any(v is not AccountingNone for v in self.custom_fields.values())
)
def add_debit_credit(self, debit, credit):
self.debit += debit
self.credit += credit
def add_custom_field(self, field, value):
self.custom_fields[field] += value
def __iadd__(self, other):
self.debit += other.debit
self.credit += other.credit
for field in self.custom_fields:
self.custom_fields[field] += other.custom_fields[field]
return self
class AccountingExpressionProcessor:
"""Processor for accounting expressions.
Expressions of the form
<field><mode>(.fieldname)?[accounts][optional move line domain]
are supported, where:
* field is bal, crd, deb, pbal (positive balances only),
nbal (negative balance only), fld (custom field)
* mode is i (initial balance), e (ending balance),
p (moves over period)
* .fieldname is used only with fldp and specifies the field name to sum
* there is also a special u mode (unallocated P&L) which computes
the sum from the beginning until the beginning of the fiscal year
of the period; it is only meaningful for P&L accounts
* accounts is a list of accounts, possibly containing % wildcards,
or a domain expression on account.account
* an optional domain on move lines allowing filters on eg analytic
accounts or journal
Examples:
* bal[70]: variation of the balance of moves on account 70
over the period (it is the same as balp[70]);
* bali[70,60]: balance of accounts 70 and 60 at the start of period;
* bale[1%]: balance of accounts starting with 1 at end of period.
* fldp.quantity[60%]: sum of the quantity field of moves on accounts 60
How to use:
* repeatedly invoke parse_expr() for each expression containing
accounting variables as described above; this lets the processor
group domains and modes and accounts;
* when all expressions have been parsed, invoke done_parsing()
to notify the processor that it can prepare to query (mainly
search all accounts - children, consolidation - that will need to
be queried;
* for each period, call do_queries(), then call replace_expr() for each
expression to replace accounting variables with their resulting value
for the given period.
How it works:
* by accumulating the expressions before hand, it ensures to do the
strict minimum number of queries to the database (for each period,
one query per domain and mode);
* it queries using the orm read_group which reduces to a query with
sum on debit and credit and group by on account_id and company_id,
(note: it seems the orm then does one query per account to fetch
the account name...);
* additionally, one query per view/consolidation account is done to
discover the children accounts.
"""
MODE_VARIATION = "p"
MODE_INITIAL = "i"
MODE_END = "e"
MODE_UNALLOCATED = "u"
_ACC_RE = re.compile(
r"(?P<field>\bbal|\bpbal|\bnbal|\bcrd|\bdeb|\bfld)"
r"(?P<mode>[piseu])?"
r"(?P<fld_name>\.[a-zA-Z0-9_]+)?"
r"\s*"
r"(?P<account_sel>_[a-zA-Z0-9]+|\[.*?\])"
r"\s*"
r"(?P<ml_domain>\[.*?\])?"
)
def __init__(self, companies, currency=None, account_model="account.account"):
self.env = companies.env
self.companies = companies
if not currency:
self.currency = companies.mapped("currency_id")
if len(self.currency) > 1:
raise UserError(
_(
"If currency_id is not provided, "
"all companies must have the same currency."
)
)
else:
self.currency = currency
self.dp = self.currency.decimal_places
# before done_parsing: {(ml_domain, mode): set(acc_domain)}
# after done_parsing: {(ml_domain, mode): list(account_ids)}
self._map_account_ids = defaultdict(set)
# {account_domain: set(account_ids)}
self._account_ids_by_acc_domain = defaultdict(set)
# smart ending balance (returns AccountingNone if there
# are no moves in period and 0 initial balance), implies
# a first query to get the initial balance and another
# to get the variation, so it's a bit slower
self.smart_end = True
# custom field to query and sum
self._custom_fields = set()
# Account model
self._account_model = self.env[account_model].with_context(active_test=False)
def _account_codes_to_domain(self, account_codes):
"""Convert a comma separated list of account codes
(possibly with wildcards) to a domain on account.account.
"""
elems = []
for account_code in account_codes.split(","):
account_code = account_code.strip()
if "%" in account_code:
elems.append([("code", "=like", account_code)])
else:
elems.append([("code", "=", account_code)])
return tuple(expression.OR(elems))
def _parse_match_object(self, mo):
"""Split a match object corresponding to an accounting variable
Returns field, mode, fld_name, account domain, move line domain.
"""
domain_eval_context = {
"ref": self.env.ref,
"user": self.env.user,
"time": time,
"datetime": datetime,
"dateutil": dateutil,
}
field, mode, fld_name, account_sel, ml_domain = mo.groups()
# handle some legacy modes
if not mode:
mode = self.MODE_VARIATION
elif mode == "s":
mode = self.MODE_END
# custom fields
if fld_name:
assert fld_name[0] == "."
fld_name = fld_name[1:] # strip leading dot
# convert account selector to account domain
if account_sel.startswith("_"):
# legacy bal_NNN%
acc_domain = self._account_codes_to_domain(account_sel[1:])
else:
assert account_sel[0] == "[" and account_sel[-1] == "]"
inner_account_sel = account_sel[1:-1].strip()
if not inner_account_sel:
# empty selector: select all accounts
acc_domain = tuple()
elif _is_domain(inner_account_sel):
# account selector is a domain
acc_domain = tuple(safe_eval(account_sel, domain_eval_context))
else:
# account selector is a list of account codes
acc_domain = self._account_codes_to_domain(inner_account_sel)
# move line domain
if ml_domain:
assert ml_domain[0] == "[" and ml_domain[-1] == "]"
ml_domain = tuple(safe_eval(ml_domain, domain_eval_context))
else:
ml_domain = tuple()
return field, mode, fld_name, acc_domain, ml_domain
def parse_expr(self, expr):
"""Parse an expression, extracting accounting variables.
Move line domains and account selectors are extracted and
stored in the map so when all expressions have been parsed,
we know which account domains to query for each move line domain
and mode.
"""
for mo in self._ACC_RE.finditer(expr):
field, mode, fld_name, acc_domain, ml_domain = self._parse_match_object(mo)
if mode == self.MODE_END and self.smart_end:
modes = (self.MODE_INITIAL, self.MODE_VARIATION, self.MODE_END)
else:
modes = (mode,)
for mode in modes:
key = (ml_domain, mode)
self._map_account_ids[key].add(acc_domain)
if field == "fld":
if mode != self.MODE_VARIATION:
raise UserError(
_(
"`fld` can only be used with mode `p` (variation) "
"in expression %s",
expr,
)
)
if not fld_name:
raise UserError(
_("`fld` must have a field name in exression %s", expr)
)
self._custom_fields.add(fld_name)
else:
if fld_name:
raise UserError(
_(
"`%(field)s` cannot have a field name "
"in expression %(expr)s",
field=field,
expr=expr,
)
)
def done_parsing(self):
"""Replace account domains by account ids in map"""
for key, acc_domains in self._map_account_ids.items():
all_account_ids = set()
for acc_domain in acc_domains:
acc_domain_with_company = expression.AND(
[acc_domain, [("company_id", "in", self.companies.ids)]]
)
account_ids = self._account_model.search(acc_domain_with_company).ids
self._account_ids_by_acc_domain[acc_domain].update(account_ids)
all_account_ids.update(account_ids)
self._map_account_ids[key] = list(all_account_ids)
@classmethod
def has_account_var(cls, expr):
"""Test if an string contains an accounting variable."""
return bool(cls._ACC_RE.search(expr))
def get_account_ids_for_expr(self, expr):
"""Get a set of account ids that are involved in an expression.
Prerequisite: done_parsing() must have been invoked.
"""
account_ids = set()
for mo in self._ACC_RE.finditer(expr):
_, _, _, acc_domain, _ = self._parse_match_object(mo)
account_ids.update(self._account_ids_by_acc_domain[acc_domain])
return account_ids
def get_aml_domain_for_expr(self, expr, date_from, date_to, account_id=None):
"""Get a domain on account.move.line for an expression.
Prerequisite: done_parsing() must have been invoked.
Returns a domain that can be used to search on account.move.line.
"""
aml_domains = []
date_domain_by_mode = {}
for mo in self._ACC_RE.finditer(expr):
field, mode, fld_name, acc_domain, ml_domain = self._parse_match_object(mo)
aml_domain = list(ml_domain)
account_ids = set()
account_ids.update(self._account_ids_by_acc_domain[acc_domain])
if not account_id:
aml_domain.append(("account_id", "in", tuple(account_ids)))
else:
# filter on account_id
if account_id in account_ids:
aml_domain.append(("account_id", "=", account_id))
else:
continue
if field == "crd":
aml_domain.append(("credit", "<>", 0.0))
elif field == "deb":
aml_domain.append(("debit", "<>", 0.0))
elif fld_name:
aml_domain.append((fld_name, "!=", False))
aml_domains.append(expression.normalize_domain(aml_domain))
if mode not in date_domain_by_mode:
date_domain_by_mode[mode] = self.get_aml_domain_for_dates(
date_from, date_to, mode
)
assert aml_domains
# TODO we could do this for more precision:
# AND(OR(aml_domains[mode]), date_domain[mode]) for each mode
return expression.OR(aml_domains) + expression.OR(date_domain_by_mode.values())
def get_aml_domain_for_dates(self, date_from, date_to, mode):
if mode == self.MODE_VARIATION:
domain = [("date", ">=", date_from), ("date", "<=", date_to)]
elif mode in (self.MODE_INITIAL, self.MODE_END):
# for income and expense account, sum from the beginning
# of the current fiscal year only, for balance sheet accounts
# sum from the beginning of time
date_from_date = fields.Date.from_string(date_from)
# TODO this takes the fy from the first company
# make that user controllable (nice to have)?
fy_date_from = self.companies[0].compute_fiscalyear_dates(date_from_date)[
"date_from"
]
domain = [
"|",
("date", ">=", fields.Date.to_string(fy_date_from)),
("account_id.include_initial_balance", "=", True),
]
if mode == self.MODE_INITIAL:
domain.append(("date", "<", date_from))
elif mode == self.MODE_END:
domain.append(("date", "<=", date_to))
elif mode == self.MODE_UNALLOCATED:
date_from_date = fields.Date.from_string(date_from)
# TODO this takes the fy from the first company
# make that user controllable (nice to have)?
fy_date_from = self.companies[0].compute_fiscalyear_dates(date_from_date)[
"date_from"
]
domain = [
("date", "<", fields.Date.to_string(fy_date_from)),
("account_id.include_initial_balance", "=", False),
]
return expression.normalize_domain(domain)
def _get_company_rates(self, date):
# get exchange rates for each company with its rouding
company_rates = {}
target_rate = self.currency.with_context(date=date).rate
for company in self.companies:
if company.currency_id != self.currency:
rate = target_rate / company.currency_id.with_context(date=date).rate
else:
rate = 1.0
company_rates[company.id] = (rate, company.currency_id.decimal_places)
return company_rates
def do_queries(
self,
date_from,
date_to,
additional_move_line_filter=None,
aml_model=None,
):
"""Query sums of debit and credit for all accounts and domains
used in expressions.
This method must be executed after done_parsing().
"""
if not aml_model:
aml_model = self.env["account.move.line"]
else:
aml_model = self.env[aml_model]
aml_model = aml_model.with_context(active_test=False)
company_rates = self._get_company_rates(date_to)
# {(domain, mode): {account_id: Accumulator}}
self._data = defaultdict(
lambda: defaultdict(
lambda: Accumulator(self._custom_fields),
)
)
domain_by_mode = {}
ends = []
for key in self._map_account_ids:
domain, mode = key
if mode == self.MODE_END and self.smart_end:
# postpone computation of ending balance
ends.append((domain, mode))
continue
if mode not in domain_by_mode:
domain_by_mode[mode] = self.get_aml_domain_for_dates(
date_from, date_to, mode
)
domain = list(domain) + domain_by_mode[mode]
domain.append(("account_id", "in", self._map_account_ids[key]))
if additional_move_line_filter:
domain.extend(additional_move_line_filter)
# fetch sum of debit/credit, grouped by account_id
_logger.debug("read_group domain: %s", domain)
try:
accs = aml_model.read_group(
domain,
[
"debit",
"credit",
"account_id",
"company_id",
*self._custom_fields,
],
["account_id", "company_id"],
lazy=False,
)
except ValueError as e:
raise UserError(
_(
'Error while querying move line source "%(model_name)s". '
"This is likely due to a filter or expression referencing "
"a field that does not exist in the model.\n\n"
"The technical error message is: %(exception)s. "
)
% dict(
model_name=aml_model._description,
exception=e,
)
) from e
for acc in accs:
rate, dp = company_rates[acc["company_id"][0]]
debit = acc["debit"] or 0.0
credit = acc["credit"] or 0.0
if mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and float_is_zero(
debit - credit, precision_digits=self.dp
):
# in initial mode, ignore accounts with 0 balance
continue
# due to branches, it's possible to have multiple groups
# with the same account_id, because multiple companies can
# use the same account
account_data = self._data[key][acc["account_id"][0]]
account_data.add_debit_credit(debit * rate, credit * rate)
for field_name in self._custom_fields:
account_data.add_custom_field(
field_name, acc[field_name] or AccountingNone
)
# compute ending balances by summing initial and variation
for key in ends:
domain, mode = key
initial_data = self._data[(domain, self.MODE_INITIAL)]
variation_data = self._data[(domain, self.MODE_VARIATION)]
account_ids = set(initial_data.keys()) | set(variation_data.keys())
for account_id in account_ids:
self._data[key][account_id] += initial_data[account_id]
self._data[key][account_id] += variation_data[account_id]
def replace_expr(self, expr):
"""Replace accounting variables in an expression by their amount.
Returns a new expression string.
This method must be executed after do_queries().
"""
def f(mo):
field, mode, fld_name, acc_domain, ml_domain = self._parse_match_object(mo)
key = (ml_domain, mode)
account_ids_data = self._data[key]
v = AccountingNone
account_ids = self._account_ids_by_acc_domain[acc_domain]
for account_id in account_ids:
entry = account_ids_data[account_id]
debit = entry.debit
credit = entry.credit
if field == "bal":
v += debit - credit
elif field == "pbal":
if debit >= credit:
v += debit - credit
elif field == "nbal":
if debit < credit:
v += debit - credit
elif field == "deb":
v += debit
elif field == "crd":
v += credit
else:
assert field == "fld"
v += entry.custom_fields[fld_name]
# in initial balance mode, assume 0 is None
# as it does not make sense to distinguish 0 from "no data"
if (
v is not AccountingNone
and mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED)
and float_is_zero(v, precision_digits=self.dp)
):
v = AccountingNone
return "(" + repr(v) + ")"
return self._ACC_RE.sub(f, expr)
def replace_exprs_by_account_id(self, exprs):
"""Replace accounting variables in a list of expression
by their amount, iterating by accounts involved in the expression.
yields account_id, replaced_expr
This method must be executed after do_queries().
"""
def f(mo):
field, mode, fld_name, acc_domain, ml_domain = self._parse_match_object(mo)
key = (ml_domain, mode)
# first check if account_id is involved in
# the current expression part
if account_id not in self._account_ids_by_acc_domain[acc_domain]:
return "(AccountingNone)"
# here we know account_id is involved in acc_domain
account_ids_data = self._data[key]
entry = account_ids_data[account_id]
debit = entry.debit
credit = entry.credit
if field == "bal":
v = debit - credit
elif field == "pbal":
if debit >= credit:
v = debit - credit
else:
v = AccountingNone
elif field == "nbal":
if debit < credit:
v = debit - credit
else:
v = AccountingNone
elif field == "deb":
v = debit
elif field == "crd":
v = credit
else:
assert field == "fld"
v = entry.custom_fields[fld_name]
# in initial balance mode, assume 0 is None
# as it does not make sense to distinguish 0 from "no data"
if (
v is not AccountingNone
and mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED)
and float_is_zero(v, precision_digits=self.dp)
):
v = AccountingNone
return "(" + repr(v) + ")"
account_ids = set()
for expr in exprs:
for mo in self._ACC_RE.finditer(expr):
_, mode, _, acc_domain, ml_domain = self._parse_match_object(mo)
key = (ml_domain, mode)
account_ids_data = self._data[key]
for account_id in self._account_ids_by_acc_domain[acc_domain]:
if account_ids_data[account_id].has_data():
account_ids.add(account_id)
for account_id in account_ids:
yield account_id, [self._ACC_RE.sub(f, expr) for expr in exprs]
@classmethod
def _get_balances(cls, mode, companies, date_from, date_to):
expr = f"deb{mode}[], crd{mode}[]"
aep = AccountingExpressionProcessor(companies)
# disable smart_end to have the data at once, instead
# of initial + variation
aep.smart_end = False
aep.parse_expr(expr)
aep.done_parsing()
aep.do_queries(date_from, date_to)
return {k: (v.debit, v.credit) for k, v in aep._data[((), mode)].items()}
@classmethod
def get_balances_initial(cls, companies, date):
"""A convenience method to obtain the initial balances of all accounts
at a given date.
It is the same as get_balances_end(date-1).
:param companies:
:param date:
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_INITIAL, companies, date, date)
@classmethod
def get_balances_end(cls, companies, date):
"""A convenience method to obtain the ending balances of all accounts
at a given date.
It is the same as get_balances_initial(date+1).
:param companies:
:param date:
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_END, companies, date, date)
@classmethod
def get_balances_variation(cls, companies, date_from, date_to):
"""A convenience method to obtain the variation of the
balances of all accounts over a period.
:param companies:
:param date:
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_VARIATION, companies, date_from, date_to)
@classmethod
def get_unallocated_pl(cls, companies, date):
"""A convenience method to obtain the unallocated profit/loss
of the previous fiscal years at a given date.
:param companies:
:param date:
Returns a tuple (debit, credit)
"""
# TODO shoud we include here the accounts of type "unaffected"
# or leave that to the caller?
bals = cls._get_balances(cls.MODE_UNALLOCATED, companies, date, date)
return tuple(map(sum, zip(*bals.values()))) # noqa: B905

View file

@ -0,0 +1,129 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
def _sum(lst):
"""Same as stdlib sum but returns None instead of 0
in case of empty sequence.
>>> sum([1])
1
>>> _sum([1])
1
>>> sum([1, 2])
3
>>> _sum([1, 2])
3
>>> sum([])
0
>>> _sum([])
"""
if not lst:
return None
return sum(lst)
def _avg(lst):
"""Arithmetic mean of a sequence. Returns None in case of empty sequence.
>>> _avg([1])
1.0
>>> _avg([1, 2])
1.5
>>> _avg([])
"""
if not lst:
return None
return sum(lst) / float(len(lst))
def _min(*args):
"""Same as stdlib min but returns None instead of exception
in case of empty sequence.
>>> min(1, 2)
1
>>> _min(1, 2)
1
>>> min([1, 2])
1
>>> _min([1, 2])
1
>>> min(1)
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: 'int' object is not iterable
>>> _min(1)
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: 'int' object is not iterable
>>> min([1])
1
>>> _min([1])
1
>>> min()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: min expected at least 1 argument, got 0
>>> _min()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: min expected at least 1 argument, got 0
>>> min([])
Traceback (most recent call last):
File "<stdin>", line 1, in ?
ValueError: min() arg is an empty sequence
>>> _min([])
"""
if len(args) == 1 and not args[0]:
return None
return min(*args)
def _max(*args):
"""Same as stdlib max but returns None instead of exception
in case of empty sequence.
>>> max(1, 2)
2
>>> _max(1, 2)
2
>>> max([1, 2])
2
>>> _max([1, 2])
2
>>> max(1)
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: 'int' object is not iterable
>>> _max(1)
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: 'int' object is not iterable
>>> max([1])
1
>>> _max([1])
1
>>> max()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: max expected at least 1 argument, got 0
>>> _max()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: max expected at least 1 argument, got 0
>>> max([])
Traceback (most recent call last):
File "<stdin>", line 1, in ?
ValueError: max() arg is an empty sequence
>>> _max([])
"""
if len(args) == 1 and not args[0]:
return None
return max(*args)
if __name__ == "__main__": # pragma: no cover
import doctest
doctest.testmod()

View file

@ -0,0 +1,17 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2016 Akretion (<http://akretion.com>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
class DataError(Exception):
def __init__(self, name, msg):
super().__init__()
self.name = name
self.msg = msg
def __repr__(self):
return f"{self.__class__.__name__}({repr(self.name)})"
class NameDataError(DataError):
pass

View file

@ -0,0 +1,68 @@
# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from .mis_safe_eval import NameDataError, mis_safe_eval
class ExpressionEvaluator:
def __init__(
self,
aep,
date_from,
date_to,
additional_move_line_filter=None,
aml_model=None,
):
self.aep = aep
self.date_from = date_from
self.date_to = date_to
self.additional_move_line_filter = additional_move_line_filter
self.aml_model = aml_model
self._aep_queries_done = False
def aep_do_queries(self):
if self.aep and not self._aep_queries_done:
self.aep.do_queries(
self.date_from,
self.date_to,
self.additional_move_line_filter,
self.aml_model,
)
self._aep_queries_done = True
def eval_expressions(self, expressions, locals_dict):
vals = []
drilldown_args = []
name_error = False
for expression in expressions:
expr = expression and expression.name or "AccountingNone"
if self.aep:
replaced_expr = self.aep.replace_expr(expr)
else:
replaced_expr = expr
val = mis_safe_eval(replaced_expr, locals_dict)
vals.append(val)
if isinstance(val, NameDataError):
name_error = True
if replaced_expr != expr:
drilldown_args.append({"expr": expr})
else:
drilldown_args.append(None)
return vals, drilldown_args, name_error
def eval_expressions_by_account(self, expressions, locals_dict):
if not self.aep:
return
exprs = [e and e.name or "AccountingNone" for e in expressions]
for account_id, replaced_exprs in self.aep.replace_exprs_by_account_id(exprs):
vals = []
drilldown_args = []
name_error = False
for expr, replaced_expr in zip(exprs, replaced_exprs): # noqa: B905
val = mis_safe_eval(replaced_expr, locals_dict)
vals.append(val)
if replaced_expr != expr:
drilldown_args.append({"expr": expr, "account_id": account_id})
else:
drilldown_args.append(None)
yield account_id, vals, drilldown_args, name_error

View file

@ -0,0 +1,576 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from __future__ import annotations
import logging
from collections import OrderedDict, defaultdict
from odoo import _
from odoo.exceptions import UserError
from .accounting_none import AccountingNone
from .mis_kpi_data import ACC_SUM
from .mis_safe_eval import DataError, mis_safe_eval
from .simple_array import SimpleArray
_logger = logging.getLogger(__name__)
class KpiMatrixRow:
# TODO: ultimately, the kpi matrix will become ignorant of KPI's and
# accounts and know about rows, columns, sub columns and styles only.
# It is already ignorant of period and only knowns about columns.
# This will require a correct abstraction for expanding row details.
def __init__(self, matrix, kpi, account_id=None, parent_row=None):
self._matrix = matrix
self.kpi = kpi
self.account_id = account_id
self.description = ""
self.parent_row = parent_row
if not self.account_id:
self.style_props = self._matrix._style_model.merge(
[self.kpi.report_id.style_id, self.kpi.style_id]
)
else:
self.style_props = self._matrix._style_model.merge(
[self.kpi.report_id.style_id, self.kpi.auto_expand_accounts_style_id]
)
@property
def label(self):
if not self.account_id:
return self.kpi.description
else:
return self._matrix.get_account_name(self.account_id)
@property
def row_id(self):
self._matrix._make_row_id(self.kpi.id, self.account_id)
def iter_cell_tuples(self, cols=None):
if cols is None:
cols = self._matrix.iter_cols()
for col in cols:
yield col.get_cell_tuple_for_row(self)
def iter_cells(self, subcols=None):
if subcols is None:
subcols = self._matrix.iter_subcols()
for subcol in subcols:
yield subcol.get_cell_for_row(self)
def is_empty(self):
for cell in self.iter_cells():
if cell and cell.val not in (AccountingNone, None):
return False
return True
class KpiMatrixCol:
def __init__(self, key, label, description, locals_dict, subkpis):
self.key = key
self.label = label
self.description = description
self.locals_dict = locals_dict
self.colspan = subkpis and len(subkpis) or 1
self._subcols = []
self.subkpis = subkpis
if not subkpis:
subcol = KpiMatrixSubCol(self, "", "", 0)
self._subcols.append(subcol)
else:
for i, subkpi in enumerate(subkpis):
subcol = KpiMatrixSubCol(self, subkpi.description, "", i)
self._subcols.append(subcol)
self._cell_tuples_by_row = {} # {row: (cells tuple)}
def _set_cell_tuple(self, row, cell_tuple):
self._cell_tuples_by_row[row] = cell_tuple
def iter_subcols(self):
return self._subcols
def iter_cell_tuples(self):
return self._cell_tuples_by_row.values()
def get_cell_tuple_for_row(self, row):
return self._cell_tuples_by_row.get(row)
class KpiMatrixSubCol:
def __init__(self, col, label, description, index=0):
self.col = col
self.label = label
self.description = description
self.index = index
@property
def subkpi(self):
if self.col.subkpis:
return self.col.subkpis[self.index]
def iter_cells(self):
for cell_tuple in self.col.iter_cell_tuples():
yield cell_tuple[self.index]
def get_cell_for_row(self, row):
cell_tuple = self.col.get_cell_tuple_for_row(row)
if cell_tuple is None:
return None
return cell_tuple[self.index]
class KpiMatrixCell: # noqa: B903 (immutable data class)
def __init__(
self,
row,
subcol,
val,
val_rendered,
val_comment,
style_props,
drilldown_arg,
val_type,
):
self.row = row
self.subcol = subcol
self.val = val
self.val_rendered = val_rendered
self.val_comment = val_comment
self.style_props = style_props
self.drilldown_arg = drilldown_arg
self.val_type = val_type
self.cell_id = KpiMatrix._pack_cell_id(self)
class KpiMatrix:
def __init__(self, env, multi_company=False, account_model="account.account"):
# cache language id for faster rendering
lang_model = env["res.lang"]
self.lang = lang_model._lang_get(env.user.lang)
self._style_model = env["mis.report.style"]
self._account_model = env[account_model]
# data structures
# { kpi: KpiMatrixRow }
self._kpi_rows = OrderedDict()
# { kpi: {account_id: KpiMatrixRow} }
self._detail_rows = {}
# { col_key: KpiMatrixCol }
self._cols = OrderedDict()
# { col_key (left of comparison): [(col_key, base_col_key)] }
self._comparison_todo = defaultdict(list)
# { col_key (left of sum): (col_key, [(sign, sum_col_key)])
self._sum_todo = {}
# { account_id: account_name }
self._account_names = {}
self._multi_company = multi_company
def declare_kpi(self, kpi):
"""Declare a new kpi (row) in the matrix.
Invoke this first for all kpi, in display order.
"""
self._kpi_rows[kpi] = KpiMatrixRow(self, kpi)
self._detail_rows[kpi] = {}
def declare_col(self, col_key, label, description, locals_dict, subkpis):
"""Declare a new column, giving it an identifier (key).
Invoke the declare_* methods in display order.
"""
col = KpiMatrixCol(col_key, label, description, locals_dict, subkpis)
self._cols[col_key] = col
return col
def declare_comparison(
self, cmpcol_key, col_key, base_col_key, label, description=None
):
"""Declare a new comparison column.
Invoke the declare_* methods in display order.
"""
self._comparison_todo[cmpcol_key] = (col_key, base_col_key, label, description)
self._cols[cmpcol_key] = None # reserve slot in insertion order
def declare_sum(
self, sumcol_key, col_to_sum_keys, label, description=None, sum_accdet=False
):
"""Declare a new summation column.
Invoke the declare_* methods in display order.
:param col_to_sum_keys: [(sign, col_key)]
"""
self._sum_todo[sumcol_key] = (col_to_sum_keys, label, description, sum_accdet)
self._cols[sumcol_key] = None # reserve slot in insertion order
def set_values(self, kpi, col_key, vals, drilldown_args, tooltips=True):
"""Set values for a kpi and a colum.
Invoke this after declaring the kpi and the column.
"""
self.set_values_detail_account(
kpi, col_key, None, vals, drilldown_args, tooltips
)
def set_values_detail_account(
self, kpi, col_key, account_id, vals, drilldown_args, tooltips=True
):
"""Set values for a kpi and a column and a detail account.
Invoke this after declaring the kpi and the column.
"""
if not account_id:
row = self._kpi_rows[kpi]
else:
kpi_row = self._kpi_rows[kpi]
if account_id in self._detail_rows[kpi]:
row = self._detail_rows[kpi][account_id]
else:
row = KpiMatrixRow(self, kpi, account_id, parent_row=kpi_row)
self._detail_rows[kpi][account_id] = row
col = self._cols[col_key]
cell_tuple = []
assert len(vals) == col.colspan
assert len(drilldown_args) == col.colspan
for val, drilldown_arg, subcol in zip(vals, drilldown_args, col.iter_subcols()): # noqa: B905
if isinstance(val, DataError):
val_rendered = val.name
val_comment = val.msg
else:
val_rendered = self._style_model.render(
self.lang, row.style_props, kpi.type, val
)
if row.kpi.multi and subcol.subkpi:
val_comment = "{}.{} = {}".format(
row.kpi.name,
subcol.subkpi.name,
row.kpi._get_expression_str_for_subkpi(subcol.subkpi),
)
else:
val_comment = f"{row.kpi.name} = {row.kpi.expression}"
cell_style_props = row.style_props
if row.kpi.style_expression:
# evaluate style expression
try:
style_name = mis_safe_eval(
row.kpi.style_expression, col.locals_dict
)
except Exception:
_logger.error(
"Error evaluating style expression <%s>",
row.kpi.style_expression,
exc_info=True,
)
if style_name:
style = self._style_model.search([("name", "=", style_name)])
if style:
cell_style_props = self._style_model.merge(
[row.style_props, style[0]]
)
else:
_logger.error("Style '%s' not found.", style_name)
cell = KpiMatrixCell(
row,
subcol,
val,
val_rendered,
tooltips and val_comment or None,
cell_style_props,
drilldown_arg,
kpi.type,
)
cell_tuple.append(cell)
assert len(cell_tuple) == col.colspan
col._set_cell_tuple(row, cell_tuple)
def _common_subkpis(self, cols):
if not cols:
return set()
common_subkpis = set(cols[0].subkpis)
for col in cols[1:]:
common_subkpis = common_subkpis & set(col.subkpis)
return common_subkpis
def compute_comparisons(self):
"""Compute comparisons.
Invoke this after setting all values.
"""
for (
cmpcol_key,
(col_key, base_col_key, label, description),
) in self._comparison_todo.items():
col = self._cols[col_key]
base_col = self._cols[base_col_key]
common_subkpis = self._common_subkpis([col, base_col])
if (col.subkpis or base_col.subkpis) and not common_subkpis:
raise UserError(
_(
"Columns %(descr)s and %(base_descr)s are not comparable",
descr=col.description,
base_descr=base_col.description,
)
)
if not label:
label = f"{col.label} vs {base_col.label}"
comparison_col = KpiMatrixCol(
cmpcol_key,
label,
description,
{},
sorted(common_subkpis, key=lambda s: s.sequence),
)
self._cols[cmpcol_key] = comparison_col
for row in self.iter_rows():
cell_tuple = col.get_cell_tuple_for_row(row)
base_cell_tuple = base_col.get_cell_tuple_for_row(row)
if cell_tuple is None and base_cell_tuple is None:
continue
if cell_tuple is None:
vals = [AccountingNone] * (len(common_subkpis) or 1)
else:
vals = [
cell.val
for cell in cell_tuple
if not common_subkpis or cell.subcol.subkpi in common_subkpis
]
if base_cell_tuple is None:
base_vals = [AccountingNone] * (len(common_subkpis) or 1)
else:
base_vals = [
cell.val
for cell in base_cell_tuple
if not common_subkpis or cell.subcol.subkpi in common_subkpis
]
comparison_cell_tuple = []
for val, base_val, comparison_subcol in zip( # noqa: B905
vals,
base_vals,
comparison_col.iter_subcols(),
):
# TODO FIXME average factors
comparison = self._style_model.compare_and_render(
self.lang,
row.style_props,
row.kpi.type,
row.kpi.compare_method,
val,
base_val,
1,
1,
)
delta, delta_r, delta_style, delta_type = comparison
comparison_cell_tuple.append(
KpiMatrixCell(
row,
comparison_subcol,
delta,
delta_r,
None,
delta_style,
None,
delta_type,
)
)
comparison_col._set_cell_tuple(row, comparison_cell_tuple)
def compute_sums(self):
"""Compute comparisons.
Invoke this after setting all values.
"""
for (
sumcol_key,
(col_to_sum_keys, label, description, sum_accdet),
) in self._sum_todo.items():
sumcols = [self._cols[k] for (sign, k) in col_to_sum_keys]
# TODO check all sumcols are resolved; we need a kind of
# recompute queue here so we don't depend on insertion
# order
common_subkpis = self._common_subkpis(sumcols)
if any(c.subkpis for c in sumcols) and not common_subkpis:
raise UserError(
_(
"Sum cannot be computed in column {} "
"because the columns to sum have no "
"common subkpis"
).format(label)
)
sum_col = KpiMatrixCol(
sumcol_key,
label,
description,
{},
sorted(common_subkpis, key=lambda s: s.sequence),
)
self._cols[sumcol_key] = sum_col
for row in self.iter_rows():
acc = SimpleArray([AccountingNone] * (len(common_subkpis) or 1))
if row.kpi.accumulation_method == ACC_SUM and not (
row.account_id and not sum_accdet
):
for sign, col_to_sum in col_to_sum_keys:
cell_tuple = self._cols[col_to_sum].get_cell_tuple_for_row(row)
if cell_tuple is None:
vals = [AccountingNone] * (len(common_subkpis) or 1)
else:
vals = [
cell.val
for cell in cell_tuple
if not common_subkpis
or cell.subcol.subkpi in common_subkpis
]
if sign == "+":
acc += SimpleArray(vals)
else:
acc -= SimpleArray(vals)
self.set_values_detail_account(
row.kpi,
sumcol_key,
row.account_id,
acc,
[None] * (len(common_subkpis) or 1),
tooltips=False,
)
def iter_rows(self):
"""Iterate rows in display order.
yields KpiMatrixRow.
"""
for kpi_row in self._kpi_rows.values():
yield kpi_row
detail_rows = self._detail_rows[kpi_row.kpi].values()
detail_rows = sorted(detail_rows, key=lambda r: r.label)
yield from detail_rows
def iter_cols(self):
"""Iterate columns in display order.
yields KpiMatrixCol: one for each column or comparison.
"""
for _col_key, col in self._cols.items():
yield col
def iter_subcols(self):
"""Iterate sub columns in display order.
yields KpiMatrixSubCol: one for each subkpi in each column
and comparison.
"""
for col in self.iter_cols():
yield from col.iter_subcols()
def _load_account_names(self):
account_ids = set()
for detail_rows in self._detail_rows.values():
account_ids.update(detail_rows.keys())
accounts = self._account_model.search([("id", "in", list(account_ids))])
self._account_names = {a.id: self._get_account_name(a) for a in accounts}
def _get_account_name(self, account):
result = f"{account.code} {account.name}"
if self._multi_company:
result = f"{result} [{account.company_id.name}]"
return result
def get_account_name(self, account_id):
if account_id not in self._account_names:
self._load_account_names()
return self._account_names[account_id]
def as_dict(self):
header = [{"cols": []}, {"cols": []}]
for col in self.iter_cols():
header[0]["cols"].append(
{
"label": col.label,
"description": col.description,
"colspan": col.colspan,
}
)
for subcol in col.iter_subcols():
header[1]["cols"].append(
{
"label": subcol.label,
"description": subcol.description,
"colspan": 1,
}
)
body = []
for row in self.iter_rows():
if (
row.style_props.hide_empty and row.is_empty()
) or row.style_props.hide_always:
continue
row_data = {
"row_id": row.row_id,
"parent_row_id": (row.parent_row and row.parent_row.row_id or None),
"label": row.label,
"description": row.description,
"style": self._style_model.to_css_style(row.style_props),
"cells": [],
}
for cell in row.iter_cells():
if cell is None:
# TODO use subcol style here
row_data["cells"].append({})
else:
if cell.val is AccountingNone or isinstance(cell.val, DataError):
val = None
else:
val = cell.val
col_data = {
"cell_id": cell.cell_id,
"val": val,
"val_r": cell.val_rendered,
"val_c": cell.val_comment,
"style": self._style_model.to_css_style(
cell.style_props, no_indent=True
),
# notes can not be added on 'details by account' lines
"can_be_annotated": not cell.row.account_id,
}
if cell.drilldown_arg:
col_data["drilldown_arg"] = cell.drilldown_arg
row_data["cells"].append(col_data)
body.append(row_data)
return {"header": header, "body": body}
# Logic to convert semantic coordinates (period, kpi, subkpi)
# to visual coordinates (cell id) and back. The rendering logic musn't know
# about semantic concepts such as periods and kpis. Having these well identified
# methods allow us to easily spot where the conversion between the rendering and
# semantic domain occur.
@classmethod
def _make_row_id(cls, kpi_id: int, account_id: int | None) -> str:
return f"{kpi_id}:{account_id or ''}"
@classmethod
def _make_cell_id(
cls, kpi_id: int, account_id: int | None, period_id: int, subkpi_id: int | None
) -> str:
return f"{kpi_id}#{account_id or ''}#{period_id}#{subkpi_id or ''}"
@classmethod
def _pack_cell_id(cls, cell: KpiMatrixCell) -> str:
return cls._make_cell_id(
cell.row.kpi.id,
cell.row.account_id,
cell.subcol.col.key,
cell.subcol.subkpi and cell.subcol.subkpi.id,
)
@classmethod
def _unpack_cell_id(cls, cell_id: str) -> tuple[int, int | None, int, int | None]:
kpi_id, account_id, col_key, subkpi_id = cell_id.split("#")
kpi_id = int(kpi_id)
account_id = int(account_id) if account_id else None
period_id = int(col_key)
subkpi_id = int(subkpi_id) if subkpi_id else None
return kpi_id, account_id, period_id, subkpi_id

View file

@ -0,0 +1,115 @@
# Copyright 2017 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
ACC_SUM = "sum"
ACC_AVG = "avg"
ACC_NONE = "none"
def intersect_days(item_dt_from, item_dt_to, dt_from, dt_to):
item_days = (item_dt_to - item_dt_from).days + 1.0
i_dt_from = max(dt_from, item_dt_from)
i_dt_to = min(dt_to, item_dt_to)
i_days = (i_dt_to - i_dt_from).days + 1.0
return i_days, item_days
class MisKpiData(models.AbstractModel):
"""Abstract class for manually entered KPI values."""
_name = "mis.kpi.data"
_description = "MIS Kpi Data Abtract class"
name = fields.Char(compute="_compute_name", required=False, readonly=True)
kpi_expression_id = fields.Many2one(
comodel_name="mis.report.kpi.expression",
required=True,
ondelete="restrict",
string="KPI",
)
date_from = fields.Date(required=True, string="From")
date_to = fields.Date(required=True, string="To")
amount = fields.Float()
seq1 = fields.Integer(
related="kpi_expression_id.kpi_id.sequence",
store=True,
readonly=True,
string="KPI Sequence",
)
seq2 = fields.Integer(
related="kpi_expression_id.subkpi_id.sequence",
store=True,
readonly=True,
string="Sub-KPI Sequence",
)
@api.depends(
"kpi_expression_id.subkpi_id.name",
"kpi_expression_id.kpi_id.name",
"date_from",
"date_to",
)
def _compute_name(self):
for rec in self:
subkpi_name = rec.kpi_expression_id.subkpi_id.name
if subkpi_name:
subkpi_name = "." + subkpi_name
else:
subkpi_name = ""
rec.name = "{}{}: {} - {}".format(
rec.kpi_expression_id.kpi_id.name,
subkpi_name,
rec.date_from,
rec.date_to,
)
@api.model
def _intersect_days(self, item_dt_from, item_dt_to, dt_from, dt_to):
return intersect_days(item_dt_from, item_dt_to, dt_from, dt_to)
@api.model
def _query_kpi_data(self, date_from, date_to, base_domain):
"""Query mis.kpi.data over a time period.
Returns {mis.report.kpi.expression: amount}
"""
dt_from = fields.Date.from_string(date_from)
dt_to = fields.Date.from_string(date_to)
# all data items within or overlapping [date_from, date_to]
date_domain = [("date_from", "<=", date_to), ("date_to", ">=", date_from)]
domain = expression.AND([date_domain, base_domain])
res = defaultdict(float)
res_avg = defaultdict(list)
for item in self.search(domain):
item_dt_from = fields.Date.from_string(item.date_from)
item_dt_to = fields.Date.from_string(item.date_to)
i_days, item_days = self._intersect_days(
item_dt_from, item_dt_to, dt_from, dt_to
)
if item.kpi_expression_id.kpi_id.accumulation_method == ACC_SUM:
# accumulate pro-rata overlap between item and reporting period
res[item.kpi_expression_id] += item.amount * i_days / item_days
elif item.kpi_expression_id.kpi_id.accumulation_method == ACC_AVG:
# memorize the amount and number of days overlapping
# the reporting period (used as weight in average)
res_avg[item.kpi_expression_id].append((i_days, item.amount))
else:
raise UserError(
_(
"Unexpected accumulation method %(method)s for %(name)s.",
method=item.kpi_expression_id.kpi_id.accumulation_method,
name=item.name,
)
)
# compute weighted average for ACC_AVG
for kpi_expression, amounts in res_avg.items():
res[kpi_expression] = sum(d * a for d, a in amounts) / sum(
d for d, a in amounts
)
return res

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import AccessError
from .kpimatrix import KpiMatrix
class MisReportInstanceAnnotation(models.Model):
_name = "mis.report.instance.annotation"
_description = "Mis Report Instance Annotation"
period_id = fields.Many2one(
comodel_name="mis.report.instance.period",
ondelete="cascade",
required=True,
)
kpi_id = fields.Many2one(
comodel_name="mis.report.kpi",
ondelete="cascade",
required=True,
)
subkpi_id = fields.Many2one(
comodel_name="mis.report.subkpi",
ondelete="cascade",
)
note = fields.Char()
annotation_context = fields.Json(
help="""
Context used when adding annotation
"""
)
def init(self):
self.env.cr.execute(
"""
CREATE INDEX IF NOT EXISTS
mis_report_instance_annotation_period_id_kpi_id_subkpi_id_idx
ON mis_report_instance_annotation(period_id,kpi_id,subkpi_id);
"""
)
@api.model
def _get_first_matching_annotation(self, cell_id, instance_id):
"""
Return first annoation
matching exactly the period,kpi,subkpi and annotation context
"""
kpi_id, _, period_id, subkpi_id = KpiMatrix._unpack_cell_id(cell_id)
annotations = self.env["mis.report.instance.annotation"].search(
[
("period_id", "=", period_id),
("kpi_id", "=", kpi_id),
("subkpi_id", "=", subkpi_id),
],
)
annotation_context = (
self.env["mis.report.instance"]
.browse(instance_id)
._get_annotation_context()
)
annotation = fields.first(
annotations.filtered(
lambda rec: rec.annotation_context == annotation_context
)
)
return annotation
@api.model
def set_annotation(self, cell_id, instance_id, note):
if (
not self.env["mis.report.instance"]
.browse(instance_id)
.user_can_edit_annotation
):
raise AccessError(_("You do not have the rights to edit annotations"))
annotation = self._get_first_matching_annotation(cell_id, instance_id)
if annotation:
annotation.note = note
else:
kpi_id, _account_id, period_id, subkpi_id = KpiMatrix._unpack_cell_id(
cell_id
)
self.env["mis.report.instance.annotation"].create(
{
"period_id": period_id,
"kpi_id": kpi_id,
"subkpi_id": subkpi_id,
"note": note,
"annotation_context": self.env["mis.report.instance"]
.browse(instance_id)
._get_annotation_context(),
}
)
@api.model
def remove_annotation(self, cell_id, instance_id):
if (
not self.env["mis.report.instance"]
.browse(instance_id)
.user_can_edit_annotation
):
raise AccessError(_("You do not have the rights to edit annotations"))
annotation = self._get_first_matching_annotation(cell_id, instance_id)
if annotation:
annotation.unlink()

View file

@ -0,0 +1,314 @@
# Copyright 2016 Therp BV (<http://therp.nl>)
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2020 CorporateHub (https://corporatehub.eu)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import sys
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from .accounting_none import AccountingNone
from .data_error import DataError
if sys.version_info.major >= 3:
unicode = str
class PropertyDict(dict):
def __getattr__(self, name):
return self.get(name)
def copy(self): # pylint: disable=copy-wo-api-one,method-required-super
return PropertyDict(self)
PROPS = [
"color",
"background_color",
"font_style",
"font_weight",
"font_size",
"indent_level",
"prefix",
"suffix",
"dp",
"divider",
"hide_empty",
"hide_always",
]
TYPE_NUM = "num"
TYPE_PCT = "pct"
TYPE_STR = "str"
CMP_DIFF = "diff"
CMP_PCT = "pct"
CMP_NONE = "none"
class MisReportKpiStyle(models.Model):
_name = "mis.report.style"
_description = "MIS Report Style"
@api.constrains("indent_level")
def check_positive_val(self):
for record in self:
if record.indent_level < 0:
raise ValidationError(
_("Indent level must be greater than " "or equal to 0")
)
_font_style_selection = [("normal", "Normal"), ("italic", "Italic")]
_font_weight_selection = [("nornal", "Normal"), ("bold", "Bold")]
_font_size_selection = [
("medium", "medium"),
("xx-small", "xx-small"),
("x-small", "x-small"),
("small", "small"),
("large", "large"),
("x-large", "x-large"),
("xx-large", "xx-large"),
]
_font_size_to_xlsx_size = {
"medium": 11,
"xx-small": 5,
"x-small": 7,
"small": 9,
"large": 13,
"x-large": 15,
"xx-large": 17,
}
# style name
name = fields.Char(string="Style name", required=True)
# color
color_inherit = fields.Boolean(default=True)
color = fields.Char(
string="Text color",
help="Text color in valid RGB code (from #000000 to #FFFFFF)",
default="#000000",
)
background_color_inherit = fields.Boolean(default=True)
background_color = fields.Char(
help="Background color in valid RGB code (from #000000 to #FFFFFF)",
default="#FFFFFF",
)
# font
font_style_inherit = fields.Boolean(default=True)
font_style = fields.Selection(selection=_font_style_selection)
font_weight_inherit = fields.Boolean(default=True)
font_weight = fields.Selection(selection=_font_weight_selection)
font_size_inherit = fields.Boolean(default=True)
font_size = fields.Selection(selection=_font_size_selection)
# indent
indent_level_inherit = fields.Boolean(default=True)
indent_level = fields.Integer()
# number format
prefix_inherit = fields.Boolean(default=True)
prefix = fields.Char()
suffix_inherit = fields.Boolean(default=True)
suffix = fields.Char()
dp_inherit = fields.Boolean(default=True)
dp = fields.Integer(string="Rounding", default=0)
divider_inherit = fields.Boolean(default=True)
divider = fields.Selection(
[
("1e-6", _("µ")),
("1e-3", _("m")),
("1", _("1")),
("1e3", _("k")),
("1e6", _("M")),
],
string="Factor",
default="1",
)
hide_empty_inherit = fields.Boolean(default=True)
hide_empty = fields.Boolean(default=False)
hide_always_inherit = fields.Boolean(default=True)
hide_always = fields.Boolean(default=False)
_sql_constraints = [
("style_name_uniq", "unique(name)", "Style name should be unique")
]
@api.model
def merge(self, styles):
"""Merge several styles, giving priority to the last.
Returns a PropertyDict of style properties.
"""
r = PropertyDict()
for style in styles:
if not style:
continue
if isinstance(style, dict):
r.update(style)
else:
for prop in PROPS:
inherit = getattr(style, prop + "_inherit", None)
if not inherit:
value = getattr(style, prop)
r[prop] = value
return r
@api.model
def render(self, lang, style_props, var_type, value, sign="-"):
if var_type == TYPE_NUM:
return self.render_num(
lang,
value,
style_props.divider,
style_props.dp,
style_props.prefix,
style_props.suffix,
sign=sign,
)
elif var_type == TYPE_PCT:
return self.render_pct(lang, value, style_props.dp, sign=sign)
else:
return self.render_str(lang, value)
@api.model
def render_num(
self, lang, value, divider=1.0, dp=0, prefix=None, suffix=None, sign="-"
):
# format number following user language
if value is None or value is AccountingNone:
return ""
value = round(value / float(divider or 1), dp or 0) or 0
r = lang.format("%%%s.%df" % (sign, dp or 0), value, grouping=True)
r = r.replace("-", "\N{NON-BREAKING HYPHEN}")
if prefix:
r = prefix + "\N{NO-BREAK SPACE}" + r
if suffix:
r = r + "\N{NO-BREAK SPACE}" + suffix
return r
@api.model
def render_pct(self, lang, value, dp=1, sign="-"):
return self.render_num(lang, value, divider=0.01, dp=dp, suffix="%", sign=sign)
@api.model
def render_str(self, lang, value):
if value is None or value is AccountingNone:
return ""
return unicode(value)
@api.model
def compare_and_render(
self,
lang,
style_props,
var_type,
compare_method,
value,
base_value,
average_value=1,
average_base_value=1,
):
"""
:param lang: res.lang record
:param style_props: PropertyDict with style properties
:param var_type: num, pct or str
:param compare_method: diff, pct, none
:param value: value to compare (value - base_value)
:param base_value: value compared with (value - base_value)
:param average_value: value = value / average_value
:param average_base_value: base_value = base_value / average_base_value
:return: tuple with 4 elements
- delta = comparison result (Float or AccountingNone)
- delta_r = delta rendered in formatted string (String)
- delta_style = PropertyDict with style properties
- delta_type = Type of the comparison result (num or pct)
"""
delta = AccountingNone
delta_r = ""
delta_style = style_props.copy()
delta_type = TYPE_NUM
if isinstance(value, DataError) or isinstance(base_value, DataError):
return AccountingNone, "", delta_style, delta_type
if value is None:
value = AccountingNone
if base_value is None:
base_value = AccountingNone
if var_type == TYPE_PCT:
delta = value - base_value
if delta and round(delta, (style_props.dp or 0) + 2) != 0:
delta_style.update(divider=0.01, prefix="", suffix=_("pp"))
else:
delta = AccountingNone
elif var_type == TYPE_NUM:
if value and average_value:
# pylint: disable=redefined-variable-type
value = value / float(average_value)
if base_value and average_base_value:
# pylint: disable=redefined-variable-type
base_value = base_value / float(average_base_value)
if compare_method == CMP_DIFF:
delta = value - base_value
if delta and round(delta, style_props.dp or 0) != 0:
pass
else:
delta = AccountingNone
elif compare_method == CMP_PCT:
if base_value and round(base_value, style_props.dp or 0) != 0:
delta = (value - base_value) / abs(base_value)
if delta and round(delta, 3) != 0:
delta_style.update(dp=1)
delta_type = TYPE_PCT
else:
delta = AccountingNone
if delta is not AccountingNone:
delta_r = self.render(lang, delta_style, delta_type, delta, sign="+")
return delta, delta_r, delta_style, delta_type
@api.model
def to_xlsx_style(self, var_type, props, no_indent=False):
xlsx_attributes = [
("italic", props.font_style == "italic"),
("bold", props.font_weight == "bold"),
("font_size", self._font_size_to_xlsx_size.get(props.font_size, 11)),
("font_color", props.color),
("bg_color", props.background_color),
]
if var_type == TYPE_NUM:
num_format = "#,##0"
if props.dp:
num_format += "."
num_format += "0" * props.dp
if props.prefix:
num_format = f'"{props.prefix} "{num_format}'
if props.suffix:
num_format = f'{num_format}" {props.suffix}"'
xlsx_attributes.append(("num_format", num_format))
elif var_type == TYPE_PCT:
num_format = "0"
if props.dp:
num_format += "."
num_format += "0" * props.dp
num_format += "%"
xlsx_attributes.append(("num_format", num_format))
if props.indent_level is not None and not no_indent:
xlsx_attributes.append(("indent", props.indent_level))
return dict([a for a in xlsx_attributes if a[1] is not None])
@api.model
def to_css_style(self, props, no_indent=False):
css_attributes = [
("font-style", props.font_style),
("font-weight", props.font_weight),
("font-size", props.font_size),
("color", props.color),
("background-color", props.background_color),
]
if props.indent_level is not None and not no_indent:
css_attributes.append(("text-indent", f"{props.indent_level}em"))
return (
"; ".join(["{}: {}".format(*a) for a in css_attributes if a[1] is not None])
or None
)

View file

@ -0,0 +1,74 @@
# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from .mis_report import _is_valid_python_var
class ParentLoopError(ValidationError):
pass
class InvalidNameError(ValidationError):
pass
class MisReportSubReport(models.Model):
_name = "mis.report.subreport"
_description = "MIS Report - Sub Reports Relation"
name = fields.Char(required=True)
report_id = fields.Many2one(
comodel_name="mis.report",
required=True,
ondelete="cascade",
)
subreport_id = fields.Many2one(
comodel_name="mis.report",
required=True,
ondelete="restrict",
)
_sql_constraints = [
(
"name_unique",
"unique(name, report_id)",
"Subreport name should be unique by report",
),
(
"subreport_unique",
"unique(subreport_id, report_id)",
"Should not include the same report more than once as sub report "
"of a given report",
),
]
@api.constrains("name")
def _check_name(self):
for rec in self:
if not _is_valid_python_var(rec.name):
raise InvalidNameError(
_("Subreport name ({}) must be a valid python identifier").format(
rec.name
)
)
@api.constrains("report_id", "subreport_id")
def _check_loop(self):
def _has_subreport(reports, report):
if not reports:
return False
if report in reports:
return True
return any(
_has_subreport(r.subreport_ids.mapped("subreport_id"), report)
for r in reports
)
for rec in self:
if _has_subreport(rec.subreport_id, rec.report_id):
raise ParentLoopError(_("Subreport loop detected"))
# TODO check subkpi compatibility in subreports

View file

@ -0,0 +1,33 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import traceback
from odoo.tools.safe_eval import _BUILTINS, _SAFE_OPCODES, test_expr
from .data_error import DataError, NameDataError
__all__ = ["mis_safe_eval"]
def mis_safe_eval(expr, locals_dict):
"""Evaluate an expression using safe_eval
Returns the evaluated value or DataError.
Raises NameError if the evaluation depends on a variable that is not
present in local_dict.
"""
try:
c = test_expr(expr, _SAFE_OPCODES, mode="eval")
globals_dict = {"__builtins__": _BUILTINS}
# pylint: disable=eval-used,eval-referenced
val = eval(c, globals_dict, locals_dict)
except NameError:
val = NameDataError("#NAME", traceback.format_exc())
except ZeroDivisionError:
# pylint: disable=redefined-variable-type
val = DataError("#DIV/0", traceback.format_exc())
except Exception:
val = DataError("#ERR", traceback.format_exc())
return val

View file

@ -0,0 +1,96 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.fields import Date
from .mis_kpi_data import intersect_days
class ProRataReadGroupMixin(models.AbstractModel):
_name = "prorata.read_group.mixin"
_description = "Adapt model with date_from/date_to for pro-rata temporis read_group"
date_from = fields.Date(required=True)
date_to = fields.Date(required=True)
date = fields.Date(
compute=lambda self: None,
search="_search_date",
help=(
"Dummy field that adapts searches on date "
"to searches on date_from/date_to."
),
)
def _search_date(self, operator, value):
if operator in (">=", ">"):
return [("date_to", operator, value)]
elif operator in ("<=", "<"):
return [("date_from", operator, value)]
raise UserError(
_("Unsupported operator %s for searching on date") % (operator,)
)
@api.model
def _intersect_days(self, item_dt_from, item_dt_to, dt_from, dt_to):
return intersect_days(item_dt_from, item_dt_to, dt_from, dt_to)
@api.model
def read_group(
self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True
):
"""Override read_group to perform pro-rata temporis adjustments.
When read_group is invoked with a domain that filters on
a time period (date >= from and date <= to, or
date_from <= to and date_to >= from), adjust the accumulated
values pro-rata temporis.
"""
date_from = None
date_to = None
assert isinstance(domain, list)
for domain_item in domain:
if isinstance(domain_item, list | tuple):
field, op, value = domain_item
if field == "date" and op == ">=":
date_from = value
elif field == "date_to" and op == ">=":
date_from = value
elif field == "date" and op == "<=":
date_to = value
elif field == "date_from" and op == "<=":
date_to = value
if (
date_from is not None
and date_to is not None
and not any(":" in f for f in fields)
):
dt_from = Date.from_string(date_from)
dt_to = Date.from_string(date_to)
res = {}
sum_fields = set(fields) - set(groupby)
read_fields = set(fields + ["date_from", "date_to"])
for item in self.search(domain).read(read_fields):
key = tuple(item[k] for k in groupby)
if key not in res:
res[key] = {k: item[k] for k in groupby}
res[key].update({k: 0.0 for k in sum_fields})
res_item = res[key]
for sum_field in sum_fields:
item_dt_from = Date.from_string(item["date_from"])
item_dt_to = Date.from_string(item["date_to"])
i_days, item_days = self._intersect_days(
item_dt_from, item_dt_to, dt_from, dt_to
)
res_item[sum_field] += item[sum_field] * i_days / item_days
return res.values()
return super().read_group(
domain,
fields,
groupby,
offset=offset,
limit=limit,
orderby=orderby,
lazy=lazy,
)

View file

@ -0,0 +1,184 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
""" A trivial immutable array that supports basic arithmetic operations.
>>> a = SimpleArray((1.0, 2.0, 3.0))
>>> b = SimpleArray((4.0, 5.0, 6.0))
>>> t = (4.0, 5.0, 6.0)
>>> +a
SimpleArray((1.0, 2.0, 3.0))
>>> -a
SimpleArray((-1.0, -2.0, -3.0))
>>> a + b
SimpleArray((5.0, 7.0, 9.0))
>>> b + a
SimpleArray((5.0, 7.0, 9.0))
>>> a + t
SimpleArray((5.0, 7.0, 9.0))
>>> t + a
SimpleArray((5.0, 7.0, 9.0))
>>> a - b
SimpleArray((-3.0, -3.0, -3.0))
>>> a - t
SimpleArray((-3.0, -3.0, -3.0))
>>> t - a
SimpleArray((3.0, 3.0, 3.0))
>>> a * b
SimpleArray((4.0, 10.0, 18.0))
>>> b * a
SimpleArray((4.0, 10.0, 18.0))
>>> a * t
SimpleArray((4.0, 10.0, 18.0))
>>> t * a
SimpleArray((4.0, 10.0, 18.0))
>>> a / b
SimpleArray((0.25, 0.4, 0.5))
>>> b / a
SimpleArray((4.0, 2.5, 2.0))
>>> a / t
SimpleArray((0.25, 0.4, 0.5))
>>> t / a
SimpleArray((4.0, 2.5, 2.0))
>>> b / 2
SimpleArray((2.0, 2.5, 3.0))
>>> 2 * b
SimpleArray((8.0, 10.0, 12.0))
>>> 1 - b
SimpleArray((-3.0, -4.0, -5.0))
>>> b += 2 ; b
SimpleArray((6.0, 7.0, 8.0))
>>> a / ((1.0, 0.0, 1.0))
SimpleArray((1.0, DataError('#DIV/0'), 3.0))
>>> a / 0.0
SimpleArray((DataError('#DIV/0'), DataError('#DIV/0'), DataError('#DIV/0')))
>>> a * ((1.0, 'a', 1.0))
SimpleArray((1.0, DataError('#ERR'), 3.0))
>>> 6.0 / a
SimpleArray((6.0, 3.0, 2.0))
>>> Vector = named_simple_array('Vector', ('x', 'y'))
>>> p1 = Vector((1, 2))
>>> print(p1.x, p1.y, p1)
1 2 Vector((1, 2))
>>> p2 = Vector((2, 3))
>>> print(p2.x, p2.y, p2)
2 3 Vector((2, 3))
>>> p3 = p1 + p2
>>> print(p3.x, p3.y, p3)
3 5 Vector((3, 5))
>>> p4 = (4, 5) + p2
>>> print(p4.x, p4.y, p4)
6 8 Vector((6, 8))
>>> p1 * 2
Vector((2, 4))
>>> 2 * p1
Vector((2, 4))
>>> p1 - 1
Vector((0, 1))
>>> 1 - p1
Vector((0, -1))
>>> p1 / 2.0
Vector((0.5, 1.0))
>>> v = 2.0 / p1
>>> print(v.x, v.y, v)
2.0 1.0 Vector((2.0, 1.0))
"""
import itertools
import operator
import traceback
from .data_error import DataError
__all__ = ["SimpleArray", "named_simple_array"]
class SimpleArray(tuple):
def _op(self, op, other):
def _o2(x, y):
try:
return op(x, y)
except ZeroDivisionError:
return DataError("#DIV/0", traceback.format_exc())
except Exception:
return DataError("#ERR", traceback.format_exc())
if isinstance(other, tuple):
if len(other) != len(self):
raise TypeError("tuples must have same length for %s" % op)
return self.__class__(map(_o2, self, other))
else:
return self.__class__(_o2(z, other) for z in self)
def _cast(self, other):
if isinstance(other, self.__class__):
return other
elif isinstance(other, tuple):
return self.__class__(other)
else:
# other is a scalar
return self.__class__(itertools.repeat(other, len(self)))
def __add__(self, other):
return self._op(operator.add, other)
__radd__ = __add__
def __pos__(self):
return self.__class__(map(operator.pos, self))
def __neg__(self):
return self.__class__(map(operator.neg, self))
def __sub__(self, other):
return self._op(operator.sub, other)
def __rsub__(self, other):
return self._cast(other)._op(operator.sub, self)
def __mul__(self, other):
return self._op(operator.mul, other)
__rmul__ = __mul__
def __div__(self, other):
return self._op(operator.div, other)
def __floordiv__(self, other):
return self._op(operator.floordiv, other)
def __truediv__(self, other):
return self._op(operator.truediv, other)
def __rdiv__(self, other):
return self._cast(other)._op(operator.div, self)
def __rfloordiv__(self, other):
return self._cast(other)._op(operator.floordiv, self)
def __rtruediv__(self, other):
return self._cast(other)._op(operator.truediv, self)
def __repr__(self):
return f"{self.__class__.__name__}({tuple.__repr__(self)})"
def named_simple_array(typename, field_names):
"""Return a subclass of SimpleArray, with named properties.
This method is to SimpleArray what namedtuple is to tuple.
It's less sophisticated than namedtuple so some namedtuple
advanced use cases may not work, but it's good enough for
our needs in mis_builder, ie referring to subkpi values
by name.
"""
props = {
field_name: property(operator.itemgetter(i))
for i, field_name in enumerate(field_names)
}
return type(typename, (SimpleArray,), props)
if __name__ == "__main__": # pragma: no cover
import doctest
doctest.testmod()

View file

@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View file

@ -0,0 +1,27 @@
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Adrien Peiffer <adrien.peiffer@acsone.eu>
* Alexis de Lattre <alexis.delattre@akretion.com>
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
* Jordi Ballester <jordi.ballester@eficent.com>
* Thomas Binsfeld <thomas.binsfeld@gmail.com>
* Giovanni Capalbo <giovanni@therp.nl>
* Marco Calcagni <mcalcagni@dinamicheaziendali.it>
* Sébastien Beau <sebastien.beau@akretion.com>
* Laurent Mignon <laurent.mignon@acsone.eu>
* Luc De Meyer <luc.demeyer@noviat.com>
* Benjamin Willig <benjamin.willig@acsone.eu>
* Martronic SA <info@martronic.ch>
* nicomacr <nmr@adhoc.com.ar>
* Juan Jose Scarafia <jjs@adhoc.com.ar>
* Richard deMeester <richard@willowit.com.au>
* Eric Caudal <eric.caudal@elico-corp.com>
* Andrea Stirpe <a.stirpe@onestein.nl>
* Maxence Groine <mgroine@fiefmanage.ch>
* Arnaud Pineux <arnaud.pineux@acsone.eu>
* Ernesto Tejeda <ernesto.tejeda@tecnativa.com>
* Pedro M. Baeza <pedro.baeza@tecnativa.com>
* Alexey Pelykh <alexey.pelykh@corphub.eu>
* Jairo Llopis (https://www.moduon.team/)
* Dzung Tran <dungtd@trobz.com>
* Hoang Diep <hoang@trobz.com>

View file

@ -0,0 +1,5 @@
This module allows you to build Management Information Systems dashboards.
Such style of reports presents KPI in rows and time periods in columns.
Reports mainly fetch data from account moves, but can also combine data coming
from arbitrary Odoo models. Reports can be exported to PDF, Excel and they
can be added to Odoo dashboards.

View file

@ -0,0 +1,6 @@
A typical extension is to provide a mechanism to filter reports on analytic dimensions
or operational units. To implement this, you can override _get_additional_move_line_filter
and _get_additional_filter to further filter move lines or queries based on a user
selection. A typical use case could be to add an analytic account field on mis.report.instance,
or even on mis.report.instance.period if you want different columns to show different
analytic accounts.

View file

@ -0,0 +1,539 @@
16.0.5.1.9 (2024-02-09)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Restore compatibility with python 3.9 (`#590 <https://github.com/OCA/mis-builder/issues/590>`_)
16.0.5.1.8 (2024-02-08)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Resolve a permission issue when creating report periods with a user without admin rights. (`#596 <https://github.com/OCA/mis-builder/issues/596>`_)
16.0.5.1.0 (2023-04-04)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Improve UX by adding the option to edit the pivot date directly on the view.
16.0.5.0.0 (2023-04-01)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Migration to 16.0
- Addition of a generic filter domain on reports and columns.
- Addition of a search bar to the widget. The corresponding search view is configurable
per report.
- Huge improvement of the widget style. This was long overdue.
- Make the MIS Report menu accessible to the Billing Administrator group
(instead of the hidden Show Full Accounting Features), to align with the access rules
and avoid giving a false sense of security. This also makes the menu discoverable to
new users.
- Removal of analytic fetures because the upstream ``analytic_distribution`` mechanism
is not compatible; support may be introduced in separate module, depending on use
cases.
- Abandon the ``mis_report_filters`` context key which had security implication.
It is replaced by a ``mis_analytic_domain`` context key which is ANDed with other
report-defined filters. (`#472 <https://github.com/OCA/mis-builder/issues/472>`_)
- Rename the ``get_filter_descriptions_from_context`` method to
``get_filter_descriptions``. This method may be overridden to provide additional
subtitles on the PDF or XLS report, representing user-selected filters.
- The ``hide_analytic_filters`` has been replaced by ``widget_show_filters``.
- The visibility of the settings button on the widget is now controlled by a
``show_settings_button``. Before it was visible only for the ``account_user`` group
but this was not flexible enough.
- The widget configuration settings are now grouped in a dedicated ``Widget`` tab in
the report configuration form.
**Bugfixes**
- Fix access error when previewing or printing report. (`#415 <https://github.com/OCA/mis-builder/issues/415>`_)
15.0.4.0.5 (2022-07-19)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Support users without timezone. (`#388 <https://github.com/OCA/mis-builder/issues/388>`_)
15.0.4.0.4 (2022-07-19)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Allow deleting a report that has subreports. (`#431 <https://github.com/OCA/mis-builder/issues/431>`_)
15.0.4.0.2 (2022-02-16)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix access right issue when clicking the "Save" button on a MIS Report Instance form. (`#410 <https://github.com/OCA/mis-builder/issues/410>`_)
14.0.4.0.0 (2022-01-08)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Remove various field size limits. (`#332 <https://github.com/OCA/mis-builder/issues/332>`_)
**Bugfixes**
- Support for the Odoo 13+ multi-company model. In multi-company mode, several allowed
companies can be declared on MIS Report instances, and the report operates on the
intersection of report companies and companies selected in the user context. (`#327 <https://github.com/OCA/mis-builder/issues/327>`_)
- The ``get_additional_query_filter`` argument of ``evaluate()`` is now propagated
correctly. (`#375 <https://github.com/OCA/mis-builder/issues/375>`_)
- Use the ``parent_state`` field of ``account.move.line`` to filter entries in ``posted``
and ``draft`` state only. Before, when reporting in draft mode, all entries were used
(i.e. there was no filter), and that started including the cancelled entries/invoices in
Odoo 13.+.
This change also contains a **breaking change** in the internal API. For quite a while
the ``target_move argument`` of AEP and other methods was not used by MIS Builder itself
and was kept for backward compatibility. To avoid rippling effects of the necessary
change to use ``parent_state``, we now remove this argument. (`#377 <https://github.com/OCA/mis-builder/issues/377>`_)
14.0.3.6.7 (2021-06-02)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- When on a MIS Report Instance, if you wanted to generate a new line of type comparison, you couldn't currently select any existing period to compare.
This happened because the field domain was searching in a NewId context, thus not finding a correct period.
Changing the domain and making it use a computed field with a search for the _origin record solves the problem. (`#361 <https://github.com/OCA/mis-builder/issues/361>`_)
14.0.3.6.6 (2021-04-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix drilldown action name when the account model has been customized. (`#350 <https://github.com/OCA/mis-builder/issues/350>`_)
14.0.3.6.5 (2021-04-23)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- While duplicating a MIS report instance, comparison columns are ignored because
they would raise an error otherwise, as they keep the old source_cmpcol_from_id
and source_cmpcol_to_id from the original record. (`#343 <https://github.com/OCA/mis-builder/issues/343>`_)
14.0.3.6.4 (2021-04-06)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- The drilldown action name displayed on the breadcrumb has been revised.
The kpi description and the account ``display_name`` are shown instead
of the kpi's technical definition. (`#304 <https://github.com/OCA/mis-builder/issues/304>`_)
- Add analytic group filters on report instance, periods and in the interactive
view. (`#320 <https://github.com/OCA/mis-builder/issues/320>`_)
13.0.3.6.3 (2020-08-28)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Having a "Compare columns" added on a KPI with an associated style using a
Factor/Divider did lead to the said factor being applied on the percentages
when exporting to XLSX. (`#300 <https://github.com/OCA/mis-builder/issues/300>`_)
**Misc**
- `#280 <https://github.com/OCA/mis-builder/issues/280>`_, `#296 <https://github.com/OCA/mis-builder/issues/296>`_
13.0.3.6.2 (2020-04-22)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- The "Settings" button is now displayed for users with the "Show full accounting features" right when previewing a report. (`#281 <https://github.com/OCA/mis-builder/issues/281>`_)
13.0.3.6.1 (2020-04-22)
~~~~~~~~~~~~~~~~~~~~~~~
**Bugfixes**
- Fix ``TypeError: 'module' object is not iterable`` when using
budgets by account. (`#276 <https://github.com/OCA/mis-builder/issues/276>`_)
13.0.3.6.0 (2020-03-28)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- Add column-level filters on analytic account and analytic tags.
These filters are combined with a AND with the report-level filters
and cannot be modified in the preview. (`#138 <https://github.com/OCA/mis-builder/issues/138>`_)
- Access to KPI from other reports in KPI expressions, aka subreports. In a
report template, one can list named "subreports" (other report templates). When
evaluating expressions, you can access KPI's of subreports with a dot-prefix
notation. Example: you can define a MIS Report for a "Balance Sheet", and then
have another MIS Report "Balance Sheet Ratios" that fetches KPI's from "Balance
Sheet" to create new KPI's for the ratios (e.g. balance_sheet.current_assets /
balance_sheet.total_assets). (`#155 <https://github.com/OCA/mis-builder/issues/155>`_)
13.0.3.5.0 (2020-01-??)
~~~~~~~~~~~~~~~~~~~~~~~
Migration to odoo 13.0.
12.0.3.5.0 (2019-10-26)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- The ``account_id`` field of the model selected in 'Move lines source'
in the Period form can now be a Many2one
relationship with any model that has a ``code`` field (not only with
``account.account`` model). To this end, the model to be used for Actuals
move lines can be configured on the report template. It can be something else
than move lines and the only constraint is that its ``account_id`` field
has a ``code`` field. (`#149 <https://github.com/oca/mis-builder/issues/149>`_)
- Add ``source_aml_model_name`` field so extension modules providing
alternative data sources can more easily customize their data source. (`#214 <https://github.com/oca/mis-builder/issues/214>`_)
- Support analytic tag filters in the backend view and preview widget.
Selecting several tags in the filter means filtering on move lines which
have *all* these tags set. This is to support the most common use case of
using tags for different dimensions. The filter also makes a AND with the
analytic account filter. (`#228 <https://github.com/oca/mis-builder/issues/228>`_)
- Display company in account details rows in multi-company mode. (`#242 <https://github.com/oca/mis-builder/issues/242>`_)
**Bugfixes**
- Propagate context to xlsx report, so the analytic account filter
works when exporting to xslx too. This also requires a fix to
``report_xlsx`` (see https://github.com/OCA/reporting-engine/pull/259). (`#178 <https://github.com/oca/mis-builder/issues/178>`_)
- In columns of type Sum, preserve styles for KPIs that are not summable
(eg percentage values). Before this fix, such cells were displayed without
style. (`#219 <https://github.com/oca/mis-builder/issues/219>`_)
- In Excel export, keep the percentage point suffix (pp) instead of replacing it with %. (`#220 <https://github.com/oca/mis-builder/issues/220>`_)
12.0.3.4.0 (2019-07-09)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
- New year-to-date mode for defining periods. (`#165 <https://github.com/oca/mis-builder/issues/165>`_)
- Add support for move lines with negative debit or credit.
Used by some for storno accounting. Not officially supported. (`#175 <https://github.com/oca/mis-builder/issues/175>`_)
- In Excel export, use a number format with thousands separator. The
specific separator used depends on the Excel configuration (eg regional
settings). (`#190 <https://github.com/oca/mis-builder/issues/190>`_)
- Add generation date/time at the end of the XLS export. (`#191 <https://github.com/oca/mis-builder/issues/191>`_)
- In presence of Sub KPIs, report more informative user errors when
non-multi expressions yield tuples of incorrect lenght. (`#196 <https://github.com/oca/mis-builder/issues/196>`_)
**Bugfixes**
- Fix rendering of percentage types in Excel export. (`#192 <https://github.com/oca/mis-builder/issues/192>`_)
12.0.3.3.0 (2019-01-26)
~~~~~~~~~~~~~~~~~~~~~~~
**Features**
*Dynamic analytic filters in report preview are not yet available in 11,
this requires an update to the JS widget that proved difficult to implement
so far. Help welcome.*
- Analytic account filters. On a report, an analytic
account can be selected for filtering. The filter will
be applied to move lines queries. A filter box is also
available in the widget to let the user select the analytic
account during report preview. (`#15 <https://github.com/oca/mis-builder/issues/15>`_)
- Control visibility of analytic filter combo box in widget.
This is useful to hide the analytic filters on reports where
they do not make sense, such as balance sheet reports. (`#42 <https://github.com/oca/mis-builder/issues/42>`_)
- Display analytic filters in the header of exported pdf and xls. (`#44 <https://github.com/oca/mis-builder/issues/44>`_)
- Replace the last old gtk icons with fontawesome icons. (`#104 <https://github.com/oca/mis-builder/issues/104>`_)
- Use active_test=False in AEP queries.
This is important for reports involving inactive taxes.
This should not negatively effect existing reports, because
an accounting report must take into account all existing move lines
even if they reference objects such as taxes, journals, accounts types
that have been deactivated since their creation. (`#107 <https://github.com/oca/mis-builder/issues/107>`_)
- int(), float() and round() support for AccountingNone. (`#108 <https://github.com/oca/mis-builder/issues/108>`_)
- Allow referencing subkpis by name by writing `kpi_x.subkpi_y` in expressions. (`#114 <https://github.com/oca/mis-builder/issues/114>`_)
- Add an option to control the display of the start/end dates in the
column headers. It is disabled by default (this is a change compared
to previous behaviour). (`#118 <https://github.com/oca/mis-builder/issues/118>`_)
- Add evaluate method to mis.report. This is a simplified
method to evaluate kpis of a report over a time period,
without creating a mis.report.instance. (`#123 <https://github.com/oca/mis-builder/issues/123>`_)
**Bugs**
- In the style form, hide the "Hide always" checkbox when "Hide always inherit"
is checked, as for all other syle elements. (`#121 <https://github.com/OCA/mis-builder/pull/121>_`)
**Upgrading from 3.2 (breaking changes)**
If you use ``Actuals (alternative)`` data source in combination with analytic
filters, the underlying model must now have an ``analytic_account_id`` field.
11.0.3.2.2 (2018-06-30)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] Fix bug in company_default_get call returning
id instead of recordset
(`#103 <https://github.com/OCA/mis-builder/pull/103>`_)
* [IMP] add "hide always" style property to make hidden KPI's
(for KPI that serve as basis for other formulas, but do not
need to be displayed).
(`#46 <https://github.com/OCA/mis-builder/issues/46>`_)
11.0.3.2.1 (2018-05-29)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] Missing comparison operator for AccountingNone
leading to errors in pbal computations
(`#93 <https://github.com/OCA/mis-builder/issue/93>`_)
10.0.3.2.0 (2018-05-02)
~~~~~~~~~~~~~~~~~~~~~~~
* [FIX] make subkpi ordering deterministic
(`#71 <https://github.com/OCA/mis-builder/issues/71>`_)
* [ADD] report instance level option to disable account expansion,
enabling the creation of detailed templates while deferring the decision
of rendering the details or not to the report instance
(`#74 <https://github.com/OCA/mis-builder/issues/74>`_)
* [ADD] pbal and nbal accounting expressions, to sum positive
and negative balances respectively (ie ignoring accounts with negative,
resp positive balances)
(`#86 <https://github.com/OCA/mis-builder/issues/86>`_)
11.0.3.1.2 (2018-02-04)
~~~~~~~~~~~~~~~~~~~~~~~
Migration to Odoo 11. No new feature.
(`#67 <https://github.com/OCA/mis-builder/pull/67>`_)
10.0.3.1.1 (2017-11-14)
~~~~~~~~~~~~~~~~~~~~~~~
New features:
* [ADD] month and year relative periods, easier to use than
date ranges for the most common case.
(`#2 <https://github.com/OCA/mis-builder/issues/2>`_)
* [ADD] multi-company consolidation support, with currency conversion
(the conversion rate date is the end of the reporting period)
(`#7 <https://github.com/OCA/mis-builder/issues/7>`_,
`#3 <https://github.com/OCA/mis-builder/issues/3>`_)
* [ADD] provide ref, datetime, dateutil, time, user in the evaluation
context of move line domains; among other things, this allows using
references to xml ids (such as account types or tax tags) when
querying move lines
(`#26 <https://github.com/OCA/mis-builder/issues/26>`_).
* [ADD] extended account selectors: you can now select accounts using
any domain on account.account, not only account codes
``balp[('account_type', '=', 'asset_receivable')]``
(`#4 <https://github.com/OCA/mis-builder/issues/4>`_).
* [IMP] in the report instance configuration form, the filters are
now grouped in a notebook page, this improves readability and
extensibility
(`#39 <https://github.com/OCA/mis-builder/issues/39>`_).
Bug fixes:
* [FIX] fix error when saving periods in comparison mode on newly
created (not yet saved) report instances.
`#50 <https://github.com/OCA/mis-builder/pull/50>`_
* [FIX] improve display of Base Date report instance view.
`#51 <https://github.com/OCA/mis-builder/pull/51>`_
Upgrading from 3.0 (breaking changes):
* Alternative move line data sources must have a company_id field.
10.0.3.0.4 (2017-10-14)
~~~~~~~~~~~~~~~~~~~~~~~
Bug fix:
* [FIX] issue with initial balance rounding.
`#30 <https://github.com/OCA/mis-builder/issues/30>`_
10.0.3.0.3 (2017-10-03)
~~~~~~~~~~~~~~~~~~~~~~~
Bug fix:
* [FIX] fix error saving KPI on newly created reports.
`#18 <https://github.com/OCA/mis-builder/issues/18>`_
10.0.3.0.2 (2017-10-01)
~~~~~~~~~~~~~~~~~~~~~~~
New features:
* [ADD] Alternative move line source per report column.
This makes mis buidler accounting expressions work on any model
that has debit, credit, account_id and date fields. Provided you can
expose, say, committed purchases, or your budget as a view with
debit, credit and account_id, this opens up a lot of possibilities
* [ADD] Comparison column source (more flexible than the previous,
now deprecated, comparison mechanism).
CAVEAT: there is no automated migration to the new mechanism.
* [ADD] Sum column source, to create columns that add/subtract
other columns.
* [ADD] mis.kpi.data abstract model as a basis for manual KPI values
supporting automatic ajustment to the reporting time period (the basis
for budget item, but could also server other purposes, such as manually
entering some KPI values, such as number of employee)
* [ADD] mis_builder_budget module providing a new budget data source
* [ADD] new "hide empty" style property
* [IMP] new AEP method to get accounts involved in an expression
(this is useful to find which KPI relate to a given P&L
acount, to implement budget control)
* [IMP] many UI improvements
* [IMP] many code style improvements and some refactoring
* [IMP] add the column date_from, date_to in expression evaluation context,
as well as time, datetime and dateutil modules
Main bug fixes:
* [FIX] deletion of templates and reports (cascade and retricts)
(https://github.com/OCA/account-financial-reporting/issues/281)
* [FIX] copy of reports
(https://github.com/OCA/account-financial-reporting/issues/282)
* [FIX] better error message when periods have wrong/missing dates
(https://github.com/OCA/account-financial-reporting/issues/283)
* [FIX] xlsx export of string types KPI
(https://github.com/OCA/account-financial-reporting/issues/285)
* [FIX] sorting of detail by account
* [FIX] computation bug in detail by account when multiple accounting
expressions were used in a KPI
* [FIX] permission issue when adding report to dashboard with non admin user
10.0.2.0.3 (unreleased)
~~~~~~~~~~~~~~~~~~~~~~~
* [IMP] more robust behaviour in presence of missing expressions
* [FIX] indent style
* [FIX] local variable 'ctx' referenced before assignment when generating
reports with no objects
* [IMP] use fontawesome icons
* [MIG] migrate to 10.0
* [FIX] unicode error when exporting to Excel
* [IMP] provide full access to mis builder style for group Adviser.
9.0.2.0.2 (2016-09-27)
~~~~~~~~~~~~~~~~~~~~~~
* [IMP] Add refresh button in mis report preview.
* [IMP] Widget code changes to allow to add fields in the widget more easily.
9.0.2.0.1 (2016-05-26)
~~~~~~~~~~~~~~~~~~~~~~
* [IMP] remove unused argument in declare_and_compute_period()
for a cleaner API. This is a breaking API changing merged in
urgency before it is used by other modules.
9.0.2.0.0 (2016-05-24)
~~~~~~~~~~~~~~~~~~~~~~
Part of the work for this release has been done at the Sorrento sprint
April 26-29, 2016. The rest (ie a major refactoring) has been done in
the weeks after.
* [IMP] hide button box in edit mode on the report instance settings form
* [FIX] Fix sum aggregation of non-stored fields
(https://github.com/OCA/account-financial-reporting/issues/178)
* [IMP] There is now a default style at the report level
* [CHG] Number display properties (rounding, prefix, suffix, factor) are
now defined in styles
* [CHG] Percentage difference are rounded to 1 digit instead of the kpi's
rounding, as the KPI rounding does not make sense in this case
* [CHG] The divider suffix (k, M, etc) is not inserted automatically anymore
because it is inconsistent when working with prefixes; you need to add it
manually in the suffix
* [IMP] AccountingExpressionProcessor now supports 'balu' expressions
to obtain the unallocated profit/loss of previous fiscal years;
get_unallocated_pl is the corresponding convenience method
* [IMP] AccountingExpressionProcessor now has easy methods to obtain
balances by account: get_balances_initial, get_balances_end,
get_balances_variation
* [IMP] there is now an auto-expand feature to automatically display
a detail by account for selected kpis
* [IMP] the kpi and period lists are now manipulated through forms instead
of directly in the tree views
* [IMP] it is now possible to create a report through a wizard, such
reports are deemed temporary and available through a "Last Reports Generated"
menu, they are garbaged collected automatically, unless saved permanently,
which can be done using a Save button
* [IMP] there is now a beginner mode to configure simple reports with
only one period
* [IMP] it is now easier to configure periods with fixed start/end dates
* [IMP] the new sub-kpi mechanism allows the creation of columns
with multiple values, or columns with different values
* [IMP] thanks to the new style model, the Excel export is now styled
* [IMP] a new style model is now used to centralize style configuration
* [FIX] use =like instead of like to search for accounts, because
the % are added by the user in the expressions
* [FIX] Correctly compute the initial balance of income and expense account
based on the start of the fiscal year
* [IMP] Support date ranges (from OCA/server-tools/date_range) as a more
flexible alternative to fiscal periods
* v9 migration: fiscal periods are removed, account charts are removed,
consolidation accounts have been removed
8.0.1.0.0 (2016-04-27)
~~~~~~~~~~~~~~~~~~~~~~
* The copy of a MIS Report Instance now copies period.
https://github.com/OCA/account-financial-reporting/pull/181
* The copy of a MIS Report Template now copies KPIs and queries.
https://github.com/OCA/account-financial-reporting/pull/177
* Usability: the default view for MIS Report instances is now the rendered preview,
and the settings are accessible through a gear icon in the list view and
a button in the preview.
https://github.com/OCA/account-financial-reporting/pull/170
* Display blank cells instead of 0.0 when there is no data.
https://github.com/OCA/account-financial-reporting/pull/169
* Usability: better layout of the MIS Report periods settings on small screens.
https://github.com/OCA/account-financial-reporting/pull/167
* Include the download buttons inside the MIS Builder widget, and refactor
the widget to open the door to analytic filtering in the previews.
https://github.com/OCA/account-financial-reporting/pull/151
* Add KPI rendering prefixes (so you can print $ in front of the value).
https://github.com/OCA/account-financial-reporting/pull/158
* Add hooks for analytic filtering.
https://github.com/OCA/account-financial-reporting/pull/128
https://github.com/OCA/account-financial-reporting/pull/131
8.0.0.2.0
~~~~~~~~~
Pre-history. Or rather, you need to look at the git log.

View file

@ -0,0 +1,7 @@
Your preferred way to install addons will work with MIS Builder.
An easy way to install it with all its dependencies is using pip:
* ``pip install --pre odoo12-addon-mis_builder``
* then restart Odoo, update the addons list in your database, and install
the MIS Builder application.

View file

@ -0,0 +1,3 @@
The mis_builder `roadmap <https://github.com/OCA/mis-builder/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement>`_
and `known issues <https://github.com/OCA/mis-builder/issues?q=is%3Aopen+is%3Aissue+label%3Abug>`_ can
be found on GitHub.

View file

@ -0,0 +1,26 @@
To configure this module, you need to:
* Go to Accounting > Configuration > MIS Reporting > MIS Report Templates where
you can create report templates by defining KPI's. KPI's constitute the rows of your
reports. Such report templates are time independent.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_template.png
:alt: Sample report template
:width: 80 %
:align: center
* Then in Accounting > Reports > MIS Reporting > MIS Reports you can create report instance by
binding the templates to time periods, hence defining the columns of your reports.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_settings.png
:alt: Sample report configuration
:width: 80 %
:align: center
* From the MIS Reports view, you can preview the report, add it to and Odoo dashboard,
and export it to PDF or Excel.
.. figure:: https://raw.githubusercontent.com/OCA/mis-builder/10.0/mis_builder/static/description/ex_report_preview.png
:alt: Sample preview
:width: 80 %
:align: center

View file

@ -0,0 +1,5 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import mis_report_instance_qweb
from . import mis_report_instance_xlsx

View file

@ -0,0 +1,27 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class Report(models.Model):
_inherit = "ir.actions.report"
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
if (
self._get_report(report_ref).report_name
== "mis_builder.report_mis_report_instance"
):
if not res_ids:
res_ids = self.env.context.get("active_ids")
mis_report_instance = self.env["mis.report.instance"].browse(res_ids)[0]
# data=None, because it was there only to force Odoo
# to propagate context
return super(
Report, self.with_context(landscape=mis_report_instance.landscape_pdf)
)._render_qweb_pdf(report_ref, res_ids, data=None)
return super()._render_qweb_pdf(report_ref, res_ids, data)

View file

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="qweb_pdf_export" model="ir.actions.report">
<field name="name">MIS report instance QWEB PDF report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report</field>
<field name="report_name">mis_builder.report_mis_report_instance</field>
<field name="report_type">qweb-pdf</field>
</record>
<!--
TODO we use divs with css table layout, but this has drawbacks:
(bad layout of first column, no colspan for first header row),
consider getting back to a plain HTML table.
-->
<template id="report_mis_report_instance">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.internal_layout">
<t t-set="matrix" t-value="o._compute_matrix()" />
<t t-set="notes" t-value="o.get_notes_by_cell_id()" />
<t t-set="style_obj" t-value="o.env['mis.report.style']" />
<div class="page">
<h3>
<span t-field="o.name" />
<span>-</span>
<t t-foreach="o.query_company_ids" t-as="company">
<span t-field="company.name" />
<span t-if="company != o.query_company_ids[-1]">,</span>
</t>
</h3>
<p>
<div class="mis_report_filers">
<t
t-foreach="o.get_filter_descriptions()"
t-as="filter_description"
>
<div>
<span t-out="filter_description" />
</div>
</t>
</div>
</p>
<div class="mis_table">
<div class="mis_thead">
<div class="mis_row">
<div class="mis_cell mis_collabel" />
<t t-foreach="matrix.iter_cols()" t-as="col">
<div class="mis_cell mis_collabel">
<t t-out="col.label" />
<t t-if="col.description">
<br />
<t t-out="col.description" />
</t>
</div>
<!-- add empty cells because we have no colspan with css tables -->
<t
t-foreach="list(col.iter_subcols())[1:]"
t-as="subcol"
>
<div class="mis_cell mis_collabel" />
</t>
</t>
</div>
<div class="mis_row">
<div class="mis_cell mis_collabel" />
<t t-foreach="matrix.iter_subcols()" t-as="subcol">
<div class="mis_cell mis_collabel">
<t t-out="subcol.label" />
<t t-if="subcol.description">
<br />
<t t-out="subcol.description" />
</t>
</div>
</t>
</div>
</div>
<div class="mis_tbody">
<t t-foreach="matrix.iter_rows()" t-as="row">
<div
t-if="not ((row.style_props.hide_empty and row.is_empty()) or row.style_props.hide_always)"
class="mis_row"
>
<div
t-att-style="style_obj.to_css_style(row.style_props)"
class="mis_cell mis_rowlabel"
>
<t t-out="row.label" />
<t t-if="row.description">
<br />
<t t-out="row.description" />
</t>
</div>
<t t-foreach="row.iter_cells()" t-as="cell">
<div
t-att-style="cell and style_obj.to_css_style(cell.style_props) or ''"
class="mis_cell mis_amount"
>
<t
t-out="cell and cell.val_rendered or ''"
/>
<span
class="oe_mis_builder_footnote"
t-if="cell"
>
<t
t-out="notes.get(cell.cell_id,{}).get('sequence','')"
/>
</span>
</div>
</t>
</div>
</t>
</div>
</div>
<!-- Foot notes -->
<div class="oe_mis_builder_footnote_div">
<table class="oe_mis_builder_footnote_table">
<t
t-foreach="sorted(notes.values(),key=lambda r:r['sequence'])"
t-as="note"
>
<tr>
<td><t t-out="note['sequence']" />. </td>
<td><t t-out="note['text']" /></td>
</tr>
</t>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View file

@ -0,0 +1,186 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
import numbers
from collections import defaultdict
from datetime import datetime
from odoo import _, api, fields, models
from ..models.accounting_none import AccountingNone
from ..models.data_error import DataError
from ..models.mis_report_style import TYPE_STR
_logger = logging.getLogger(__name__)
ROW_HEIGHT = 15 # xlsxwriter units
COL_WIDTH = 0.9 # xlsxwriter units
MIN_COL_WIDTH = 10 # characters
MAX_COL_WIDTH = 50 # characters
class MisBuilderXlsx(models.AbstractModel):
_name = "report.mis_builder.mis_report_instance_xlsx"
_description = "MIS Builder XLSX report"
_inherit = "report.report_xlsx.abstract"
@api.model
def _mis_builder_add_annotation(self, sheet, cell, row_pos, col_pos, notes):
"""
Add anotation as a comment on cell in .xls
"""
if cell and (annotation := notes.get(cell.cell_id, {}).get("text")):
sheet.write_comment(row_pos, col_pos, annotation)
def generate_xlsx_report(self, workbook, data, objects):
# get the computed result of the report
matrix = objects._compute_matrix()
notes = objects.get_notes_by_cell_id()
style_obj = self.env["mis.report.style"]
# create worksheet
report_name = "{} - {}".format(
objects[0].name, ", ".join([a.name for a in objects[0].query_company_ids])
)
sheet = workbook.add_worksheet(report_name[:31])
row_pos = 0
col_pos = 0
# width of the labels column
label_col_width = MIN_COL_WIDTH
# {col_pos: max width in characters}
col_width = defaultdict(lambda: MIN_COL_WIDTH)
# document title
bold = workbook.add_format({"bold": True})
header_format = workbook.add_format(
{"bold": True, "align": "center", "bg_color": "#F0EEEE"}
)
sheet.write(row_pos, 0, report_name, bold)
row_pos += 2
# filters
filter_descriptions = objects.get_filter_descriptions()
if filter_descriptions:
for filter_description in objects.get_filter_descriptions():
sheet.write(row_pos, 0, filter_description)
row_pos += 1
row_pos += 1
# column headers
sheet.write(row_pos, 0, "", header_format)
col_pos = 1
for col in matrix.iter_cols():
label = col.label
if col.description:
label += "\n" + col.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
if col.colspan > 1:
sheet.merge_range(
row_pos,
col_pos,
row_pos,
col_pos + col.colspan - 1,
label,
header_format,
)
else:
sheet.write(row_pos, col_pos, label, header_format)
col_width[col_pos] = max(
col_width[col_pos], len(col.label or ""), len(col.description or "")
)
col_pos += col.colspan
row_pos += 1
# sub column headers
sheet.write(row_pos, 0, "", header_format)
col_pos = 1
for subcol in matrix.iter_subcols():
label = subcol.label
if subcol.description:
label += "\n" + subcol.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
sheet.write(row_pos, col_pos, label, header_format)
col_width[col_pos] = max(
col_width[col_pos],
len(subcol.label or ""),
len(subcol.description or ""),
)
col_pos += 1
row_pos += 1
# rows
for row in matrix.iter_rows():
if (
row.style_props.hide_empty and row.is_empty()
) or row.style_props.hide_always:
continue
row_xlsx_style = style_obj.to_xlsx_style(TYPE_STR, row.style_props)
row_format = workbook.add_format(row_xlsx_style)
col_pos = 0
label = row.label
if row.description:
label += "\n" + row.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
sheet.write(row_pos, col_pos, label, row_format)
label_col_width = max(
label_col_width, len(row.label or ""), len(row.description or "")
)
for cell in row.iter_cells():
col_pos += 1
self._mis_builder_add_annotation(sheet, cell, row_pos, col_pos, notes)
if not cell or cell.val is AccountingNone:
# TODO col/subcol format
sheet.write(row_pos, col_pos, "", row_format)
continue
cell_xlsx_style = style_obj.to_xlsx_style(
cell.val_type, cell.style_props, no_indent=True
)
cell_xlsx_style["align"] = "right"
cell_format = workbook.add_format(cell_xlsx_style)
if isinstance(cell.val, DataError):
val = cell.val.name
# TODO display cell.val.msg as Excel comment?
elif cell.val is None or cell.val is AccountingNone:
val = ""
else:
divider = float(cell.style_props.get("divider", 1))
if (
divider != 1
and isinstance(cell.val, numbers.Number)
and not cell.val_type == "pct"
):
val = cell.val / divider
else:
val = cell.val
sheet.write(row_pos, col_pos, val, cell_format)
col_width[col_pos] = max(
col_width[col_pos], len(cell.val_rendered or "")
)
row_pos += 1
# Add date/time footer
row_pos += 1
footer_format = workbook.add_format(
{"italic": True, "font_color": "#202020", "font_size": 9}
)
lang_model = self.env["res.lang"]
lang = lang_model._lang_get(self.env.user.lang)
now_tz = fields.Datetime.context_timestamp(
self.env["res.users"], datetime.now()
)
create_date = _(
"Generated on %(gen_date)s at %(gen_time)s",
gen_date=now_tz.strftime(lang.date_format),
gen_time=now_tz.strftime(lang.time_format),
)
sheet.write(row_pos, 0, create_date, footer_format)
# adjust col widths
sheet.set_column(0, 0, min(label_col_width, MAX_COL_WIDTH) * COL_WIDTH)
data_col_width = min(MAX_COL_WIDTH, max(col_width.values()))
min_col_pos = min(col_width.keys())
max_col_pos = max(col_width.keys())
sheet.set_column(min_col_pos, max_col_pos, data_col_width * COL_WIDTH)

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="xls_export" model="ir.actions.report">
<field name="name">MIS report instance XLS report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report</field>
<field name="report_name">mis_builder.mis_report_instance_xlsx</field>
<field name="report_type">xlsx</field>
<field name="report_file">mis_report_instance</field>
</record>
</odoo>

View file

@ -0,0 +1,24 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
manage_mis_report_kpi,manage_mis_report_kpi,model_mis_report_kpi,account.group_account_manager,1,1,1,1
access_mis_report_kpi,access_mis_report_kpi,model_mis_report_kpi,base.group_user,1,0,0,0
manage_mis_report_query,manage_mis_report_query,model_mis_report_query,account.group_account_manager,1,1,1,1
access_mis_report_query,access_mis_report_query,model_mis_report_query,base.group_user,1,0,0,0
manage_mis_report,manage_mis_report,model_mis_report,account.group_account_manager,1,1,1,1
access_mis_report,access_mis_report,model_mis_report,base.group_user,1,0,0,0
manage_mis_report_instance_period,manage_mis_report_instance_period,model_mis_report_instance_period,account.group_account_manager,1,1,1,1
access_mis_report_instance_period,access_mis_report_instance_period,model_mis_report_instance_period,base.group_user,1,0,0,0
manage_mis_report_instance_period_sum,manage_mis_report_instance_period_sum,model_mis_report_instance_period_sum,account.group_account_manager,1,1,1,1
access_mis_report_instance_period_sum,access_mis_report_instance_period_sum,model_mis_report_instance_period_sum,base.group_user,1,0,0,0
manage_mis_report_instance,manage_mis_report_instance,model_mis_report_instance,account.group_account_manager,1,1,1,1
access_mis_report_instance,access_mis_report_instance,model_mis_report_instance,base.group_user,1,0,0,0
manage_mis_report_subkpi,access_mis_report_subkpi,model_mis_report_subkpi,account.group_account_manager,1,1,1,1
access_mis_report_subkpi,access_mis_report_subkpi,model_mis_report_subkpi,base.group_user,1,0,0,0
manage_mis_report_kpi_expression,access_mis_report_kpi_expression,model_mis_report_kpi_expression,account.group_account_manager,1,1,1,1
access_mis_report_kpi_expression,access_mis_report_kpi_expression,model_mis_report_kpi_expression,base.group_user,1,0,0,0
manage_mis_report_subreport,access_mis_report_subreport,model_mis_report_subreport,account.group_account_manager,1,1,1,1
access_mis_report_subreport,access_mis_report_subreport,model_mis_report_subreport,base.group_user,1,0,0,0
manage_mis_report_style,access_mis_report_style,model_mis_report_style,account.group_account_manager,1,1,1,1
access_mis_report_style,access_mis_report_style,model_mis_report_style,base.group_user,1,0,0,0
access_add_to_dashboard_wizard,access_add_to_dashboard_wizard,model_add_mis_report_instance_dashboard_wizard,base.group_user,1,1,1,0
access_read_mis_report_annotation, access_read_mis_report_annotation,model_mis_report_instance_annotation,mis_builder.group_read_annotation,1,0,0,0
access_edit_mis_report_annotation, access_edit_mis_report_annotation,model_mis_report_instance_annotation,mis_builder.group_edit_annotation,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 manage_mis_report_kpi manage_mis_report_kpi model_mis_report_kpi account.group_account_manager 1 1 1 1
3 access_mis_report_kpi access_mis_report_kpi model_mis_report_kpi base.group_user 1 0 0 0
4 manage_mis_report_query manage_mis_report_query model_mis_report_query account.group_account_manager 1 1 1 1
5 access_mis_report_query access_mis_report_query model_mis_report_query base.group_user 1 0 0 0
6 manage_mis_report manage_mis_report model_mis_report account.group_account_manager 1 1 1 1
7 access_mis_report access_mis_report model_mis_report base.group_user 1 0 0 0
8 manage_mis_report_instance_period manage_mis_report_instance_period model_mis_report_instance_period account.group_account_manager 1 1 1 1
9 access_mis_report_instance_period access_mis_report_instance_period model_mis_report_instance_period base.group_user 1 0 0 0
10 manage_mis_report_instance_period_sum manage_mis_report_instance_period_sum model_mis_report_instance_period_sum account.group_account_manager 1 1 1 1
11 access_mis_report_instance_period_sum access_mis_report_instance_period_sum model_mis_report_instance_period_sum base.group_user 1 0 0 0
12 manage_mis_report_instance manage_mis_report_instance model_mis_report_instance account.group_account_manager 1 1 1 1
13 access_mis_report_instance access_mis_report_instance model_mis_report_instance base.group_user 1 0 0 0
14 manage_mis_report_subkpi access_mis_report_subkpi model_mis_report_subkpi account.group_account_manager 1 1 1 1
15 access_mis_report_subkpi access_mis_report_subkpi model_mis_report_subkpi base.group_user 1 0 0 0
16 manage_mis_report_kpi_expression access_mis_report_kpi_expression model_mis_report_kpi_expression account.group_account_manager 1 1 1 1
17 access_mis_report_kpi_expression access_mis_report_kpi_expression model_mis_report_kpi_expression base.group_user 1 0 0 0
18 manage_mis_report_subreport access_mis_report_subreport model_mis_report_subreport account.group_account_manager 1 1 1 1
19 access_mis_report_subreport access_mis_report_subreport model_mis_report_subreport base.group_user 1 0 0 0
20 manage_mis_report_style access_mis_report_style model_mis_report_style account.group_account_manager 1 1 1 1
21 access_mis_report_style access_mis_report_style model_mis_report_style base.group_user 1 0 0 0
22 access_add_to_dashboard_wizard access_add_to_dashboard_wizard model_add_mis_report_instance_dashboard_wizard base.group_user 1 1 1 0
23 access_read_mis_report_annotation access_read_mis_report_annotation model_mis_report_instance_annotation mis_builder.group_read_annotation 1 0 0 0
24 access_edit_mis_report_annotation access_edit_mis_report_annotation model_mis_report_instance_annotation mis_builder.group_edit_annotation 1 1 1 1

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="mis_builder_multi_company_rule" model="ir.rule">
<field name="name">Mis Report Instance multi company</field>
<field name="model_id" ref="model_mis_report_instance" />
<field name="domain_force">
['|',('company_id','=',False),('company_id','in',company_ids), '|',
('company_ids', '=', False), ('company_ids', 'in', company_ids)]
</field>
</record>
</odoo>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="res.groups" id="group_read_annotation">
<field name="name">MIS Report: view annotations</field>
</record>
<record model="res.groups" id="group_edit_annotation">
<field name="name">MIS Report: add annotations</field>
<field
name="implied_ids"
eval="[Command.link(ref('mis_builder.group_read_annotation'))]"
/>
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,108 @@
.o_web_client .mis_builder_amount {
text-align: right;
}
.o_web_client .mis_builder_collabel {
text-align: center;
}
.o_web_client .mis_builder_rowlabel {
text-align: left;
}
.o_web_client .mis_builder a {
/* we don't want the link color, to respect user styles */
color: inherit;
}
.o_web_client .mis_builder a:hover {
/* underline links on hover to give a visual cue */
text-decoration: underline;
}
.oe_mis_builder_content {
}
.oe_mis_builder_report_wide_sheet {
max-width: 95% !important;
}
/* style for the control panel (search box and buttons) */
.oe_mis_builder_cp {
display: flex;
flex-direction: row;
padding-bottom: 20px;
}
.oe_mis_builder_cp_left {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.oe_mis_builder_cp_right {
display: flex;
flex-direction: column;
flex-grow: 2;
max-width: 1280px;
}
.oe_mis_builder_cp_right_top_right {
display: flex;
flex-direction: row;
}
.oe_mis_builder_cp_right_top {
display: flex;
flex-direction: row;
}
.oe_mis_builder_cp_right_bottom {
display: flex;
flex-direction: row;
}
.oe_mis_builder_filter_buttons {
display: flex;
flex-grow: 1;
justify-content: flex-start;
}
.oe_mis_builder_action_buttons {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
.oe_mis_builder_dropdown {
overflow: visible !important;
}
.oe_mis_builder_footnote {
font-size: 80%;
color: red;
position: relative;
bottom: 1ex;
width: 1em;
display: inline-block;
padding-right: 1px;
}
.oe_mis_builder_footnote_table {
list-style: none;
white-space: pre-wrap;
display: inline-block;
td {
vertical-align: top;
}
}
.oe_mis_builder_footnote_div {
padding-top: 1em;
}
.oe_mis_builder_menu_disabled {
color: gainsboro;
}

View file

@ -0,0 +1,287 @@
/** @odoo-module **/
import Dialog from "web.Dialog";
import {Component, onMounted, onWillStart, useState, useSubEnv} from "@odoo/owl";
import {DatePicker} from "@web/core/datepicker/datepicker";
import {FilterMenu} from "@web/search/filter_menu/filter_menu";
import {SearchBar} from "@web/search/search_bar/search_bar";
import {SearchModel} from "@web/search/search_model";
import {parseDate} from "@web/core/l10n/dates";
import {qweb} from "web.core";
import {registry} from "@web/core/registry";
import {useBus, useService} from "@web/core/utils/hooks";
export class MisReportWidget extends Component {
setup() {
super.setup();
this.orm = useService("orm");
this.user = useService("user");
this.action = useService("action");
this.view = useService("view");
this.JSON = JSON;
this.state = useState({
mis_report_data: {header: [], body: [], notes: {}},
pivot_date: null,
can_edit_annotation: false,
can_read_annotation: false,
});
this.searchModel = new SearchModel(this.env, {
user: this.user,
orm: this.orm,
view: this.view,
});
useSubEnv({searchModel: this.searchModel});
useBus(this.env.searchModel, "update", async () => {
await this.env.searchModel.sectionsPromise;
this.refresh();
});
onWillStart(this.willStart);
onMounted(this._onMounted);
}
// Lifecycle
async willStart() {
const [result] = await this.orm.read(
"mis.report.instance",
[this._instanceId()],
[
"source_aml_model_name",
"widget_show_filters",
"widget_show_settings_button",
"widget_search_view_id",
"pivot_date",
"widget_show_pivot_date",
"user_can_read_annotation",
"user_can_edit_annotation",
"wide_display_by_default",
],
{context: this.context}
);
this.source_aml_model_name = result.source_aml_model_name;
this.widget_show_filters = result.widget_show_filters;
this.widget_show_settings_button = result.widget_show_settings_button;
this.widget_search_view_id =
result.widget_search_view_id && result.widget_search_view_id[0];
this.state.pivot_date = parseDate(result.pivot_date);
this.widget_show_pivot_date = result.widget_show_pivot_date;
if (this.showSearchBar) {
// Initialize the search model
await this.searchModel.load({
resModel: this.source_aml_model_name,
searchViewId: this.widget_search_view_id,
});
}
this.wide_display = result.wide_display_by_default;
// Compute the report
this.refresh();
this.state.can_edit_annotation = result.user_can_edit_annotation;
this.state.can_read_annotation = result.user_can_read_annotation;
}
async _onMounted() {
this.resize_sheet();
}
get showSearchBar() {
return (
this.source_aml_model_name &&
this.widget_show_filters &&
this.widget_search_view_id
);
}
get showPivotDate() {
return this.widget_show_pivot_date;
}
/**
* Return the id of the mis.report.instance to which the widget is
* bound.
*
* @returns int
*/
_instanceId() {
if (this.props.value) {
return this.props.value;
}
/*
* This trick is needed because in a dashboard the view does
* not seem to be bound to an instance: it seems to be a limitation
* of Odoo dashboards that are not designed to contain forms but
* rather tree views or charts.
*/
var context = this.props.record.context;
if (context.active_model === "mis.report.instance") {
return context.active_id;
}
}
get context() {
var ctx = super.context;
if (this.showSearchBar) {
ctx = {
...ctx,
mis_analytic_domain: this.searchModel.searchDomain,
};
}
if (this.showPivotDate && this.state.pivot_date) {
ctx = {
...ctx,
mis_pivot_date: this.state.pivot_date,
};
}
return ctx;
}
async drilldown(event) {
const drilldown = JSON.parse(event.target.dataset.drilldown);
const action = await this.orm.call(
"mis.report.instance",
"drilldown",
[this._instanceId(), drilldown],
{context: this.context}
);
this.action.doAction(action);
}
async refresh() {
this.state.mis_report_data = await this.orm.call(
"mis.report.instance",
"compute",
[this._instanceId()],
{context: this.context}
);
}
async refresh_annotation() {
this.state.mis_report_data.notes = await this.orm.call(
"mis.report.instance",
"get_notes_by_cell_id",
[this._instanceId()],
{context: this.context}
);
}
async printPdf() {
const action = await this.orm.call(
"mis.report.instance",
"print_pdf",
[this._instanceId()],
{context: this.context}
);
this.action.doAction(action);
}
async exportXls() {
const action = await this.orm.call(
"mis.report.instance",
"export_xls",
[this._instanceId()],
{context: this.context}
);
this.action.doAction(action);
}
async displaySettings() {
const action = await this.orm.call(
"mis.report.instance",
"display_settings",
[this._instanceId()],
{context: this.context}
);
this.action.doAction(action);
}
async _remove_annotation(cell_id) {
await this.orm.call(
"mis.report.instance.annotation",
"remove_annotation",
[cell_id, this._instanceId()],
{context: this.context}
);
this.refresh_annotation();
}
async _save_annotation(cell_id) {
const text = document.querySelector(".o_mis_builder_annotation_text").value;
await this.orm.call(
"mis.report.instance.annotation",
"set_annotation",
[cell_id, this._instanceId(), text],
{context: this.context}
);
await this.refresh_annotation();
}
async annotate(event) {
const cell_id = event.target.dataset.cellId;
const note = this.state.mis_report_data.notes[cell_id];
const note_text = (note && note.text) || "";
var buttons = [
{
text: this.env._t("Save"),
classes: "btn-primary",
close: true,
click: this._save_annotation.bind(this, cell_id),
},
{
text: this.env._t("Cancel"),
close: true,
},
];
if (typeof note !== "undefined") {
buttons.push({
text: this.env._t("Remove"),
classes: "btn-secondary",
close: true,
click: this._remove_annotation.bind(this, cell_id),
});
}
new Dialog(this, {
title: "Annotate",
size: "medium",
$content: $(
qweb.render("mis_builder.annotation_dialog", {
text: note_text,
})
),
buttons: buttons,
}).open();
}
async remove_annotation(event) {
const cell_id = event.target.dataset.cellId;
this._remove_annotation(cell_id);
}
onDateTimeChanged(ev) {
this.state.pivot_date = ev;
this.refresh();
}
async toggle_wide_display() {
this.wide_display = !this.wide_display;
this.resize_sheet();
}
async resize_sheet() {
var sheet_element = document.getElementsByClassName("o_form_sheet")[0];
sheet_element.classList.toggle(
"oe_mis_builder_report_wide_sheet",
this.wide_display
);
var button_resize_element = document.getElementById("icon_resize");
button_resize_element.classList.toggle("fa-expand", !this.wide_display);
button_resize_element.classList.toggle("fa-compress", this.wide_display);
}
}
MisReportWidget.components = {FilterMenu, SearchBar, DatePicker};
MisReportWidget.template = "mis_builder.MisReportWidget";
registry.category("fields").add("mis_report_widget", MisReportWidget);

View file

@ -0,0 +1,206 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="mis_builder.MisReportWidget" owl="1">
<div class="oe_mis_builder_content">
<t t-if="state.mis_report_data">
<t t-set="notes" t-value="state.mis_report_data.notes" />
<div class="oe_mis_builder_cp">
<div class="oe_mis_builder_cp_left">
</div>
<div class="oe_mis_builder_cp_right">
<div class="oe_mis_builder_cp_right_top_right">
<div class="oe_mis_builder_action_buttons">
<button
t-on-click="toggle_wide_display"
class="btn btn-secondary"
>
<i id="icon_resize" class="fa" />
</button>
</div>
</div>
<div class="oe_mis_builder_cp_right_top">
<SearchBar t-if="showSearchBar" />
</div>
<div class="oe_mis_builder_cp_right_bottom">
<div class="oe_mis_builder_filter_buttons">
<FilterMenu t-if="showSearchBar" />
<DatePicker
date="state.pivot_date"
onDateTimeChanged="onDateTimeChanged.bind(this)"
placeholder="'Base date...'"
t-if="showPivotDate"
/>
</div>
<div class="oe_mis_builder_action_buttons">
<button t-on-click="refresh" class="btn">
<span class="fa fa-refresh" /> Refresh </button>
<button t-on-click="printPdf" class="btn">
<span class="fa fa-print" /> Print </button>
<button t-on-click="exportXls" class="btn">
<span class="fa fa-download" /> Export </button>
<button
t-on-click="displaySettings"
t-if="widget_show_settings_button"
class="btn"
>
<span class="fa fa-cog" /> Settings </button>
</div>
</div>
</div>
</div>
<div class="o_list_renderer o_renderer table-responsive">
<table
class="o_list_table table table-sm table-hover table-striped mis_builder"
>
<thead>
<tr
t-foreach="state.mis_report_data.header"
t-as="row"
t-key="row_index"
class="oe_list_header_columns"
>
<th class="oe_list_header_char">
</th>
<th
t-foreach="row.cols"
t-as="col"
t-key="col_index"
class="oe_list_header_char mis_builder_collabel"
t-att-colspan="col.colspan"
>
<t t-esc="col.label" />
<t t-if="col.description">
<br />
<t t-esc="col.description" />
</t>
</th>
</tr>
</thead>
<tbody>
<tr
t-foreach="state.mis_report_data.body"
t-as="row"
t-key="row_index"
>
<td t-att="{'style': row.style}">
<t t-esc="row.label" />
<t t-if="row.description">
<br />
<t t-esc="row.description" />
</t>
</td>
<td
t-foreach="row.cells"
t-as="cell"
t-key="cell_index"
t-att="{'style': cell.style, 'title': cell.val_c}"
class="mis_builder_amount oe_mis_builder_dropdown"
>
<div>
<t t-if="cell.drilldown_arg">
<a
href="javascript:void(0)"
class="mis_builder_drilldown"
t-on-click="drilldown"
t-att-data-drilldown="JSON.stringify(cell.drilldown_arg)"
>
<t t-esc="cell.val_r" />
</a>
</t>
<t t-else="">
<t t-esc="cell.val_r" />
</t>
<span class="oe_mis_builder_footnote">
<div t-if="notes[cell.cell_id]">
<a
t-att-id="'note_'+notes[cell.cell_id].sequence"
t-out="notes[cell.cell_id] and notes[cell.cell_id].sequence"
t-att="{'title': notes[cell.cell_id].text}"
href="#footnotes"
/>
</div>
</span>
<div id="dropdown_menu" class="btn-group">
<div
class="dropdown"
t-if="state.can_edit_annotation and cell.can_be_annotated"
>
<div
data-bs-toggle="dropdown"
t-attf-class="dropdown-toggle"
/>
<div
class="dropdown-menu o_filter_menu"
role="menu"
>
<a
href="javascript:void(0)"
t-on-click="annotate"
t-att-data-cell-id="cell.cell_id"
role="menuitem"
class="dropdown-item js_tag"
>
Annotate
</a>
</div>
</div>
<!-- show menu as disabled -->
<div
t-else=""
class="dropdown-toggle oe_mis_builder_menu_disabled"
/>
</div>
</div>
</td>
</tr>
</tbody>
<tfoot>
<tr />
</tfoot>
</table>
</div>
<!-- Adding notes -->
<div class="oe_mis_builder_footnote_div" id="footnotes">
<table class="oe_mis_builder_footnote_table">
<t
t-foreach="state.mis_report_data.notes"
t-as="cell_id"
t-key="cell_id"
>
<tr>
<td><a
t-out="notes[cell_id].sequence"
t-att-href="'#note_'+notes[cell_id].sequence"
/>. </td>
<td><t t-out="notes[cell_id].text" /></td>
<td><i
href="javascript:void(0)"
t-on-click="remove_annotation"
t-att-data-cell-id="cell_id"
class="btn fa fa-trash-o"
t-if="state.can_edit_annotation"
/></td>
</tr>
</t>
</table>
</div>
</t>
</div>
</t>
<t t-name="mis_builder.annotation_dialog">
<form role="form">
<textarea
class="o_mis_builder_annotation_text"
name="note"
rows='4'
placeholder="Insert note here"
><t t-out="text" t-att-data-textnote="text" /></textarea>
</form>
</t>
</templates>

View file

@ -0,0 +1,68 @@
.mis_table {
display: table;
width: 100%;
table-layout: fixed;
}
.mis_row {
display: table-row;
page-break-inside: avoid;
}
.mis_cell {
display: table-cell;
page-break-inside: avoid;
}
.mis_thead {
display: table-header-group;
}
.mis_tbody {
display: table-row-group;
}
.mis_table,
.mis_table .mis_row {
border-left: 0px;
border-right: 0px;
text-align: left;
padding-right: 3px;
padding-left: 3px;
padding-top: 2px;
padding-bottom: 2px;
border-collapse: collapse;
}
.mis_table .mis_row {
border-color: grey;
border-bottom: 1px solid lightGrey;
}
.mis_table .mis_cell.mis_collabel {
font-weight: bold;
background-color: #f0f0f0;
text-align: center;
}
.mis_table .mis_cell.mis_rowlabel {
text-align: left;
/*white-space: nowrap;*/
}
.mis_table .mis_cell.mis_amount {
text-align: right;
}
.oe_mis_builder_footnote {
font-size: 70%;
color: red;
position: relative;
bottom: 1ex;
width: 1em;
display: inline-block;
padding-right: 1px;
}
.oe_mis_builder_footnote_div {
padding-top: 1em;
}
.oe_mis_builder_footnote_table {
list-style: none;
white-space: pre-wrap;
display: inline-block;
td {
vertical-align: top;
}
}

View file

@ -0,0 +1,17 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import test_accounting_none
from . import test_aep
from . import test_multi_company_aep
from . import test_aggregate
from . import test_data_sources
from . import test_kpi_data
from . import test_mis_report_instance
from . import test_mis_safe_eval
from . import test_period_dates
from . import test_render
from . import test_simple_array
from . import test_target_move
from . import test_utc_midnight
from . import test_mis_report_instance_annotation

View file

@ -0,0 +1,67 @@
# Copyright 2017 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import doctest
from odoo.tests import BaseCase, tagged
def _zip(iter1, iter2):
i = 0
iter1 = iter(iter1)
iter2 = iter(iter2)
while True:
i1 = next(iter1, None)
i2 = next(iter2, None)
if i1 is None and i2 is None:
return
yield i, i1, i2
i += 1
def assert_matrix(matrix, expected):
for i, row, expected_row in _zip(matrix.iter_rows(), expected):
if row is None and expected_row is not None:
raise AssertionError("not enough rows")
if row is not None and expected_row is None:
raise AssertionError("too many rows")
for j, cell, expected_val in _zip(row.iter_cells(), expected_row):
assert (
cell and cell.val
) == expected_val, "{} != {} in row {} col {}".format(
cell and cell.val, expected_val, i, j
)
@tagged("doctest")
class OdooDocTestCase(BaseCase):
"""
We need a custom DocTestCase class in order to:
- define test_tags to run as part of standard tests
- output a more meaningful test name than default "DocTestCase.runTest"
"""
__qualname__ = "doctests for "
def __init__(self, test):
self.__test = test
self.__name = test._dt_test.name
super().__init__(self.__name)
def __getattr__(self, item):
if item == self.__name:
return self.__test
def load_doctests(module):
"""
Generates a tests loading method for the doctests of the given module
https://docs.python.org/3/library/unittest.html#load-tests-protocol
"""
def load_tests(loader, tests, ignore):
for test in doctest.DocTestSuite(module):
tests.addTest(OdooDocTestCase(test))
return tests
return load_tests

View file

@ -0,0 +1,7 @@
from odoo import models
class MisKpiDataTestItem(models.Model):
_name = "mis.kpi.data.test.item"
_inherit = "mis.kpi.data"
_description = "MIS Kpi Data test item"

View file

@ -0,0 +1,8 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from ..models import accounting_none
from .common import load_doctests
load_tests = load_doctests(accounting_none)

View file

@ -0,0 +1,467 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import datetime
import time
import odoo.tests.common as common
from odoo import fields
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
from ..models import aep
from ..models.accounting_none import AccountingNone
from ..models.aep import AccountingExpressionProcessor as AEP
from ..models.aep import _is_domain
from .common import load_doctests
load_tests = load_doctests(aep)
class TestAEP(common.TransactionCase):
def setUp(self):
super().setUp()
self.res_company = self.env["res.company"]
self.account_model = self.env["account.account"]
self.move_model = self.env["account.move"]
self.journal_model = self.env["account.journal"]
self.curr_year = datetime.date.today().year
self.prev_year = self.curr_year - 1
# create company
self.company = self.res_company.create({"name": "AEP Company"})
# create receivable bs account
self.account_ar = self.account_model.create(
{
"company_id": self.company.id,
"code": "400AR",
"name": "Receivable",
"account_type": "asset_receivable",
"reconcile": True,
}
)
# create income pl account
self.account_in = self.account_model.create(
{
"company_id": self.company.id,
"code": "700IN",
"name": "Income",
"account_type": "income",
}
)
self.account_in_no_data = self.account_model.create(
{
"company_id": self.company.id,
"code": "700INNODATA",
"name": "Income (no data)",
"account_type": "income",
}
)
# create journal
self.journal = self.journal_model.create(
{
"company_id": self.company.id,
"name": "Sale journal",
"code": "VEN",
"type": "sale",
}
)
# create move in December last year
self._create_move(
date=datetime.date(self.prev_year, 12, 1),
amount=100,
debit_acc=self.account_ar,
credit_acc=self.account_in,
)
# create move in January this year
self._create_move(
date=datetime.date(self.curr_year, 1, 1),
amount=300,
debit_acc=self.account_ar,
credit_acc=self.account_in,
credit_quantity=3,
)
# create move in March this year
self._create_move(
date=datetime.date(self.curr_year, 3, 1),
amount=500,
debit_acc=self.account_ar,
credit_acc=self.account_in,
)
# create the AEP, and prepare the expressions we'll need
self.aep = AEP(self.company)
self.aep.parse_expr("bali[]")
self.aep.parse_expr("bale[]")
self.aep.parse_expr("balp[]")
self.aep.parse_expr("balu[]")
self.aep.parse_expr("bali[700IN]")
self.aep.parse_expr("bale[700IN]")
self.aep.parse_expr("balp[700IN]")
self.aep.parse_expr("balp[700NA]") # account that does not exist
self.aep.parse_expr("bali[400AR]")
self.aep.parse_expr("bale[400AR]")
self.aep.parse_expr("balp[400AR]")
self.aep.parse_expr("debp[400A%]")
self.aep.parse_expr("crdp[700I%]")
self.aep.parse_expr("bali[400%]")
self.aep.parse_expr("bale[700%]")
self.aep.parse_expr("balp[700I%]")
self.aep.parse_expr("fldp.quantity[700%]")
self.aep.parse_expr("balp[]" "[('account_id.code', '=', '400AR')]")
self.aep.parse_expr(
"balp[]" "[('account_id.account_type', '=', " " 'asset_receivable')]"
)
self.aep.parse_expr("balp[('account_type', '=', " " 'asset_receivable')]")
self.aep.parse_expr(
"balp['&', "
" ('account_type', '=', "
" 'asset_receivable'), "
" ('code', '=', '400AR')]"
)
self.aep.parse_expr("bal_700IN") # deprecated
self.aep.parse_expr("bals[700IN]") # deprecated
def _create_move(
self, date, amount, debit_acc, credit_acc, post=True, credit_quantity=0
):
move = self.move_model.create(
{
"journal_id": self.journal.id,
"date": fields.Date.to_string(date),
"line_ids": [
(
0,
0,
{
"name": "/",
"debit": amount,
"account_id": debit_acc.id,
},
),
(
0,
0,
{
"name": "/",
"credit": amount,
"account_id": credit_acc.id,
"quantity": credit_quantity,
},
),
],
}
)
if post:
move._post()
return move
def _do_queries(self, date_from, date_to):
self.aep.do_queries(
date_from=fields.Date.to_string(date_from),
date_to=fields.Date.to_string(date_to),
)
def _eval(self, expr):
eval_dict = {"AccountingNone": AccountingNone}
return safe_eval(self.aep.replace_expr(expr), eval_dict)
def _eval_by_account_id(self, expr):
res = {}
eval_dict = {"AccountingNone": AccountingNone}
for account_id, replaced_exprs in self.aep.replace_exprs_by_account_id([expr]):
res[account_id] = safe_eval(replaced_exprs[0], eval_dict)
return res
def test_sanity_check(self):
self.assertEqual(self.company.fiscalyear_last_day, 31)
self.assertEqual(self.company.fiscalyear_last_month, "12")
def test_parse_expr_error_handling(self):
aep = AEP(self.company)
with self.assertRaises(UserError) as cm:
aep.parse_expr("fldi.quantity[700%]")
self.assertIn(
"`fld` can only be used with mode `p` (variation)", str(cm.exception)
)
with self.assertRaises(UserError) as cm:
aep.parse_expr("fldp[700%]")
self.assertIn("`fld` must have a field name", str(cm.exception))
with self.assertRaises(UserError) as cm:
aep.parse_expr("balp.quantity[700%]")
self.assertIn("`bal` cannot have a field name", str(cm.exception))
def test_aep_basic(self):
self.aep.done_parsing()
# let's query for december
self._do_queries(
datetime.date(self.prev_year, 12, 1), datetime.date(self.prev_year, 12, 31)
)
# initial balance must be None
self.assertIs(self._eval("bali[400AR]"), AccountingNone)
self.assertIs(self._eval("bali[700IN]"), AccountingNone)
# check variation
self.assertEqual(self._eval("balp[400AR]"), 100)
self.assertEqual(self._eval("balp[][('account_id.code', '=', '400AR')]"), 100)
self.assertEqual(
self._eval(
"balp[]" "[('account_id.account_type', '=', " " 'asset_receivable')]"
),
100,
)
self.assertEqual(
self._eval("balp[('account_type', '=', " " 'asset_receivable')]"),
100,
)
self.assertEqual(
self._eval(
"balp['&', "
" ('account_type', '=', "
" 'asset_receivable'), "
" ('code', '=', '400AR')]"
),
100,
)
self.assertEqual(self._eval("balp[700IN]"), -100)
# check ending balance
self.assertEqual(self._eval("bale[400AR]"), 100)
self.assertEqual(self._eval("bale[700IN]"), -100)
# let's query for January
self._do_queries(
datetime.date(self.curr_year, 1, 1), datetime.date(self.curr_year, 1, 31)
)
# initial balance is None for income account (it's not carried over)
self.assertEqual(self._eval("bali[400AR]"), 100)
self.assertIs(self._eval("bali[700IN]"), AccountingNone)
# check variation
self.assertEqual(self._eval("balp[400AR]"), 300)
self.assertEqual(self._eval("balp[700IN]"), -300)
# check ending balance
self.assertEqual(self._eval("bale[400AR]"), 400)
self.assertEqual(self._eval("bale[700IN]"), -300)
# check result for non existing account
self.assertIs(self._eval("bale[700NA]"), AccountingNone)
# check fldp.quantity
self.assertEqual(self._eval("fldp.quantity[700%]"), 3)
# let's query for March
self._do_queries(
datetime.date(self.curr_year, 3, 1), datetime.date(self.curr_year, 3, 31)
)
# initial balance is the ending balance fo January
self.assertEqual(self._eval("bali[400AR]"), 400)
self.assertEqual(self._eval("bali[700IN]"), -300)
self.assertEqual(self._eval("pbali[400AR]"), 400)
self.assertEqual(self._eval("nbali[400AR]"), 0)
self.assertEqual(self._eval("nbali[700IN]"), -300)
self.assertEqual(self._eval("pbali[700IN]"), 0)
# check variation
self.assertEqual(self._eval("balp[400AR]"), 500)
self.assertEqual(self._eval("balp[700IN]"), -500)
self.assertEqual(self._eval("nbalp[400AR]"), 0)
self.assertEqual(self._eval("pbalp[400AR]"), 500)
self.assertEqual(self._eval("nbalp[700IN]"), -500)
self.assertEqual(self._eval("pbalp[700IN]"), 0)
# check ending balance
self.assertEqual(self._eval("bale[400AR]"), 900)
self.assertEqual(self._eval("nbale[400AR]"), 0)
self.assertEqual(self._eval("pbale[400AR]"), 900)
self.assertEqual(self._eval("bale[700IN]"), -800)
self.assertEqual(self._eval("nbale[700IN]"), -800)
self.assertEqual(self._eval("pbale[700IN]"), 0)
# check some variant expressions, for coverage
self.assertEqual(self._eval("crdp[700I%]"), 500)
self.assertEqual(self._eval("debp[400A%]"), 500)
self.assertEqual(self._eval("bal_700IN"), -500)
self.assertEqual(self._eval("bals[700IN]"), -800)
# check fldp.quantity
self.assertEqual(self._eval("fldp.quantity[700%]"), 0)
# unallocated p&l from previous year
self.assertEqual(self._eval("balu[]"), -100)
# TODO allocate profits, and then...
# let's query for December where there is no data
self._do_queries(
datetime.date(self.curr_year, 12, 1), datetime.date(self.curr_year, 12, 31)
)
self.assertIs(self._eval("balp[700IN]"), AccountingNone)
def test_aep_by_account(self):
self.aep.done_parsing()
self._do_queries(
datetime.date(self.curr_year, 3, 1), datetime.date(self.curr_year, 3, 31)
)
variation = self._eval_by_account_id("balp[]")
self.assertEqual(variation, {self.account_ar.id: 500, self.account_in.id: -500})
variation = self._eval_by_account_id("pbalp[]")
self.assertEqual(
variation, {self.account_ar.id: 500, self.account_in.id: AccountingNone}
)
variation = self._eval_by_account_id("nbalp[]")
self.assertEqual(
variation, {self.account_ar.id: AccountingNone, self.account_in.id: -500}
)
variation = self._eval_by_account_id("balp[700IN]")
self.assertEqual(variation, {self.account_in.id: -500})
variation = self._eval_by_account_id("crdp[700IN] - debp[400AR]")
self.assertEqual(variation, {self.account_ar.id: -500, self.account_in.id: 500})
end = self._eval_by_account_id("bale[]")
self.assertEqual(end, {self.account_ar.id: 900, self.account_in.id: -800})
def test_aep_by_account_no_data(self):
"""Test that accounts with no data are not returned."""
self.aep.done_parsing()
self._do_queries(
datetime.date(self.curr_year, 3, 1), datetime.date(self.curr_year, 3, 31)
)
variation = self._eval("balp[700I%]")
self.assertEqual(variation, -500)
variation_by_account = self._eval_by_account_id("balp[700I%]")
self.assertEqual(variation_by_account, {self.account_in.id: -500})
def test_aep_convenience_methods(self):
initial = AEP.get_balances_initial(self.company, time.strftime("%Y") + "-03-01")
self.assertEqual(
initial, {self.account_ar.id: (400, 0), self.account_in.id: (0, 300)}
)
variation = AEP.get_balances_variation(
self.company,
time.strftime("%Y") + "-03-01",
time.strftime("%Y") + "-03-31",
)
self.assertEqual(
variation, {self.account_ar.id: (500, 0), self.account_in.id: (0, 500)}
)
end = AEP.get_balances_end(self.company, time.strftime("%Y") + "-03-31")
self.assertEqual(
end, {self.account_ar.id: (900, 0), self.account_in.id: (0, 800)}
)
unallocated = AEP.get_unallocated_pl(
self.company, time.strftime("%Y") + "-03-15"
)
self.assertEqual(unallocated, (0, 100))
def test_float_is_zero(self):
dp = self.company.currency_id.decimal_places
self.assertEqual(dp, 2)
# make initial balance at Jan 1st equal to 0.01
self._create_move(
date=datetime.date(self.prev_year, 12, 1),
amount=100.01,
debit_acc=self.account_in,
credit_acc=self.account_ar,
)
initial = AEP.get_balances_initial(self.company, time.strftime("%Y") + "-01-01")
self.assertEqual(initial, {self.account_ar.id: (100.00, 100.01)})
# make initial balance at Jan 1st equal to 0.001
self._create_move(
date=datetime.date(self.prev_year, 12, 1),
amount=0.009,
debit_acc=self.account_ar,
credit_acc=self.account_in,
)
initial = AEP.get_balances_initial(self.company, time.strftime("%Y") + "-01-01")
# epsilon initial balances is reported as empty
self.assertEqual(initial, {})
def test_get_account_ids_for_expr(self):
self.aep.done_parsing()
expr = "balp[700IN]"
account_ids = self.aep.get_account_ids_for_expr(expr)
self.assertEqual(account_ids, {self.account_in.id})
expr = "balp[700%]"
account_ids = self.aep.get_account_ids_for_expr(expr)
self.assertEqual(account_ids, {self.account_in.id, self.account_in_no_data.id})
expr = "bali[400%], bale[700%]" # subkpis combined expression
account_ids = self.aep.get_account_ids_for_expr(expr)
self.assertEqual(
account_ids,
{self.account_in.id, self.account_ar.id, self.account_in_no_data.id},
)
def test_get_aml_domain_for_expr(self):
self.aep.done_parsing()
expr = "balp[700IN]"
domain = self.aep.get_aml_domain_for_expr(expr, "2017-01-01", "2017-03-31")
self.assertEqual(
domain,
[
("account_id", "in", (self.account_in.id,)),
"&",
("date", ">=", "2017-01-01"),
("date", "<=", "2017-03-31"),
],
)
expr = "debi[700IN] - crdi[400AR]"
domain = self.aep.get_aml_domain_for_expr(expr, "2017-02-01", "2017-03-31")
self.assertEqual(
domain,
[
"|",
# debi[700IN]
"&",
("account_id", "in", (self.account_in.id,)),
("debit", "<>", 0.0),
# crdi[400AR]
"&",
("account_id", "in", (self.account_ar.id,)),
("credit", "<>", 0.0),
"&",
# for P&L accounts, only after fy start
"|",
("date", ">=", "2017-01-01"),
("account_id.include_initial_balance", "=", True),
# everything must be before from_date for initial balance
("date", "<", "2017-02-01"),
],
)
def test_is_domain(self):
self.assertTrue(_is_domain("('a', '=' 1)"))
self.assertTrue(_is_domain("'&', ('a', '=' 1), ('b', '=', 1)"))
self.assertTrue(_is_domain("'|', ('a', '=' 1), ('b', '=', 1)"))
self.assertTrue(_is_domain("'!', ('a', '=' 1), ('b', '=', 1)"))
self.assertTrue(_is_domain("\"&\", ('a', '=' 1), ('b', '=', 1)"))
self.assertTrue(_is_domain("\"|\", ('a', '=' 1), ('b', '=', 1)"))
self.assertTrue(_is_domain("\"!\", ('a', '=' 1), ('b', '=', 1)"))
self.assertFalse(_is_domain("123%"))
self.assertFalse(_is_domain("123%,456"))
self.assertFalse(_is_domain(""))
def test_inactive_tax(self):
expr = 'balp[][("tax_ids.name", "=", "test tax")]'
self.aep.parse_expr(expr)
self.aep.done_parsing()
tax = self.env["account.tax"].create(
dict(name="test tax", active=True, amount=0, company_id=self.company.id)
)
move = self._create_move(
date=datetime.date(self.prev_year, 12, 1),
amount=100,
debit_acc=self.account_ar,
credit_acc=self.account_in,
post=False,
)
for ml in move.line_ids:
if ml.credit:
ml.write(dict(tax_ids=[(6, 0, [tax.id])]))
tax.active = False
move._post()
# let's query for december 1st
self._do_queries(
datetime.date(self.prev_year, 12, 1), datetime.date(self.prev_year, 12, 1)
)
# let's see if there was a match
self.assertEqual(self._eval(expr), -100)
def test_invalid_field(self):
expr = 'balp[][("invalid_field", "=", "...")]'
self.aep.parse_expr(expr)
self.aep.done_parsing()
with self.assertRaises(UserError) as cm:
self._do_queries(
datetime.date(self.prev_year, 12, 1),
datetime.date(self.prev_year, 12, 1),
)
assert "Error while querying move line source" in str(cm.exception)

View file

@ -0,0 +1,7 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from ..models import aggregate
from .common import load_doctests
load_tests = load_doctests(aggregate)

View file

@ -0,0 +1,227 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from ..models.accounting_none import AccountingNone
from ..models.mis_report import CMP_DIFF
from ..models.mis_report_instance import (
MODE_NONE,
SRC_ACTUALS_ALT,
SRC_CMPCOL,
SRC_SUMCOL,
)
from .common import assert_matrix
class TestMisReportInstanceDataSources(common.TransactionCase):
"""Test sum and comparison data source."""
def _create_move(self, date, amount, debit_acc, credit_acc):
move = self.move_model.create(
{
"journal_id": self.journal.id,
"date": date,
"line_ids": [
(0, 0, {"name": "/", "debit": amount, "account_id": debit_acc.id}),
(
0,
0,
{"name": "/", "credit": amount, "account_id": credit_acc.id},
),
],
}
)
move._post()
return move
@classmethod
def setUpClass(cls):
super().setUpClass()
# Perform the tests with a brand new company to avoid intrusive data from other
# modules added to the default company
cls.company = cls.env["res.company"].create({"name": "Company Test"})
cls.env.user.company_id = cls.company
def setUp(self):
super().setUp()
self.account_model = self.env["account.account"]
self.move_model = self.env["account.move"]
self.journal_model = self.env["account.journal"]
# create receivable bs account
self.account_ar = self.account_model.create(
{
"company_id": self.env.user.company_id.id,
"code": "400AR",
"name": "Receivable",
"account_type": "asset_receivable",
"reconcile": True,
}
)
# create income account
self.account_in = self.account_model.create(
{
"company_id": self.env.user.company_id.id,
"code": "700IN",
"name": "Income",
"account_type": "income",
}
)
self.account_in2 = self.account_model.create(
{
"company_id": self.env.user.company_id.id,
"code": "700IN2",
"name": "Income",
"account_type": "income",
}
)
# create journal
self.journal = self.journal_model.create(
{
"company_id": self.env.user.company_id.id,
"name": "Sale journal",
"code": "VEN",
"type": "sale",
}
)
# create move
self._create_move(
date="2017-01-01",
amount=11,
debit_acc=self.account_ar,
credit_acc=self.account_in,
)
# create move
self._create_move(
date="2017-02-01",
amount=13,
debit_acc=self.account_ar,
credit_acc=self.account_in,
)
self._create_move(
date="2017-02-01",
amount=17,
debit_acc=self.account_ar,
credit_acc=self.account_in2,
)
# create report
self.report = self.env["mis.report"].create(dict(name="test report"))
self.kpi1 = self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
name="k1",
description="kpi 1",
expression="-balp[700IN]",
compare_method=CMP_DIFF,
)
)
self.expr1 = self.kpi1.expression_ids[0]
self.kpi2 = self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
name="k2",
description="kpi 2",
expression="-balp[700%]",
compare_method=CMP_DIFF,
auto_expand_accounts=True,
)
)
self.instance = self.env["mis.report.instance"].create(
dict(name="test instance", report_id=self.report.id, comparison_mode=True)
)
self.p1 = self.env["mis.report.instance.period"].create(
dict(
name="p1",
report_instance_id=self.instance.id,
manual_date_from="2017-01-01",
manual_date_to="2017-01-31",
)
)
self.p2 = self.env["mis.report.instance.period"].create(
dict(
name="p2",
report_instance_id=self.instance.id,
manual_date_from="2017-02-01",
manual_date_to="2017-02-28",
)
)
def test_sum(self):
self.psum = self.env["mis.report.instance.period"].create(
dict(
name="psum",
report_instance_id=self.instance.id,
mode=MODE_NONE,
source=SRC_SUMCOL,
source_sumcol_ids=[
(0, 0, dict(period_to_sum_id=self.p1.id, sign="+")),
(0, 0, dict(period_to_sum_id=self.p2.id, sign="+")),
],
)
)
matrix = self.instance._compute_matrix()
# None in last col because account details are not summed by default
assert_matrix(
matrix,
[
[11, 13, 24],
[11, 30, 41],
[11, 13, AccountingNone],
[AccountingNone, 17, AccountingNone],
],
)
def test_sum_diff(self):
self.psum = self.env["mis.report.instance.period"].create(
dict(
name="psum",
report_instance_id=self.instance.id,
mode=MODE_NONE,
source=SRC_SUMCOL,
source_sumcol_ids=[
(0, 0, dict(period_to_sum_id=self.p1.id, sign="+")),
(0, 0, dict(period_to_sum_id=self.p2.id, sign="-")),
],
source_sumcol_accdet=True,
)
)
matrix = self.instance._compute_matrix()
assert_matrix(
matrix,
[[11, 13, -2], [11, 30, -19], [11, 13, -2], [AccountingNone, 17, -17]],
)
def test_cmp(self):
self.pcmp = self.env["mis.report.instance.period"].create(
dict(
name="pcmp",
report_instance_id=self.instance.id,
mode=MODE_NONE,
source=SRC_CMPCOL,
source_cmpcol_from_id=self.p1.id,
source_cmpcol_to_id=self.p2.id,
)
)
matrix = self.instance._compute_matrix()
assert_matrix(
matrix, [[11, 13, 2], [11, 30, 19], [11, 13, 2], [AccountingNone, 17, 17]]
)
def test_actuals(self):
matrix = self.instance._compute_matrix()
assert_matrix(matrix, [[11, 13], [11, 30], [11, 13], [AccountingNone, 17]])
def test_actuals_disable_auto_expand_accounts(self):
self.instance.no_auto_expand_accounts = True
matrix = self.instance._compute_matrix()
assert_matrix(matrix, [[11, 13], [11, 30]])
def test_actuals_alt(self):
aml_model = self.env["ir.model"].search([("name", "=", "account.move.line")])
self.kpi2.auto_expand_accounts = False
self.p1.source = SRC_ACTUALS_ALT
self.p1.source_aml_model_id = aml_model.id
self.p2.source = SRC_ACTUALS_ALT
self.p1.source_aml_model_id = aml_model.id
matrix = self.instance._compute_matrix()
assert_matrix(matrix, [[11, 13], [11, 30]])

View file

@ -0,0 +1,142 @@
# Copyright 2017 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo_test_helper import FakeModelLoader
from odoo.tests.common import TransactionCase
from ..models.mis_kpi_data import ACC_AVG, ACC_SUM
class TestKpiData(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.loader = FakeModelLoader(cls.env, cls.__module__)
cls.loader.backup_registry()
from .fake_models import MisKpiDataTestItem
cls.loader.update_registry((MisKpiDataTestItem,))
report = cls.env["mis.report"].create(dict(name="test report"))
cls.kpi1 = cls.env["mis.report.kpi"].create(
dict(
report_id=report.id,
name="k1",
description="kpi 1",
expression="AccountingNone",
)
)
cls.expr1 = cls.kpi1.expression_ids[0]
cls.kpi2 = cls.env["mis.report.kpi"].create(
dict(
report_id=report.id,
name="k2",
description="kpi 2",
expression="AccountingNone",
)
)
cls.expr2 = cls.kpi2.expression_ids[0]
cls.kd11 = cls.env["mis.kpi.data.test.item"].create(
dict(
kpi_expression_id=cls.expr1.id,
date_from="2017-05-01",
date_to="2017-05-10",
amount=10,
)
)
cls.kd12 = cls.env["mis.kpi.data.test.item"].create(
dict(
kpi_expression_id=cls.expr1.id,
date_from="2017-05-11",
date_to="2017-05-20",
amount=20,
)
)
cls.kd13 = cls.env["mis.kpi.data.test.item"].create(
dict(
kpi_expression_id=cls.expr1.id,
date_from="2017-05-21",
date_to="2017-05-25",
amount=30,
)
)
cls.kd21 = cls.env["mis.kpi.data.test.item"].create(
dict(
kpi_expression_id=cls.expr2.id,
date_from="2017-06-01",
date_to="2017-06-30",
amount=3,
)
)
@classmethod
def tearDownClass(cls):
cls.loader.restore_registry()
return super().tearDownClass()
def test_kpi_data_name(self):
self.assertEqual(self.kd11.name, "k1: 2017-05-01 - 2017-05-10")
self.assertEqual(self.kd12.name, "k1: 2017-05-11 - 2017-05-20")
def test_kpi_data_sum(self):
self.assertEqual(self.kpi1.accumulation_method, ACC_SUM)
# one full
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-10", []
)
self.assertEqual(r, {self.expr1: 10})
# one half
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-05", []
)
self.assertEqual(r, {self.expr1: 5})
# two full
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-20", []
)
self.assertEqual(r, {self.expr1: 30})
# two half
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-06", "2017-05-15", []
)
self.assertEqual(r, {self.expr1: 15})
# more than covered range
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-01-01", "2017-05-31", []
)
self.assertEqual(r, {self.expr1: 60})
# two kpis
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-21", "2017-06-30", []
)
self.assertEqual(r, {self.expr1: 30, self.expr2: 3})
def test_kpi_data_avg(self):
self.kpi1.accumulation_method = ACC_AVG
# one full
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-10", []
)
self.assertEqual(r, {self.expr1: 10})
# one half
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-05", []
)
self.assertEqual(r, {self.expr1: 10})
# two full
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-01", "2017-05-20", []
)
self.assertEqual(r, {self.expr1: (10 * 10 + 20 * 10) / 20})
# two half
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-05-06", "2017-05-15", []
)
self.assertEqual(r, {self.expr1: (10 * 5 + 20 * 5) / 10})
# more than covered range
r = self.env["mis.kpi.data.test.item"]._query_kpi_data(
"2017-01-01", "2017-05-31", []
)
self.assertEqual(r, {self.expr1: (10 * 10 + 20 * 10 + 30 * 5) / 25})

View file

@ -0,0 +1,635 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from odoo.tools import test_reports
from ..models.accounting_none import AccountingNone
from ..models.mis_report import TYPE_STR, SubKPITupleLengthError, SubKPIUnknownTypeError
class TestMisReportInstance(common.HttpCase):
"""Basic integration test to exercise mis.report.instance.
We don't check the actual results here too much as computation correctness
should be covered by lower level unit tests.
"""
def setUp(self):
super().setUp()
partner_model_id = self.env.ref("base.model_res_partner").id
partner_create_date_field_id = self.env.ref(
"base.field_res_partner__create_date"
).id
partner_debit_field_id = self.env.ref("account.field_res_partner__debit").id
# create a report with 2 subkpis and one query
self.report = self.env["mis.report"].create(
dict(
name="test report",
subkpi_ids=[
(0, 0, dict(name="sk1", description="subkpi 1", sequence=1)),
(0, 0, dict(name="sk2", description="subkpi 2", sequence=2)),
],
query_ids=[
(
0,
0,
dict(
name="partner",
model_id=partner_model_id,
field_ids=[(4, partner_debit_field_id, None)],
date_field=partner_create_date_field_id,
aggregate="sum",
),
)
],
)
)
# create another report with 2 subkpis, no query
self.report_2 = self.env["mis.report"].create(
dict(
name="another test report",
subkpi_ids=[
(
0,
0,
dict(
name="subkpi1_report2",
description="subkpi 1, report 2",
sequence=1,
),
),
(
0,
0,
dict(
name="subkpi2_report2",
description="subkpi 2, report 2",
sequence=2,
),
),
],
)
)
# Third report, 2 subkpis, no query
self.report_3 = self.env["mis.report"].create(
dict(
name="test report 3",
subkpi_ids=[
(
0,
0,
dict(
name="subkpi1_report3",
description="subkpi 1, report 3",
sequence=1,
),
),
(
0,
0,
dict(
name="subkpi2_report3",
description="subkpi 2, report 3",
sequence=2,
),
),
],
)
)
# kpi with accounting formulas
self.kpi1 = self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 1",
name="k1",
multi=True,
expression_ids=[
(
0,
0,
dict(name="bale[200%]", subkpi_id=self.report.subkpi_ids[0].id),
),
(
0,
0,
dict(name="balp[200%]", subkpi_id=self.report.subkpi_ids[1].id),
),
],
)
)
# kpi with accounting formula and query
self.kpi2 = self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 2",
name="k2",
multi=True,
expression_ids=[
(
0,
0,
dict(name="balp[200%]", subkpi_id=self.report.subkpi_ids[0].id),
),
(
0,
0,
dict(
name="partner.debit", subkpi_id=self.report.subkpi_ids[1].id
),
),
],
)
)
# kpi with a simple expression summing other multi-valued kpis
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 4",
name="k4",
multi=False,
expression="k1 + k2 + k3",
)
)
# kpi with 2 constants
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 3",
name="k3",
multi=True,
expression_ids=[
(
0,
0,
dict(
name="AccountingNone",
subkpi_id=self.report.subkpi_ids[0].id,
),
),
(0, 0, dict(name="1.0", subkpi_id=self.report.subkpi_ids[1].id)),
],
)
)
# kpi with a NameError (x not defined)
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 5",
name="k5",
multi=True,
expression_ids=[
(0, 0, dict(name="x", subkpi_id=self.report.subkpi_ids[0].id)),
(0, 0, dict(name="1.0", subkpi_id=self.report.subkpi_ids[1].id)),
],
)
)
# string-type kpi
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 6",
name="k6",
multi=True,
type=TYPE_STR,
expression_ids=[
(0, 0, dict(name='"bla"', subkpi_id=self.report.subkpi_ids[0].id)),
(
0,
0,
dict(name='"blabla"', subkpi_id=self.report.subkpi_ids[1].id),
),
],
)
)
# kpi that references another subkpi by name
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 7",
name="k7",
multi=True,
expression_ids=[
(0, 0, dict(name="k3.sk1", subkpi_id=self.report.subkpi_ids[0].id)),
(0, 0, dict(name="k3.sk2", subkpi_id=self.report.subkpi_ids[1].id)),
],
)
)
# Report 2 : kpi with AccountingNone value
self.env["mis.report.kpi"].create(
dict(
report_id=self.report_2.id,
description="AccountingNone kpi",
name="AccountingNoneKPI",
multi=False,
)
)
# Report 2 : 'classic' kpi with values for each sub-KPI
self.env["mis.report.kpi"].create(
dict(
report_id=self.report_2.id,
description="Classic kpi",
name="classic_kpi_r2",
multi=True,
expression_ids=[
(
0,
0,
dict(
name="bale[200%]", subkpi_id=self.report_2.subkpi_ids[0].id
),
),
(
0,
0,
dict(
name="balp[200%]", subkpi_id=self.report_2.subkpi_ids[1].id
),
),
],
)
)
# Report 3 : kpi with wrong tuple length
self.env["mis.report.kpi"].create(
dict(
report_id=self.report_3.id,
description="Wrong tuple length kpi",
name="wrongTupleLen",
multi=False,
expression="('hello', 'does', 'this', 'work')",
)
)
# Report 3 : 'classic' kpi
self.env["mis.report.kpi"].create(
dict(
report_id=self.report_3.id,
description="Classic kpi",
name="classic_kpi_r2",
multi=True,
expression_ids=[
(
0,
0,
dict(
name="bale[200%]", subkpi_id=self.report_3.subkpi_ids[0].id
),
),
(
0,
0,
dict(
name="balp[200%]", subkpi_id=self.report_3.subkpi_ids[1].id
),
),
],
)
)
# create a report instance
self.report_instance = self.env["mis.report.instance"].create(
dict(
name="test instance",
report_id=self.report.id,
company_id=self.env.ref("base.main_company").id,
period_ids=[
(
0,
0,
dict(
name="p1",
mode="relative",
type="d",
subkpi_ids=[(4, self.report.subkpi_ids[0].id, None)],
),
),
(
0,
0,
dict(
name="p2",
mode="fix",
manual_date_from="2014-01-01",
manual_date_to="2014-12-31",
),
),
],
)
)
# same for report 2
self.report_instance_2 = self.env["mis.report.instance"].create(
dict(
name="test instance 2",
report_id=self.report_2.id,
company_id=self.env.ref("base.main_company").id,
period_ids=[
(
0,
0,
dict(
name="p3",
mode="fix",
manual_date_from="2019-01-01",
manual_date_to="2019-12-31",
),
)
],
)
)
# and for report 3
self.report_instance_3 = self.env["mis.report.instance"].create(
dict(
name="test instance 3",
report_id=self.report_3.id,
company_id=self.env.ref("base.main_company").id,
period_ids=[
(
0,
0,
dict(
name="p4",
mode="fix",
manual_date_from="2019-01-01",
manual_date_to="2019-12-31",
),
)
],
)
)
def test_compute(self):
matrix = self.report_instance._compute_matrix()
for row in matrix.iter_rows():
vals = [c.val for c in row.iter_cells()]
if row.kpi.name == "k3":
# k3 is constant
self.assertEqual(vals, [AccountingNone, AccountingNone, 1.0])
elif row.kpi.name == "k6":
# k6 is a string kpi
self.assertEqual(vals, ["bla", "bla", "blabla"])
elif row.kpi.name == "k7":
# k7 references k3 via subkpi names
self.assertEqual(vals, [AccountingNone, AccountingNone, 1.0])
def test_multi_company_compute(self):
self.report_instance.write(
{
"multi_company": True,
"company_ids": [(6, 0, self.report_instance.company_id.ids)],
}
)
self.report_instance.report_id.kpi_ids.write({"auto_expand_accounts": True})
matrix = self.report_instance._compute_matrix()
for row in matrix.iter_rows():
if row.account_id:
account = self.env["account.account"].browse(row.account_id)
self.assertEqual(
row.label,
f"{account.code} {account.name} [{account.company_id.name}]",
)
self.report_instance.write({"multi_company": False})
matrix = self.report_instance._compute_matrix()
for row in matrix.iter_rows():
if row.account_id:
account = self.env["account.account"].browse(row.account_id)
self.assertEqual(row.label, f"{account.code} {account.name}")
def test_evaluate(self):
company = self.env.ref("base.main_company")
aep = self.report._prepare_aep(company)
r = self.report.evaluate(aep, date_from="2014-01-01", date_to="2014-12-31")
self.assertEqual(r["k3"], (AccountingNone, 1.0))
self.assertEqual(r["k6"], ("bla", "blabla"))
self.assertEqual(r["k7"], (AccountingNone, 1.0))
def test_json(self):
self.report_instance.compute()
def test_drilldown(self):
action = self.report_instance.drilldown(
dict(expr="balp[200%]", period_id=self.report_instance.period_ids[0].id)
)
account_ids = (
self.env["account.account"]
.search(
[
("code", "=like", "200%"),
("company_id", "=", self.env.ref("base.main_company").id),
]
)
.ids
)
self.assertTrue(("account_id", "in", tuple(account_ids)) in action["domain"])
self.assertEqual(action["res_model"], "account.move.line")
def test_drilldown_action_name_with_account(self):
period = self.report_instance.period_ids[0]
account = self.env["account.account"].search([], limit=1)
args = {
"period_id": period.id,
"kpi_id": self.kpi1.id,
"account_id": account.id,
}
action_name = self.report_instance._get_drilldown_action_name(args)
expected_name = "{kpi} - {account} - {period}".format(
kpi=self.kpi1.description,
account=account.display_name,
period=period.display_name,
)
assert action_name == expected_name
def test_drilldown_action_name_without_account(self):
period = self.report_instance.period_ids[0]
args = {
"period_id": period.id,
"kpi_id": self.kpi1.id,
}
action_name = self.report_instance._get_drilldown_action_name(args)
expected_name = f"{self.kpi1.description} - {period.display_name}"
assert action_name == expected_name
def test_drilldown_views(self):
IrUiView = self.env["ir.ui.view"]
model_name = "account.move.line"
IrUiView.search([("model", "=", model_name)]).unlink()
IrUiView.create(
[
{
"name": "mis_report_test_drilldown_views_chart",
"model": model_name,
"arch": "<graph><field name='name'/></graph>",
},
{
"name": "mis_report_test_drilldown_views_tree",
"model": model_name,
"arch": "<pivot><field name='name'/></pivot>",
},
]
)
action = self.report_instance.drilldown(
dict(expr="balp[200%]", period_id=self.report_instance.period_ids[0].id)
)
self.assertEqual(action["view_mode"], "pivot,graph")
self.assertEqual(action["views"], [[False, "pivot"], [False, "graph"]])
IrUiView.create(
[
{
"name": "mis_report_test_drilldown_views_form",
"model": model_name,
"arch": "<form><field name='name'/></form>",
},
{
"name": "mis_report_test_drilldown_views_tree",
"model": model_name,
"arch": "<tree><field name='name'/></tree>",
},
]
)
action = self.report_instance.drilldown(
dict(expr="balp[200%]", period_id=self.report_instance.period_ids[0].id)
)
self.assertEqual(action["view_mode"], "tree,form,pivot,graph")
self.assertEqual(
action["views"],
[[False, "tree"], [False, "form"], [False, "pivot"], [False, "graph"]],
)
def test_qweb(self):
self.report_instance.print_pdf() # get action
test_reports.try_report(
self.env.cr,
self.env.uid,
"mis_builder.report_mis_report_instance",
[self.report_instance.id],
report_type="qweb-pdf",
)
def test_xlsx(self):
self.report_instance.export_xls() # get action
test_reports.try_report(
self.env.cr,
self.env.uid,
"mis_builder.mis_report_instance_xlsx",
[self.report_instance.id],
report_type="xlsx",
)
def test_get_kpis_by_account_id(self):
account_ids = (
self.env["account.account"]
.search(
[
("code", "=like", "200%"),
("company_id", "=", self.env.ref("base.main_company").id),
]
)
.ids
)
kpi200 = {self.kpi1, self.kpi2}
res = self.report.get_kpis_by_account_id(self.env.ref("base.main_company"))
for account_id in account_ids:
self.assertTrue(account_id in res)
self.assertEqual(res[account_id], kpi200)
def test_kpi_name_get_name_search(self):
r = self.env["mis.report.kpi"].name_search("k1")
self.assertEqual(len(r), 1)
self.assertEqual(r[0][0], self.kpi1.id)
self.assertEqual(r[0][1], "kpi 1 (k1)")
r = self.env["mis.report.kpi"].name_search("kpi 1")
self.assertEqual(len(r), 1)
self.assertEqual(r[0][0], self.kpi1.id)
self.assertEqual(r[0][1], "kpi 1 (k1)")
def test_kpi_expr_name_get_name_search(self):
r = self.env["mis.report.kpi.expression"].name_search("k1")
self.assertEqual(
[i[1] for i in r],
["kpi 1 / subkpi 1 (k1.sk1)", "kpi 1 / subkpi 2 (k1.sk2)"],
)
r = self.env["mis.report.kpi.expression"].name_search("k1.sk1")
self.assertEqual([i[1] for i in r], ["kpi 1 / subkpi 1 (k1.sk1)"])
r = self.env["mis.report.kpi.expression"].name_search("k4")
self.assertEqual([i[1] for i in r], ["kpi 4 (k4)"])
def test_query_company_ids(self):
# sanity check single company mode
assert not self.report_instance.multi_company
assert self.report_instance.company_id
assert self.report_instance.query_company_ids == self.report_instance.company_id
# create a second company
c1 = self.report_instance.company_id
c2 = self.env["res.company"].create(
dict(
name="company 2",
)
)
self.report_instance.write(dict(multi_company=True, company_id=False))
self.report_instance.company_ids |= c1
self.report_instance.company_ids |= c2
assert len(self.report_instance.company_ids) == 2
self.assertFalse(self.report_instance.query_company_ids - self.env.companies)
# In a user context where there is only one company, ensure
# query_company_ids only has one company too.
assert (
self.report_instance.with_context(
allowed_company_ids=(c1.id,)
).query_company_ids
== c1
)
def test_multi_company_onchange(self):
# not multi company
self.assertTrue(self.report_instance.company_id)
self.assertFalse(self.report_instance.multi_company)
self.assertFalse(self.report_instance.company_ids)
self.assertEqual(
self.report_instance.query_company_ids[0], self.report_instance.company_id
)
# create a child company
self.env["res.company"].create(
dict(name="company 2", parent_id=self.report_instance.company_id.id)
)
self.report_instance.multi_company = True
# multi company, company_ids not set
self.assertEqual(self.report_instance.query_company_ids, self.env.companies)
# set company_ids
previous_company = self.report_instance.company_id
self.report_instance._onchange_company()
self.assertFalse(self.report_instance.company_id)
self.assertTrue(self.report_instance.multi_company)
self.assertEqual(self.report_instance.company_ids, previous_company)
self.assertEqual(self.report_instance.query_company_ids, previous_company)
# reset single company mode
self.report_instance.multi_company = False
self.report_instance._onchange_company()
self.assertEqual(
self.report_instance.query_company_ids[0], self.report_instance.company_id
)
self.assertFalse(self.report_instance.company_ids)
def test_mis_report_analytic_filters(self):
# Check that matrix has no values when using a filter with a non existing value
matrix = self.report_instance.with_context(
analytic_domain=[("partner_id", "=", -1)]
)._compute_matrix()
for row in matrix.iter_rows():
vals = [c.val for c in row.iter_cells()]
if row.kpi.name == "k1":
self.assertEqual(vals, [AccountingNone, AccountingNone, AccountingNone])
elif row.kpi.name == "k2":
self.assertEqual(vals, [AccountingNone, AccountingNone, None])
elif row.kpi.name == "k4":
self.assertEqual(vals, [AccountingNone, AccountingNone, 1.0])
def test_raise_when_unknown_kpi_value_type(self):
with self.assertRaises(SubKPIUnknownTypeError):
self.report_instance_2.compute()
def test_raise_when_wrong_tuple_length_with_subkpis(self):
with self.assertRaises(SubKPITupleLengthError):
self.report_instance_3.compute()
def test_unprivileged(self):
test_user = common.new_test_user(
self.env, "mis_you", groups="base.group_user,account.group_account_readonly"
)
self.report_instance.with_user(test_user).compute()

View file

@ -0,0 +1,154 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.tests.common import TransactionCase
class TestMisReportInstanceAnnotation(TransactionCase):
def setUp(self):
super().setUp()
self.report = self.env["mis.report"].create(
dict(
name="test report",
subkpi_ids=[
Command.create(
dict(
name="subkpi1_report2",
description="subkpi 1, report 2",
sequence=1,
)
),
Command.create(
dict(
name="subkpi2_report2",
description="subkpi 2, report 2",
sequence=2,
),
),
],
)
)
self.kpi = self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
description="kpi 1",
name="k1",
multi=True,
expression_ids=[
Command.create(
dict(name="bale[200%]", subkpi_id=self.report.subkpi_ids[0].id),
),
Command.create(
dict(name="balp[200%]", subkpi_id=self.report.subkpi_ids[1].id),
),
],
)
)
self.report_instance = self.env["mis.report.instance"].create(
dict(
name="test instance",
report_id=self.report.id,
company_id=self.env.ref("base.main_company").id,
period_ids=[
Command.create(
dict(
name="p1",
mode="fix",
manual_date_from="2013-01-01",
manual_date_to="2013-12-31",
sequence=1,
),
),
Command.create(
dict(
name="p2",
mode="fix",
manual_date_from="2014-01-01",
manual_date_to="2014-12-31",
sequence=2,
),
),
],
)
)
def test_adding_note(self):
notes = self.report_instance.get_notes_by_cell_id()
self.assertEqual({}, notes)
# report with 4 cells, 2 periods and 2 subkpis
matrix = self.report_instance._compute_matrix()
cell_ids = [c.cell_id for row in matrix.iter_rows() for c in row.iter_cells()]
self.assertEqual(len(cell_ids), 4)
first_cell_id, second_cell_id, third_cell_id, _fourth_cell_id = cell_ids
# adding one note
self.env["mis.report.instance.annotation"].set_annotation(
first_cell_id, self.report_instance.id, "This is a note"
)
notes = self.report_instance.get_notes_by_cell_id()
self.assertDictEqual(
{first_cell_id: {"text": "This is a note", "sequence": 1}}, notes
)
# adding another note
self.env["mis.report.instance.annotation"].set_annotation(
third_cell_id, self.report_instance.id, "This is another note"
)
notes = self.report_instance.get_notes_by_cell_id()
self.assertDictEqual(
{
first_cell_id: {"text": "This is a note", "sequence": 1},
third_cell_id: {"text": "This is another note", "sequence": 2},
},
notes,
)
self.env["mis.report.instance.annotation"].set_annotation(
second_cell_id, self.report_instance.id, "This is third note"
)
notes = self.report_instance.get_notes_by_cell_id()
# Last note added should have a sequence of
# 2 since it is deplayed in the second cell
self.assertDictEqual(
{
first_cell_id: {"text": "This is a note", "sequence": 1},
second_cell_id: {"text": "This is third note", "sequence": 2},
third_cell_id: {"text": "This is another note", "sequence": 3},
},
notes,
)
def test_remove_note(self):
notes = self.report_instance.get_notes_by_cell_id()
self.assertEqual({}, notes)
# report with 4 cells, 2 periods and 2 subkpis
matrix = self.report_instance._compute_matrix()
cell_ids = [c.cell_id for row in matrix.iter_rows() for c in row.iter_cells()]
self.assertEqual(len(cell_ids), 4)
first_cell_id = cell_ids[0]
# adding one note
self.env["mis.report.instance.annotation"].set_annotation(
first_cell_id, self.report_instance.id, "This is a note"
)
notes = self.report_instance.get_notes_by_cell_id()
self.assertDictEqual(
{first_cell_id: {"text": "This is a note", "sequence": 1}}, notes
)
# remove note
self.env["mis.report.instance.annotation"].remove_annotation(
first_cell_id, self.report_instance.id
)
notes = self.report_instance.get_notes_by_cell_id()
self.assertEqual({}, notes)

View file

@ -0,0 +1,25 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from ..models.mis_safe_eval import DataError, NameDataError, mis_safe_eval
class TestMisSafeEval(common.TransactionCase):
def test_nominal(self):
val = mis_safe_eval("a + 1", {"a": 1})
self.assertEqual(val, 2)
def test_exceptions(self):
val = mis_safe_eval("1/0", {}) # division by zero
self.assertTrue(isinstance(val, DataError))
self.assertEqual(val.name, "#DIV/0")
val = mis_safe_eval("1a", {}) # syntax error
self.assertTrue(isinstance(val, DataError))
self.assertEqual(val.name, "#ERR")
def test_name_error(self):
val = mis_safe_eval("a + 1", {})
self.assertTrue(isinstance(val, NameDataError))
self.assertEqual(val.name, "#NAME")

View file

@ -0,0 +1,208 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import datetime
import odoo.tests.common as common
from odoo import fields
from odoo.tools.safe_eval import safe_eval
from ..models.accounting_none import AccountingNone
from ..models.aep import AccountingExpressionProcessor as AEP
class TestMultiCompanyAEP(common.TransactionCase):
def setUp(self):
super().setUp()
self.res_company = self.env["res.company"]
self.account_model = self.env["account.account"]
self.move_model = self.env["account.move"]
self.journal_model = self.env["account.journal"]
self.currency_model = self.env["res.currency"]
self.curr_year = datetime.date.today().year
self.prev_year = self.curr_year - 1
self.usd = self.currency_model.with_context(active_test=False).search(
[("name", "=", "USD")]
)
self.eur = self.currency_model.with_context(active_test=False).search(
[("name", "=", "EUR")]
)
# create company A and B
self.company_eur = self.res_company.create(
{"name": "CYEUR", "currency_id": self.eur.id}
)
self.company_usd = self.res_company.create(
{"name": "CYUSD", "currency_id": self.usd.id}
)
self.env["res.currency.rate"].search([]).unlink()
for company, divider in [(self.company_eur, 1.0), (self.company_usd, 2.0)]:
# create receivable bs account
company_key = company.name
setattr(
self,
"account_ar_" + company_key,
self.account_model.create(
{
"company_id": company.id,
"code": "400AR",
"name": "Receivable",
"account_type": "asset_receivable",
"reconcile": True,
}
),
)
# create income pl account
setattr(
self,
"account_in_" + company_key,
self.account_model.create(
{
"company_id": company.id,
"code": "700IN",
"name": "Income",
"account_type": "income",
}
),
)
# create journal
setattr(
self,
"journal" + company_key,
self.journal_model.create(
{
"company_id": company.id,
"name": "Sale journal",
"code": "VEN",
"type": "sale",
}
),
)
# create move in december last year
self._create_move(
journal=getattr(self, "journal" + company_key),
date=datetime.date(self.prev_year, 12, 1),
amount=100 / divider,
debit_acc=getattr(self, "account_ar_" + company_key),
credit_acc=getattr(self, "account_in_" + company_key),
)
# create move in january this year
self._create_move(
journal=getattr(self, "journal" + company_key),
date=datetime.date(self.curr_year, 1, 1),
amount=300 / divider,
debit_acc=getattr(self, "account_ar_" + company_key),
credit_acc=getattr(self, "account_in_" + company_key),
)
# create move in february this year
self._create_move(
journal=getattr(self, "journal" + company_key),
date=datetime.date(self.curr_year, 3, 1),
amount=500 / divider,
debit_acc=getattr(self, "account_ar_" + company_key),
credit_acc=getattr(self, "account_in_" + company_key),
)
def _create_move(self, journal, date, amount, debit_acc, credit_acc):
move = self.move_model.create(
{
"journal_id": journal.id,
"date": fields.Date.to_string(date),
"line_ids": [
(0, 0, {"name": "/", "debit": amount, "account_id": debit_acc.id}),
(
0,
0,
{"name": "/", "credit": amount, "account_id": credit_acc.id},
),
],
}
)
move._post()
return move
def _do_queries(self, companies, currency, date_from, date_to):
# create the AEP, and prepare the expressions we'll need
aep = AEP(companies, currency)
aep.parse_expr("bali[]")
aep.parse_expr("bale[]")
aep.parse_expr("balp[]")
aep.parse_expr("balu[]")
aep.parse_expr("bali[700IN]")
aep.parse_expr("bale[700IN]")
aep.parse_expr("balp[700IN]")
aep.parse_expr("bali[400AR]")
aep.parse_expr("bale[400AR]")
aep.parse_expr("balp[400AR]")
aep.parse_expr("debp[400A%]")
aep.parse_expr("crdp[700I%]")
aep.parse_expr("bali[400%]")
aep.parse_expr("bale[700%]")
aep.done_parsing()
aep.do_queries(
date_from=fields.Date.to_string(date_from),
date_to=fields.Date.to_string(date_to),
)
return aep
def _eval(self, aep, expr):
eval_dict = {"AccountingNone": AccountingNone}
return safe_eval(aep.replace_expr(expr), eval_dict)
def _eval_by_account_id(self, aep, expr):
res = {}
eval_dict = {"AccountingNone": AccountingNone}
for account_id, replaced_exprs in aep.replace_exprs_by_account_id([expr]):
res[account_id] = safe_eval(replaced_exprs[0], eval_dict)
return res
def test_aep_basic(self):
# let's query for december, one company
aep = self._do_queries(
self.company_eur,
None,
datetime.date(self.prev_year, 12, 1),
datetime.date(self.prev_year, 12, 31),
)
self.assertEqual(self._eval(aep, "balp[700IN]"), -100)
aep = self._do_queries(
self.company_usd,
None,
datetime.date(self.prev_year, 12, 1),
datetime.date(self.prev_year, 12, 31),
)
self.assertEqual(self._eval(aep, "balp[700IN]"), -50)
# let's query for december, two companies
aep = self._do_queries(
self.company_eur | self.company_usd,
self.eur,
datetime.date(self.prev_year, 12, 1),
datetime.date(self.prev_year, 12, 31),
)
self.assertEqual(self._eval(aep, "balp[700IN]"), -150)
def test_aep_multi_currency(self):
date_from = datetime.date(self.prev_year, 12, 1)
date_to = datetime.date(self.prev_year, 12, 31)
today = datetime.date.today()
self.env["res.currency.rate"].create(
dict(currency_id=self.usd.id, name=date_to, rate=1.1)
)
self.env["res.currency.rate"].create(
dict(currency_id=self.usd.id, name=today, rate=1.2)
)
# let's query for december, one company, default currency = eur
aep = self._do_queries(self.company_eur, None, date_from, date_to)
self.assertEqual(self._eval(aep, "balp[700IN]"), -100)
# let's query for december, two companies
aep = self._do_queries(
self.company_eur | self.company_usd, self.eur, date_from, date_to
)
self.assertAlmostEqual(self._eval(aep, "balp[700IN]"), -100 - 50 / 1.1)
# let's query for december, one company, currency = usd
aep = self._do_queries(self.company_eur, self.usd, date_from, date_to)
self.assertAlmostEqual(self._eval(aep, "balp[700IN]"), -100 * 1.1)
# let's query for december, two companies, currency = usd
aep = self._do_queries(
self.company_eur | self.company_usd, self.usd, date_from, date_to
)
self.assertAlmostEqual(self._eval(aep, "balp[700IN]"), -100 * 1.1 - 50)

View file

@ -0,0 +1,159 @@
# Copyright 2017 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from odoo import fields
from ..models.mis_report_instance import (
MODE_FIX,
MODE_NONE,
MODE_REL,
SRC_SUMCOL,
DateFilterForbidden,
DateFilterRequired,
)
from .common import assert_matrix
class TestPeriodDates(common.TransactionCase):
def setUp(self):
super().setUp()
self.report_obj = self.env["mis.report"]
self.instance_obj = self.env["mis.report.instance"]
self.period_obj = self.env["mis.report.instance.period"]
self.report = self.report_obj.create(dict(name="test-report"))
self.instance = self.instance_obj.create(
dict(name="test-instance", report_id=self.report.id, comparison_mode=False)
)
self.assertEqual(len(self.instance.period_ids), 1)
self.period = self.instance.period_ids[0]
def assertDateEqual(self, first, second, msg=None):
self.assertEqual(first, fields.Date.from_string(second), msg)
def test_date_filter_constraints(self):
self.instance.comparison_mode = True
with self.assertRaises(DateFilterRequired):
self.period.write(dict(mode=MODE_NONE))
with self.assertRaises(DateFilterForbidden):
self.period.write(dict(mode=MODE_FIX, source=SRC_SUMCOL))
def test_simple_mode(self):
# not comparison_mode
self.assertFalse(self.instance.comparison_mode)
period = self.instance.period_ids[0]
self.assertEqual(period.date_from, self.instance.date_from)
self.assertEqual(period.date_to, self.instance.date_to)
def tests_mode_none(self):
self.instance.comparison_mode = True
self.period.write(dict(mode=MODE_NONE, source=SRC_SUMCOL))
self.assertFalse(self.period.date_from)
self.assertFalse(self.period.date_to)
self.assertTrue(self.period.valid)
def tests_mode_fix(self):
self.instance.comparison_mode = True
self.period.write(
dict(
mode=MODE_FIX,
manual_date_from="2017-01-01",
manual_date_to="2017-12-31",
)
)
self.assertDateEqual(self.period.date_from, "2017-01-01")
self.assertDateEqual(self.period.date_to, "2017-12-31")
self.assertTrue(self.period.valid)
def test_rel_day(self):
self.instance.write(dict(comparison_mode=True, date="2017-01-01"))
self.period.write(dict(mode=MODE_REL, type="d", offset="-2"))
self.assertDateEqual(self.period.date_from, "2016-12-30")
self.assertDateEqual(self.period.date_to, "2016-12-30")
self.assertTrue(self.period.valid)
def test_rel_day_ytd(self):
self.instance.write(dict(comparison_mode=True, date="2019-05-03"))
self.period.write(dict(mode=MODE_REL, type="d", offset="-2", is_ytd=True))
self.assertDateEqual(self.period.date_from, "2019-01-01")
self.assertDateEqual(self.period.date_to, "2019-05-01")
self.assertTrue(self.period.valid)
def test_rel_week(self):
self.instance.write(dict(comparison_mode=True, date="2016-12-30"))
self.period.write(dict(mode=MODE_REL, type="w", offset="1", duration=2))
# from Monday to Sunday, the week after 2016-12-30
self.assertDateEqual(self.period.date_from, "2017-01-02")
self.assertDateEqual(self.period.date_to, "2017-01-15")
self.assertTrue(self.period.valid)
def test_rel_week_ytd(self):
self.instance.write(dict(comparison_mode=True, date="2019-05-27"))
self.period.write(
dict(mode=MODE_REL, type="w", offset="1", duration=2, is_ytd=True)
)
self.assertDateEqual(self.period.date_from, "2019-01-01")
self.assertDateEqual(self.period.date_to, "2019-06-16")
self.assertTrue(self.period.valid)
def test_rel_month(self):
self.instance.write(dict(comparison_mode=True, date="2017-01-05"))
self.period.write(dict(mode=MODE_REL, type="m", offset="1"))
self.assertDateEqual(self.period.date_from, "2017-02-01")
self.assertDateEqual(self.period.date_to, "2017-02-28")
self.assertTrue(self.period.valid)
def test_rel_month_ytd(self):
self.instance.write(dict(comparison_mode=True, date="2019-05-15"))
self.period.write(dict(mode=MODE_REL, type="m", offset="-1", is_ytd=True))
self.assertDateEqual(self.period.date_from, "2019-01-01")
self.assertDateEqual(self.period.date_to, "2019-04-30")
self.assertTrue(self.period.valid)
def test_rel_year(self):
self.instance.write(dict(comparison_mode=True, date="2017-05-06"))
self.period.write(dict(mode=MODE_REL, type="y", offset="1"))
self.assertDateEqual(self.period.date_from, "2018-01-01")
self.assertDateEqual(self.period.date_to, "2018-12-31")
self.assertTrue(self.period.valid)
def test_rel_date_range(self):
# create a few date ranges
date_range_type = self.env["date.range.type"].create(dict(name="Year"))
for year in (2016, 2017, 2018):
self.env["date.range"].create(
dict(
type_id=date_range_type.id,
name="%d" % year,
date_start="%d-01-01" % year,
date_end="%d-12-31" % year,
company_id=date_range_type.company_id.id,
)
)
self.instance.write(dict(comparison_mode=True, date="2017-06-15"))
self.period.write(
dict(
mode=MODE_REL,
type="date_range",
date_range_type_id=date_range_type.id,
offset="-1",
duration=3,
)
)
self.assertDateEqual(self.period.date_from, "2016-01-01")
self.assertDateEqual(self.period.date_to, "2018-12-31")
self.assertTrue(self.period.valid)
def test_dates_in_expr(self):
self.env["mis.report.kpi"].create(
dict(
report_id=self.report.id,
name="k1",
description="kpi 1",
expression="(date_to - date_from).days + 1",
)
)
self.instance.date_from = "2017-01-01"
self.instance.date_to = "2017-01-31"
matrix = self.instance._compute_matrix()
assert_matrix(matrix, [[31]])

View file

@ -0,0 +1,315 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from ..models.accounting_none import AccountingNone
from ..models.data_error import DataError
from ..models.mis_report_style import CMP_DIFF, CMP_PCT, TYPE_NUM, TYPE_PCT, TYPE_STR
class TestRendering(common.TransactionCase):
def setUp(self):
super().setUp()
self.style_obj = self.env["mis.report.style"]
self.kpi_obj = self.env["mis.report.kpi"]
self.style = self.style_obj.create(dict(name="teststyle"))
self.lang = (
self.env["res.lang"]
.with_context(active_test=False)
.search([("code", "=", "en_US")])[0]
)
def _render(self, value, var_type=TYPE_NUM):
style_props = self.style_obj.merge([self.style])
return self.style_obj.render(self.lang, style_props, var_type, value)
def _compare_and_render(
self, value, base_value, var_type=TYPE_NUM, compare_method=CMP_PCT
):
style_props = self.style_obj.merge([self.style])
r = self.style_obj.compare_and_render(
self.lang, style_props, var_type, compare_method, value, base_value
)[:2]
if r[0]:
return (round(r[0], 8), r[1])
else:
return r
def test_render(self):
self.assertEqual("1", self._render(1))
self.assertEqual("1", self._render(1.1))
self.assertEqual("2", self._render(1.6))
self.style.dp_inherit = False
self.style.dp = 2
self.assertEqual("1.00", self._render(1))
self.assertEqual("1.10", self._render(1.1))
self.assertEqual("1.60", self._render(1.6))
self.assertEqual("1.61", self._render(1.606))
self.assertEqual("12,345.67", self._render(12345.67))
def test_render_negative(self):
# non breaking hyphen
self.assertEqual("\u20111", self._render(-1))
def test_render_zero(self):
self.assertEqual("0", self._render(0))
self.assertEqual("", self._render(None))
self.assertEqual("", self._render(AccountingNone))
def test_render_suffix(self):
self.style.suffix_inherit = False
self.style.suffix = ""
self.assertEqual("1\xa0", self._render(1))
self.style.suffix = "k€"
self.style.divider_inherit = False
self.style.divider = "1e3"
self.assertEqual("1\xa0k€", self._render(1000))
def test_render_prefix(self):
self.style.prefix_inherit = False
self.style.prefix = "$"
self.assertEqual("$\xa01", self._render(1))
self.style.prefix = "k$"
self.style.divider_inherit = False
self.style.divider = "1e3"
self.assertEqual("k$\xa01", self._render(1000))
def test_render_divider(self):
self.style.divider_inherit = False
self.style.divider = "1e3"
self.style.dp_inherit = False
self.style.dp = 0
self.assertEqual("1", self._render(1000))
self.style.divider = "1e6"
self.style.dp = 3
self.assertEqual("0.001", self._render(1000))
self.style.divider = "1e-3"
self.style.dp = 0
self.assertEqual("1,000", self._render(1))
self.style.divider = "1e-6"
self.style.dp = 0
self.assertEqual("1,000,000", self._render(1))
def test_render_pct(self):
self.assertEqual("100\xa0%", self._render(1, TYPE_PCT))
self.assertEqual("50\xa0%", self._render(0.5, TYPE_PCT))
self.style.dp_inherit = False
self.style.dp = 2
self.assertEqual("51.23\xa0%", self._render(0.5123, TYPE_PCT))
def test_render_string(self):
self.assertEqual("", self._render("", TYPE_STR))
self.assertEqual("", self._render(None, TYPE_STR))
self.assertEqual("abcdé", self._render("abcdé", TYPE_STR))
def test_compare_num_pct(self):
self.assertEqual((1.0, "+100.0\xa0%"), self._compare_and_render(100, 50))
self.assertEqual((0.5, "+50.0\xa0%"), self._compare_and_render(75, 50))
self.assertEqual((0.5, "+50.0\xa0%"), self._compare_and_render(-25, -50))
self.assertEqual((1.0, "+100.0\xa0%"), self._compare_and_render(0, -50))
self.assertEqual((2.0, "+200.0\xa0%"), self._compare_and_render(50, -50))
self.assertEqual((-0.5, "\u201150.0\xa0%"), self._compare_and_render(25, 50))
self.assertEqual((-1.0, "\u2011100.0\xa0%"), self._compare_and_render(0, 50))
self.assertEqual((-2.0, "\u2011200.0\xa0%"), self._compare_and_render(-50, 50))
self.assertEqual((-0.5, "\u201150.0\xa0%"), self._compare_and_render(-75, -50))
self.assertEqual(
(AccountingNone, ""), self._compare_and_render(50, AccountingNone)
)
self.assertEqual((AccountingNone, ""), self._compare_and_render(50, None))
self.assertEqual((AccountingNone, ""), self._compare_and_render(50, 50))
self.assertEqual((0.002, "+0.2\xa0%"), self._compare_and_render(50.1, 50))
self.assertEqual((AccountingNone, ""), self._compare_and_render(50.01, 50))
self.assertEqual(
(-1.0, "\u2011100.0\xa0%"), self._compare_and_render(AccountingNone, 50)
)
self.assertEqual((-1.0, "\u2011100.0\xa0%"), self._compare_and_render(None, 50))
self.assertEqual(
(AccountingNone, ""), self._compare_and_render(DataError("#ERR", "."), 1)
)
self.assertEqual(
(AccountingNone, ""), self._compare_and_render(1, DataError("#ERR", "."))
)
def test_compare_num_diff(self):
self.assertEqual(
(25, "+25"), self._compare_and_render(75, 50, TYPE_NUM, CMP_DIFF)
)
self.assertEqual(
(-25, "\u201125"), self._compare_and_render(25, 50, TYPE_NUM, CMP_DIFF)
)
self.style.suffix_inherit = False
self.style.suffix = ""
self.assertEqual(
(-25, "\u201125\xa0"),
self._compare_and_render(25, 50, TYPE_NUM, CMP_DIFF),
)
self.style.suffix = ""
self.assertEqual(
(50.0, "+50"),
self._compare_and_render(50, AccountingNone, TYPE_NUM, CMP_DIFF),
)
self.assertEqual(
(50.0, "+50"), self._compare_and_render(50, None, TYPE_NUM, CMP_DIFF)
)
self.assertEqual(
(-50.0, "\u201150"),
self._compare_and_render(AccountingNone, 50, TYPE_NUM, CMP_DIFF),
)
self.assertEqual(
(-50.0, "\u201150"), self._compare_and_render(None, 50, TYPE_NUM, CMP_DIFF)
)
self.style.dp_inherit = False
self.style.dp = 2
self.assertEqual(
(0.1, "+0.10"), self._compare_and_render(1.1, 1.0, TYPE_NUM, CMP_DIFF)
)
self.assertEqual(
(AccountingNone, ""),
self._compare_and_render(1.001, 1.0, TYPE_NUM, CMP_DIFF),
)
def test_compare_pct(self):
self.assertEqual(
(0.25, "+25\xa0pp"), self._compare_and_render(0.75, 0.50, TYPE_PCT)
)
self.assertEqual(
(AccountingNone, ""), self._compare_and_render(0.751, 0.750, TYPE_PCT)
)
def test_compare_pct_result_type(self):
style_props = self.style_obj.merge([self.style])
result = self.style_obj.compare_and_render(
self.lang, style_props, TYPE_PCT, CMP_DIFF, 0.75, 0.50
)
self.assertEqual(result[3], TYPE_NUM)
def test_merge(self):
self.style.color = "#FF0000"
self.style.color_inherit = False
style_props = self.style_obj.merge([self.style])
self.assertEqual(style_props, {"color": "#FF0000"})
style_dict = {"color": "#00FF00", "dp": 0}
style_props = self.style_obj.merge([self.style, style_dict])
self.assertEqual(style_props, {"color": "#00FF00", "dp": 0})
style2 = self.style_obj.create(
dict(
name="teststyle2",
dp_inherit=False,
dp=1,
# color_inherit=True: will not be applied
color="#0000FF",
)
)
style_props = self.style_obj.merge([self.style, style_dict, style2])
self.assertEqual(style_props, {"color": "#00FF00", "dp": 1})
def test_css(self):
self.style.color_inherit = False
self.style.color = "#FF0000"
self.style.background_color_inherit = False
self.style.background_color = "#0000FF"
self.style.suffix_inherit = False
self.style.suffix = "s"
self.style.prefix_inherit = False
self.style.prefix = "p"
self.style.font_style_inherit = False
self.style.font_style = "italic"
self.style.font_weight_inherit = False
self.style.font_weight = "bold"
self.style.font_size_inherit = False
self.style.font_size = "small"
self.style.indent_level_inherit = False
self.style.indent_level = 2
style_props = self.style_obj.merge([self.style])
css = self.style_obj.to_css_style(style_props)
self.assertEqual(
css,
"font-style: italic; "
"font-weight: bold; "
"font-size: small; "
"color: #FF0000; "
"background-color: #0000FF; "
"text-indent: 2em",
)
css = self.style_obj.to_css_style(style_props, no_indent=True)
self.assertEqual(
css,
"font-style: italic; "
"font-weight: bold; "
"font-size: small; "
"color: #FF0000; "
"background-color: #0000FF",
)
def test_xslx(self):
self.style.color_inherit = False
self.style.color = "#FF0000"
self.style.background_color_inherit = False
self.style.background_color = "#0000FF"
self.style.suffix_inherit = False
self.style.suffix = "s"
self.style.prefix_inherit = False
self.style.prefix = "p"
self.style.dp_inherit = False
self.style.dp = 2
self.style.font_style_inherit = False
self.style.font_style = "italic"
self.style.font_weight_inherit = False
self.style.font_weight = "bold"
self.style.font_size_inherit = False
self.style.font_size = "small"
self.style.indent_level_inherit = False
self.style.indent_level = 2
style_props = self.style_obj.merge([self.style])
xlsx = self.style_obj.to_xlsx_style(TYPE_NUM, style_props)
self.assertEqual(
xlsx,
{
"italic": True,
"bold": True,
"font_size": 9,
"font_color": "#FF0000",
"bg_color": "#0000FF",
"num_format": '"p "#,##0.00" s"',
"indent": 2,
},
)
xlsx = self.style_obj.to_xlsx_style(TYPE_NUM, style_props, no_indent=True)
self.assertEqual(
xlsx,
{
"italic": True,
"bold": True,
"font_size": 9,
"font_color": "#FF0000",
"bg_color": "#0000FF",
"num_format": '"p "#,##0.00" s"',
},
)
# percent type ignore prefix and suffix
xlsx = self.style_obj.to_xlsx_style(TYPE_PCT, style_props, no_indent=True)
self.assertEqual(
xlsx,
{
"italic": True,
"bold": True,
"font_size": 9,
"font_color": "#FF0000",
"bg_color": "#0000FF",
"num_format": "0.00%",
},
)
# str type have no num_format style
xlsx = self.style_obj.to_xlsx_style(TYPE_STR, style_props, no_indent=True)
self.assertEqual(
xlsx,
{
"italic": True,
"bold": True,
"font_size": 9,
"font_color": "#FF0000",
"bg_color": "#0000FF",
},
)

View file

@ -0,0 +1,7 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from ..models import simple_array
from .common import load_doctests
load_tests = load_doctests(simple_array)

View file

@ -0,0 +1,96 @@
# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo.tests.common import TransactionCase
from odoo.addons.mis_builder.models.expression_evaluator import ExpressionEvaluator
from odoo.addons.mis_builder.models.mis_report_subreport import (
InvalidNameError,
ParentLoopError,
)
class TestMisSubreport(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# create report
cls.subreport = cls.env["mis.report"].create(dict(name="test subreport"))
cls.subreport_kpi1 = cls.env["mis.report.kpi"].create(
dict(
report_id=cls.subreport.id,
name="sk1",
description="subreport kpi 1",
expression="11",
)
)
cls.report = cls.env["mis.report"].create(
dict(
name="test report",
subreport_ids=[
(0, 0, dict(name="subreport", subreport_id=cls.subreport.id))
],
)
)
cls.report_kpi1 = cls.env["mis.report.kpi"].create(
dict(
report_id=cls.report.id,
name="k1",
description="report kpi 1",
expression="subreport.sk1 + 1",
)
)
cls.parent_report = cls.env["mis.report"].create(
dict(
name="parent report",
subreport_ids=[(0, 0, dict(name="report", subreport_id=cls.report.id))],
)
)
cls.parent_report_kpi1 = cls.env["mis.report.kpi"].create(
dict(
report_id=cls.parent_report.id,
name="pk1",
description="parent report kpi 1",
expression="report.k1 + 1",
)
)
def test_basic(self):
ee = ExpressionEvaluator(aep=None, date_from="2017-01-01", date_to="2017-01-16")
d = self.report._evaluate(ee)
assert d["k1"] == 12
def test_two_levels(self):
ee = ExpressionEvaluator(aep=None, date_from="2017-01-01", date_to="2017-01-16")
d = self.parent_report._evaluate(ee)
assert d["pk1"] == 13
def test_detect_loop(self):
with self.assertRaises(ParentLoopError):
self.report.write(
dict(
subreport_ids=[
(
0,
0,
dict(name="preport1", subreport_id=self.parent_report.id),
)
]
)
)
with self.assertRaises(ParentLoopError):
self.report.write(
dict(
subreport_ids=[
(
0,
0,
dict(name="preport2", subreport_id=self.report.id),
)
]
)
)
def test_invalid_name(self):
with self.assertRaises(InvalidNameError):
self.report.subreport_ids[0].name = "ab c"

View file

@ -0,0 +1,36 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
class TestMisReportInstance(common.TransactionCase):
def test_supports_target_move_filter(self):
self.assertTrue(
self.env["mis.report"]._supports_target_move_filter("account.move.line")
)
def test_supports_target_move_filter_no_parent_state(self):
self.assertFalse(
self.env["mis.report"]._supports_target_move_filter("account.move")
)
def test_target_move_domain_posted(self):
self.assertEqual(
self.env["mis.report"]._get_target_move_domain(
"posted", "account.move.line"
),
[("parent_state", "=", "posted")],
)
def test_target_move_domain_all(self):
self.assertEqual(
self.env["mis.report"]._get_target_move_domain("all", "account.move.line"),
[("parent_state", "in", ("posted", "draft"))],
)
def test_target_move_domain_no_parent_state(self):
"""Test get_target_move_domain on a model that has no parent_state."""
self.assertEqual(
self.env["mis.report"]._get_target_move_domain("all", "account.move"), []
)

View file

@ -0,0 +1,19 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import odoo.tests.common as common
from ..models.mis_report import _utc_midnight
class TestUtcMidnight(common.TransactionCase):
def test_utc_midnight(self):
date_to_convert = "2014-07-05"
date_time_convert = _utc_midnight(date_to_convert, "Europe/Brussels")
self.assertEqual(date_time_convert, "2014-07-04 22:00:00")
date_time_convert = _utc_midnight(date_to_convert, "Europe/Brussels", add_day=1)
self.assertEqual(date_time_convert, "2014-07-05 22:00:00")
date_time_convert = _utc_midnight(date_to_convert, "US/Pacific")
self.assertEqual(date_time_convert, "2014-07-05 07:00:00")
date_time_convert = _utc_midnight(date_to_convert, "US/Pacific", add_day=1)
self.assertEqual(date_time_convert, "2014-07-06 07:00:00")

View file

@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.view" id="mis_report_view_tree">
<field name="name">mis.report.view.tree</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="description" />
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_view_form">
<field name="name">mis.report.view.form</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<form string="MIS Report">
<sheet>
<group col="2">
<field name="name" />
<field name="description" />
<field name="style_id" />
<field name="move_lines_source" options="{'no_open': true}" />
</group>
<notebook>
<page string="KPI's">
<field
name="kpi_ids"
nolabel="1"
colspan="2"
context="{'default_report_id': id}"
>
<tree>
<field name="sequence" widget="handle" />
<field name="description" />
<field name="name" />
<field name="type" />
<field
name="compare_method"
attrs="{'invisible': [('type', '=', 'str')]}"
/>
<field
name="accumulation_method"
attrs="{'invisible': [('type', '=', 'str')]}"
/>
<field name="expression" />
</tree>
</field>
</page>
<page string="Queries">
<field
name="query_ids"
nolabel="1"
colspan="2"
context="{'default_report_id': id}"
>
<tree editable="bottom">
<field name="name" />
<field name="model_id" />
<field
name="field_ids"
domain="[('model_id', '=', model_id)]"
widget="many2many_tags"
/>
<field name="field_names" />
<field name="aggregate" />
<field
name="date_field"
domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]"
/>
<field name="domain" />
</tree>
</field>
</page>
<page string="Sub KPI's">
<field name="subkpi_ids" nolabel="1" colspan="2">
<tree editable="bottom">
<field name="sequence" widget="handle" />
<field name="description" />
<field name="name" />
</tree>
</field>
</page>
<page string="Sub Reports">
<field
name="subreport_ids"
nolabel="1"
colspan="2"
context="{'default_report_id': id}"
>
<tree editable="bottom">
<field name="name" />
<field
name="subreport_id"
domain="[('id', '!=', parent.id)]"
/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="mis_report_view_kpi_form" model="ir.ui.view">
<field name="name">mis.report.view.kpi.form</field>
<field name="model">mis.report.kpi</field>
<field name="arch" type="xml">
<form string="MIS Report KPI">
<group col="4">
<field name="description" />
<field name="name" />
<field name="type" />
<newline />
<field name="compare_method" />
<field name="accumulation_method" />
<field name="style_id" />
<field name="style_expression" />
<field name='id' invisible='1' />
<field
name="report_id"
invisible="1"
attrs="{'required': [('id', '!=', False)]}"
/>
</group>
<notebook>
<page string="Expressions">
<group col="2">
<field name="multi" />
<newline />
<field
name="expression_ids"
colspan="2"
nolabel="1"
attrs="{'invisible': [('multi', '=', False)]}"
>
<tree editable="bottom">
<field
name="subkpi_id"
domain="[('report_id', '=', parent.report_id)]"
/>
<field name="name" />
</tree>
</field>
<field
name="expression"
colspan="2"
nolabel="1"
attrs="{'invisible': [('multi', '=', True)],
'readonly': [('multi', '=', True)]}"
placeholder="Enter expression here, for example balp[70%]. See also help tab."
/>
</group>
<group col="4" string="Auto expand">
<field name="auto_expand_accounts" />
<field
name="auto_expand_accounts_style_id"
attrs="{'invisible': [('auto_expand_accounts', '!=', True)]}"
/>
</group>
</page>
<page string="Help (for KPI expressions)">
<div style="display: flex; width: 100;">
<div>
<p>
Expressions can be any valid python expressions.
</p>
<p
> The following special elements are recognized in the expressions
to compute accounting data: <code
>{bal|crd|deb|pbal|nbal|fld}{pieu}(.fieldname)[account
selector][journal items domain]</code>. </p>
<ul>
<li>
<code>bal</code>, <code>crd</code>, <code
>deb</code>, <code>
pbal</code>, <code>nbal</code>, <code
>fld</code> : balance, debit, credit,
positive balance, negative balance,
other numerical field. </li>
<li>
<code>p</code>, <code>i</code>, <code
>e</code> : respectively variation over the period,
initial balance, ending balance </li>
<li>when <code
>fld</code> is used : a field name specifier
must be provided (e.g. <code
>fldp.quantity</code></li>
<li> The <b
>account selector</b> is a like expression on the
account code (eg <code
>70%</code>, etc), or a domain over accounts
(eg <code
>[('code', 'like', '60%')]</code>). </li>
<li> The <b
>journal items domain</b> is an Odoo domain filter on
journal items. </li>
<li>
<code>balu[]</code> : (<code
>u</code> for unallocated) is a special expression
that shows the unallocated profit/loss of previous fiscal
years. </li>
</ul>
<p>
Expressions can involve other KPI, sub KPI and
query results by name (eg <code>kpi1 + kpi2</code>,
<code>kpi2.subkpi1</code>, <code
>query1.field1</code>).
</p>
<p>
Additionally following variables are available
in the evaluation context:
</p>
<ul>
<li>
<code>sum</code>, <code>min</code>,
<code>max</code>, <code>len</code>,
<code>avg</code> : behave as expected, very
similar to the python builtins. </li>
<li>
<code>datetime</code>, <code
>datetime</code>, <code
>dateutil</code> : the python modules. </li>
<li>
<code>date_from</code>, <code
>date_to</code> : beginning and end date of the
period. </li>
<li>
<code
>AccountingNone</code> : a null value that behaves as 0 in
arithmetic operations. </li>
</ul>
</div>
<div>
<p>Examples:</p>
<ul>
<li>
<code
>bal[70]</code> : variation of the balance of account 70 over
the period (it is the same as <code
>balp[70]</code>. </li>
<li>
<code
>bali[70,60]</code> : initial balance of accounts 70 and 60. </li>
<li>
<code
>bale[1%%]</code> : balance of accounts starting with 1 at
end of period. </li>
<li>
<code
>crdp[40%]</code> : sum of all credits on accounts starting
with 40 during the period. </li>
<li>
<code>
debp[55%][('journal_id.code', '=',
'BNK1')]
</code>
: sum of all debits on accounts 55 and journal BNK1 during
the period. </li>
<li>
<code>
balp[('user_type_id', '=',
ref('account.
data_account_type_receivable').id)][]
</code>
: variation of the balance of all receivable accounts over
the period. </li>
<li>
<code>
balp[][('tax_line_id.tag_ids', '=', ref('l10n_be.tax_tag_56').id)]
</code>
: balance of move lines related to tax grid 56. </li>
<li>
<code
>pbale[55%]</code> : sum of all ending balances of accounts
starting with 55 whose ending balance is positive. </li>
</ul>
</div>
</div>
</page>
</notebook>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_view_action">
<field name="name">MIS Report Templates</field>
<field name="view_id" ref="mis_report_view_tree" />
<field name="res_model">mis.report</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
id="mis_report_conf_menu"
parent="account.menu_finance_configuration"
name="MIS Reporting"
sequence="90"
/>
<menuitem
id="mis_report_view_menu"
parent="mis_report_conf_menu"
name="MIS Report Templates"
action="mis_report_view_action"
sequence="21"
/>
</odoo>

View file

@ -0,0 +1,423 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.view" id="mis_report_instance_result_view_form">
<field name="name">mis.report.instance.result.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="20 " />
<field name="arch" type="xml">
<form
string="MIS Report Preview"
edit="false"
create="false"
delete="false"
>
<sheet>
<field
name="id"
widget="mis_report_widget"
nolabel="1"
style="width:100%"
/>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_tree">
<field name="name">mis.report.instance.view.tree</field>
<field name="model">mis.report.instance</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle" />
<button
type="object"
name="preview"
string="Preview"
icon="fa-search"
/>
<button type="object" name="print_pdf" string="Print" icon="fa-print" />
<button
type="object"
name="export_xls"
string="Export"
icon="fa-download"
/>
<field name="name" />
<field name="report_id" string="Template" />
<field name="company_id" groups="base.group_multi_company" />
<field name="multi_company" groups="base.group_multi_company" />
<field name="currency_id" groups="base.group_multi_currency" />
<field name="target_move" />
<field name="pivot_date" />
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_form">
<field name="name">mis.report.instance.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="15" />
<field name="arch" type="xml">
<form string="MIS Report Instance">
<sheet>
<field name="temporary" invisible="1" />
<div class="oe_right oe_button_box" name="buttons">
<button
type="object"
name="preview"
string="Preview"
icon="fa-search"
/>
<button
type="object"
name="print_pdf"
string="Print"
icon="fa-print"
/>
<button
type="object"
name="export_xls"
string="Export"
icon="fa-download"
/>
<button
type="action"
name="%(mis_report_instance_add_to_dashboard_action)d"
string="Add to dashboard"
icon="fa-plus"
attrs="{'invisible': [('temporary', '=', True)]}"
/>
<button
type="object"
name="save_report"
string="Save"
icon="fa-save"
attrs="{'invisible': [('temporary', '=', False)]}"
/>
</div>
<div class="oe_title">
<div class="oe_edit_only">
<label for="name" />
</div>
<h1>
<field name="name" placeholder="Name" />
</h1>
<field name="description" />
</div>
<group>
<group>
<field name="report_id" string="Template" />
<field
name="currency_id"
groups="base.group_multi_currency"
/>
<field name="comparison_mode" />
</group>
<group>
<group
name="simple_mode"
attrs="{'invisible': [('comparison_mode', '=', True)]}"
colspan="4"
>
<field name="date_range_id" />
<field
name="date_from"
attrs="{'required': [('comparison_mode', '=', False)]}"
/>
<field
name="date_to"
attrs="{'required': [('comparison_mode', '=', False)]}"
/>
</group>
</group>
</group>
<notebook>
<page
string="Columns"
attrs="{'invisible': [('comparison_mode', '=', False)]}"
>
<group>
<group>
<field name="date" />
</group>
<group>
</group>
<field
name="period_ids"
nolabel="1"
colspan="4"
attrs="{'required': [('comparison_mode', '=', True)]}"
context="{'default_report_instance_id': id}"
>
<tree decoration-danger="not valid">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="source" />
<field name="source_aml_model_id" />
<field name="date_from" />
<field name="date_to" />
<field name="valid" invisible="1" />
</tree>
</field>
</group>
</page>
<page string="Filters">
<group name="filters">
<field name="target_move" widget="radio" />
<field
name="multi_company"
groups="base.group_multi_company"
/>
<field
name="company_id"
groups="base.group_multi_company"
attrs="{'required': [('multi_company', '=', False)], 'invisible': [('multi_company', '=', True)]}"
/>
<field
name="company_ids"
groups="base.group_multi_company"
widget="many2many_tags"
attrs="{'invisible': [('multi_company', '=', False)]}"
/>
<field
name="query_company_ids"
groups="base.group_multi_company"
widget="many2many_tags"
/>
<field name="source_aml_model_name" invisible="1" />
<field
name="analytic_domain"
widget="domain"
options="{'model': 'source_aml_model_name'}"
/>
</group>
</page>
<page string="Layout">
<group name="layout">
<field name="landscape_pdf" />
<field name="no_auto_expand_accounts" />
<field name="display_columns_description" />
<field name="wide_display_by_default" />
</group>
</page>
<page string="Widget">
<group name="widget">
<field name="widget_show_filters" />
<field
name="widget_search_view_id"
attrs="{'invisible': [('widget_show_filters', '=', False)]}"
/>
<field name="widget_show_settings_button" />
<field name="widget_show_pivot_date" />
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_instance_view_action">
<field name="name">MIS Reports</field>
<field name="view_id" ref="mis_report_instance_view_tree" />
<field name="res_model">mis.report.instance</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('temporary', '=', False)]</field>
</record>
<menuitem
id="mis_report_finance_menu"
parent="account.menu_finance_reports"
name="MIS Reporting"
sequence="101"
groups="account.group_account_manager"
/>
<menuitem
id="mis_report_instance_view_menu"
parent="mis_report_finance_menu"
name="MIS Reports"
action="mis_report_instance_view_action"
sequence="10"
/>
<record id="wizard_mis_report_instance_view_form" model="ir.ui.view">
<field name="model">mis.report.instance</field>
<field name="priority">99</field>
<field name="inherit_id" ref="mis_builder.mis_report_instance_view_form" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<field name="name" position="attributes">
<attribute name="readonly">1</attribute>
</field>
<label for="name" position="replace" />
<field name="report_id" position="attributes">
<attribute name="invisible">1</attribute>
</field>
<div name="buttons" position="attributes">
<attribute name="invisible">1</attribute>
</div>
<sheet position="after">
<footer>
<button
type="object"
name="save_report"
string="Save"
icon="fa-save"
/>
<button
type="object"
name="preview"
string="Preview"
icon="fa-search"
/>
<button
type="object"
name="print_pdf"
string="Print"
icon="fa-print"
/>
<button
type="object"
name="export_xls"
string="Export"
icon="fa-download"
/> or <button string="Cancel" class="oe_link" special="cancel" />
</footer>
</sheet>
</field>
</record>
<record model="ir.actions.act_window" id="last_mis_report_instance_view_action">
<field name="name">Last Reports Generated</field>
<field name="view_id" ref="mis_report_instance_view_tree" />
<field name="res_model">mis.report.instance</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('temporary', '=', True)]</field>
</record>
<menuitem
id="last_wizard_mis_report_instance_view_menu"
parent="mis_report_finance_menu"
name="Last Reports Generated"
action="last_mis_report_instance_view_action"
sequence="20"
/>
<record model="ir.ui.view" id="mis_report_instance_period_view_form">
<field name="model">mis.report.instance.period</field>
<field name="priority" eval="16" />
<field name="arch" type="xml">
<form>
<group col="4">
<field name="name" placeholder="Name" />
<field
name="subkpi_ids"
domain="[('report_id', '=', parent.report_id)]"
widget="many2many_tags"
options="{'no_create': True}"
/>
<field name="valid" invisible="1" />
<field
name="report_instance_id"
invisible="1"
attrs="{'required': [('id', '!=', False)]}"
/>
<field name="report_id" invisible="1" />
<field name="id" invisible="1" />
</group>
<group string="Source" col="4">
<group colspan="2" name="source">
<field name="source" />
</group>
<group col="2" colspan="2" name="source_data">
<field
name="source_aml_model_id"
attrs="{'invisible': [('source', '!=', 'actuals_alt')], 'required': [('source', '==', 'actuals_alt')]}"
/>
<field name="source_aml_model_name" invisible="1" />
<field
name="source_sumcol_ids"
attrs="{'invisible': [('source', '!=', 'sumcol')]}"
nolabel="1"
colspan="2"
>
<tree editable="bottom">
<field name="sign" />
<field
name="period_to_sum_id"
domain="[('report_instance_id', '=', parent.report_instance_id), ('id', '!=', parent.id)]"
options="{'no_create': True, 'no_open': True}"
/>
</tree>
</field>
<field
name="source_sumcol_accdet"
attrs="{'invisible': [('source', '!=', 'sumcol')]}"
/>
<field name="allowed_cmpcol_ids" invisible="1" />
<field
name="source_cmpcol_to_id"
attrs="{'invisible': [('source', '!=', 'cmpcol')], 'required': [('source', '=', 'cmpcol')]}"
domain="[('id', 'in', allowed_cmpcol_ids)]"
options="{'no_create': True, 'no_open': True}"
/>
<field
name="source_cmpcol_from_id"
attrs="{'invisible': [('source', '!=', 'cmpcol')], 'required': [('source', '=', 'cmpcol')]}"
domain="[('id', 'in', allowed_cmpcol_ids)]"
options="{'no_create': True, 'no_open': True}"
/>
</group>
</group>
<group string="Dates">
<group colspan="4">
<field name="mode" widget="radio" />
</group>
<group
name="relative"
attrs="{'invisible': [('mode', '!=', 'relative')]}"
colspan="4"
>
<group>
<field
name="type"
attrs="{'required': [('mode', '=', 'relative')]}"
/>
<field name="is_ytd" />
<field
name="date_range_type_id"
attrs="{'invisible': [('type', '!=', 'date_range')], 'required': [('type', '=', 'date_range'), ('mode', '=', 'relative')]}"
/>
<field name="offset" />
<field name="duration" />
</group>
<group>
<field name="date_from" />
<field name="date_to" />
</group>
</group>
<group
name="fix"
attrs="{'invisible': [('mode', '!=', 'fix')]}"
colspan="4"
>
<group>
<field name="date_range_id" />
</group>
<group>
<field
name="manual_date_from"
attrs="{'required': [('mode', '=', 'fix')]}"
/>
<field
name="manual_date_to"
attrs="{'required': [('mode', '=', 'fix')]}"
/>
</group>
</group>
</group>
<group string="Filters">
<field
name="analytic_domain"
widget="domain"
options="{'model': 'source_aml_model_name'}"
attrs="{'invisible': [('source_aml_model_name', '=', False)]}"
/>
</group>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.view" id="mis_report_style_view_tree">
<field name="name">mis.report.style.view.tree</field>
<field name="model">mis.report.style</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
</tree>
</field>
</record>
<record id="mis_report_style_view_form" model="ir.ui.view">
<field name="name">mis.report.style.view.form</field>
<field name="model">mis.report.style</field>
<field name="arch" type="xml">
<form string="MIS Report Style">
<sheet>
<group string="Style" col="2">
<field name="name" />
</group>
<group string="Number" col="4">
<field name="dp_inherit" string="Rounding inherit" />
<field
name="dp"
attrs="{'invisible': [('dp_inherit', '=', True)]}"
/>
<field name="divider_inherit" string="Factor inherit" />
<field
name="divider"
attrs="{'invisible': [('divider_inherit', '=', True)]}"
/>
<field name="prefix_inherit" />
<field
name="prefix"
attrs="{'invisible': [('prefix_inherit', '=', True)]}"
/>
<field name="suffix_inherit" />
<field
name="suffix"
attrs="{'invisible': [('suffix_inherit', '=', True)]}"
/>
</group>
<group string="Color" col="4">
<field name="color_inherit" />
<field
name="color"
attrs="{'invisible': [('color_inherit', '=', True)]}"
widget="color"
/>
<field name="background_color_inherit" />
<field
name="background_color"
attrs="{'invisible': [('background_color_inherit', '=', True)]}"
widget="color"
/>
</group>
<group string="Font" col="4">
<field name="font_style_inherit" />
<field
name="font_style"
attrs="{'invisible': [('font_style_inherit', '=', True)]}"
/>
<field name="font_weight_inherit" />
<field
name="font_weight"
attrs="{'invisible': [('font_weight_inherit', '=', True)]}"
/>
<field name="font_size_inherit" />
<field
name="font_size"
attrs="{'invisible': [('font_size_inherit', '=', True)]}"
/>
</group>
<group string="Indent" col="4">
<field name="indent_level_inherit" />
<field
name="indent_level"
attrs="{'invisible': [('indent_level_inherit', '=', True)]}"
/>
</group>
<group string="Visibility" col="4">
<field name="hide_empty_inherit" />
<field
name="hide_empty"
attrs="{'invisible': [('hide_empty_inherit', '=', True)]}"
/>
<field name="hide_always_inherit" />
<field
name="hide_always"
attrs="{'invisible': [('hide_always_inherit', '=', True)]}"
/>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_style_view_action">
<field name="name">MIS Report Styles</field>
<field name="view_id" ref="mis_report_style_view_tree" />
<field name="res_model">mis.report.style</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
id="mis_report_style_view_menu"
parent="mis_report_conf_menu"
name="MIS Report Styles"
action="mis_report_style_view_action"
sequence="22"
/>
</odoo>

View file

@ -0,0 +1,4 @@
# Copyright 2014 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import mis_builder_dashboard

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