mirror of
https://github.com/bringout/oca-report.git
synced 2026-04-18 04:42:05 +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