mirror of
https://github.com/bringout/oca-report.git
synced 2026-04-18 01:42:06 +02:00
Initial commit: OCA Report packages (45 packages)
This commit is contained in:
commit
2f4db400df
2543 changed files with 469120 additions and 0 deletions
91
README.md
Normal file
91
README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# OCA Report
|
||||
|
||||
This repository contains **45** OCA packages for report.
|
||||
|
||||
## Packages Included (45 packages)
|
||||
|
||||
- **odoo-bringout-oca-mis-builder-mis_builder** - From mis: builder_mis_builder
|
||||
- **odoo-bringout-oca-mis-builder-mis_builder_budget** - From mis: builder_mis_builder_budget
|
||||
- **odoo-bringout-oca-mis-builder-mis_builder_demo** - From mis: builder_mis_builder_demo
|
||||
- **odoo-bringout-oca-report-print-send-base_report_to_label_printer** - From report: print_send_base_report_to_label_printer
|
||||
- **odoo-bringout-oca-report-print-send-base_report_to_printer** - From report: print_send_base_report_to_printer
|
||||
- **odoo-bringout-oca-report-print-send-base_report_to_printer_mail** - From report: print_send_base_report_to_printer_mail
|
||||
- **odoo-bringout-oca-report-print-send-pingen** - From report: print_send_pingen
|
||||
- **odoo-bringout-oca-report-print-send-pingen_env** - From report: print_send_pingen_env
|
||||
- **odoo-bringout-oca-report-print-send-printer_zpl2** - From report: print_send_printer_zpl2
|
||||
- **odoo-bringout-oca-report-print-send-printing_simple_configuration** - From report: print_send_printing_simple_configuration
|
||||
- **odoo-bringout-oca-report-print-send-remote_report_to_printer** - From report: print_send_remote_report_to_printer
|
||||
- **odoo-bringout-oca-reporting-engine-base_comment_template** - From reporting: engine_base_comment_template
|
||||
- **odoo-bringout-oca-reporting-engine-bi_sql_editor** - From reporting: engine_bi_sql_editor
|
||||
- **odoo-bringout-oca-reporting-engine-bi_view_editor** - From reporting: engine_bi_view_editor
|
||||
- **odoo-bringout-oca-reporting-engine-bi_view_editor_spreadsheet_dashboard** - From reporting: engine_bi_view_editor_spreadsheet_dashboard
|
||||
- **odoo-bringout-oca-reporting-engine-report_async** - From reporting: engine_report_async
|
||||
- **odoo-bringout-oca-reporting-engine-report_company_details_translatable** - From reporting: engine_report_company_details_translatable
|
||||
- **odoo-bringout-oca-reporting-engine-report_context** - From reporting: engine_report_context
|
||||
- **odoo-bringout-oca-reporting-engine-report_csv** - From reporting: engine_report_csv
|
||||
- **odoo-bringout-oca-reporting-engine-report_display_name_in_footer** - From reporting: engine_report_display_name_in_footer
|
||||
- **odoo-bringout-oca-reporting-engine-report_generate_helper** - From reporting: engine_report_generate_helper
|
||||
- **odoo-bringout-oca-reporting-engine-report_label** - From reporting: engine_report_label
|
||||
- **odoo-bringout-oca-reporting-engine-report_paperformat_company_dependent** - From reporting: engine_report_paperformat_company_dependent
|
||||
- **odoo-bringout-oca-reporting-engine-report_py3o** - From reporting: engine_report_py3o
|
||||
- **odoo-bringout-oca-reporting-engine-report_py3o_fusion_server** - From reporting: engine_report_py3o_fusion_server
|
||||
- **odoo-bringout-oca-reporting-engine-report_qr** - From reporting: engine_report_qr
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_decimal_place** - From reporting: engine_report_qweb_decimal_place
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_element_page_visibility** - From reporting: engine_report_qweb_element_page_visibility
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_encrypt** - From reporting: engine_report_qweb_encrypt
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_field_option** - From reporting: engine_report_qweb_field_option
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_parameter** - From reporting: engine_report_qweb_parameter
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_pdf_cover** - From reporting: engine_report_qweb_pdf_cover
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_pdf_watermark** - From reporting: engine_report_qweb_pdf_watermark
|
||||
- **odoo-bringout-oca-reporting-engine-report_qweb_signer** - From reporting: engine_report_qweb_signer
|
||||
- **odoo-bringout-oca-reporting-engine-report_substitute** - From reporting: engine_report_substitute
|
||||
- **odoo-bringout-oca-reporting-engine-report_text_format_option** - From reporting: engine_report_text_format_option
|
||||
- **odoo-bringout-oca-reporting-engine-report_wkhtmltopdf_param** - From reporting: engine_report_wkhtmltopdf_param
|
||||
- **odoo-bringout-oca-reporting-engine-report_xlsx** - From reporting: engine_report_xlsx
|
||||
- **odoo-bringout-oca-reporting-engine-report_xlsx_helper** - From reporting: engine_report_xlsx_helper
|
||||
- **odoo-bringout-oca-reporting-engine-report_xml** - From reporting: engine_report_xml
|
||||
- **odoo-bringout-oca-reporting-engine-sql_export** - From reporting: engine_sql_export
|
||||
- **odoo-bringout-oca-reporting-engine-sql_export_delta** - From reporting: engine_sql_export_delta
|
||||
- **odoo-bringout-oca-reporting-engine-sql_export_excel** - From reporting: engine_sql_export_excel
|
||||
- **odoo-bringout-oca-reporting-engine-sql_export_mail** - From reporting: engine_sql_export_mail
|
||||
- **odoo-bringout-oca-reporting-engine-sql_request_abstract** - From reporting: engine_sql_request_abstract
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Install any package from this category:
|
||||
|
||||
```bash
|
||||
# Install from local directory
|
||||
pip install packages/oca-report/PACKAGE_NAME/
|
||||
|
||||
# Install in development mode
|
||||
pip install -e packages/oca-report/PACKAGE_NAME/
|
||||
|
||||
# Using uv (recommended for speed)
|
||||
uv add packages/oca-report/PACKAGE_NAME/
|
||||
```
|
||||
|
||||
## Repository Structure
|
||||
|
||||
Each package in this repository follows the standard Odoo addon structure:
|
||||
|
||||
```
|
||||
oca-report/
|
||||
├── odoo-bringout-oca-PROJECT-ADDON/
|
||||
│ ├── ADDON_NAME/ # Complete addon code
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── __manifest__.py
|
||||
│ │ └── ... (models, views, etc.)
|
||||
│ ├── pyproject.toml # Python package configuration
|
||||
│ └── README.md # Package documentation
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
These packages are maintained as part of the [OCA (Odoo Community Association)](https://github.com/OCA) ecosystem.
|
||||
|
||||
## License
|
||||
|
||||
Each package maintains its original license as specified in the OCA repositories.
|
||||
47
odoo-bringout-oca-mis-builder-mis_builder/README.md
Normal file
47
odoo-bringout-oca-mis-builder-mis_builder/README.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for mis_builder. Configure related models, access rights, and options as needed.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Controllers
|
||||
|
||||
This module does not define custom HTTP controllers.
|
||||
|
|
@ -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)
|
||||
4
odoo-bringout-oca-mis-builder-mis_builder/doc/FAQ.md
Normal file
4
odoo-bringout-oca-mis-builder-mis_builder/doc/FAQ.md
Normal 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.
|
||||
7
odoo-bringout-oca-mis-builder-mis_builder/doc/INSTALL.md
Normal file
7
odoo-bringout-oca-mis-builder-mis_builder/doc/INSTALL.md
Normal 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"
|
||||
```
|
||||
24
odoo-bringout-oca-mis-builder-mis_builder/doc/MODELS.md
Normal file
24
odoo-bringout-oca-mis-builder-mis_builder/doc/MODELS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
32
odoo-bringout-oca-mis-builder-mis_builder/doc/REPORTS.md
Normal file
32
odoo-bringout-oca-mis-builder-mis_builder/doc/REPORTS.md
Normal 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
|
||||
45
odoo-bringout-oca-mis-builder-mis_builder/doc/SECURITY.md
Normal file
45
odoo-bringout-oca-mis-builder-mis_builder/doc/SECURITY.md
Normal 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
|
||||
|
|
@ -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.
|
||||
7
odoo-bringout-oca-mis-builder-mis_builder/doc/USAGE.md
Normal file
7
odoo-bringout-oca-mis-builder-mis_builder/doc/USAGE.md
Normal 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
|
||||
```
|
||||
8
odoo-bringout-oca-mis-builder-mis_builder/doc/WIZARDS.md
Normal file
8
odoo-bringout-oca-mis-builder-mis_builder/doc/WIZARDS.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Wizards
|
||||
|
||||
Transient models exposed as UI wizards in mis_builder.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class AddMisReportInstanceDashboard
|
||||
```
|
||||
719
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/README.rst
Normal file
719
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/README.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"],
|
||||
}
|
||||
|
|
@ -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>
|
||||
1897
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/bs.po
Normal file
1897
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/bs.po
Normal file
File diff suppressed because it is too large
Load diff
2545
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/ca.po
Normal file
2545
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/ca.po
Normal file
File diff suppressed because it is too large
Load diff
2000
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/de.po
Normal file
2000
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/de.po
Normal file
File diff suppressed because it is too large
Load diff
1918
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/el.po
Normal file
1918
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/el.po
Normal file
File diff suppressed because it is too large
Load diff
1918
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/el_GR.po
Normal file
1918
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/el_GR.po
Normal file
File diff suppressed because it is too large
Load diff
2393
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/es.po
Normal file
2393
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/es.po
Normal file
File diff suppressed because it is too large
Load diff
2310
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/fr.po
Normal file
2310
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/fr.po
Normal file
File diff suppressed because it is too large
Load diff
2057
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/hr.po
Normal file
2057
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/hr.po
Normal file
File diff suppressed because it is too large
Load diff
2168
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/it.po
Normal file
2168
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/it.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
2195
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/nl.po
Normal file
2195
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/nl.po
Normal file
File diff suppressed because it is too large
Load diff
2198
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/nl_NL.po
Normal file
2198
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/nl_NL.po
Normal file
File diff suppressed because it is too large
Load diff
1936
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/pt.po
Normal file
1936
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/pt.po
Normal file
File diff suppressed because it is too large
Load diff
2327
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/pt_BR.po
Normal file
2327
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load diff
2067
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/sv.po
Normal file
2067
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/sv.po
Normal file
File diff suppressed because it is too large
Load diff
2026
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/tr.po
Normal file
2026
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/i18n/tr.po
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
0
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/readme/newsfragments/.gitignore
vendored
Normal file
0
odoo-bringout-oca-mis-builder-mis_builder/mis_builder/readme/newsfragments/.gitignore
vendored
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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 |
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]])
|
||||
|
|
@ -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})
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]])
|
||||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"), []
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue