Repository
OCA/l10n-romania · module folder · Try on Runboat
Module version
2.1.0
Category
Localization
Folder size
0.3 MB
License
AGPL-3
Application
No
Auto-installable
No
Website
https://github.com/OCA/l10n-romania
Last tracking update
2026-09-18 11:33:44
Authors
Odoo Community Association (OCA), NextERP Romania, Dakai Soft SRL
Maintainers
Odoo Community Association (OCA), NextERP Romania, Dakai Soft SRL
Committers
Mihai Fekete, OCA-git-bot, oca-ci
Odoo dependencies
Python dependencies
None
System dependencies
None
Required by
l10n_ro_stock_account_retail_price_change, l10n_ro_stock_account_retail_report
Description
# Romania - Stock Accounting Retail (Marfa in Magazin)

Implements the Romanian retail merchandise accounting (gestiunea
mărfurilor la preț de vânzare cu amănuntul), as described in the
standard monograph for retail commerce:

- **371 Mărfuri** — stock valued at retail price with VAT
- **378 Diferențe de preț la mărfuri** — markup (adaosul comercial)
- **4428 TVA neexigibilă** — VAT included in the retail price, not yet
  collected
- **607 Cheltuieli privind mărfurile** — cost of goods sold
- **707 Venituri din vânzarea mărfurilor** — retail revenue (booked
  from the POS / sales invoice, not by this module)

This is the kernel of the family: it defines what a retail location is,
where the shelf price comes from, and how the markup is booked. The
price change document, the reporting and the point of sale treatment
live in modules of their own:

- `l10n_ro_stock_account_retail_price_change` — the price change
  document, its report and the price history on the product
- `l10n_ro_stock_account_retail_report` — retail stock reporting
- `l10n_ro_stock_account_retail_landed_cost` — keeps 371 at the shelf
  price when a landed cost raises the cost
- `l10n_ro_stock_account_retail_price_difference` — shows what a vendor
  bill leaves of the markup, before it is posted
- `l10n_ro_stock_account_retail_picking_report` — cost, markup and shelf
  price columns on the goods receipt note
- `l10n_ro_stock_account_retail_pos` — keeps the point of sale closing
  entry from discharging the stock a second time

## Configuration

A retail warehouse is set up by ticking *Retail Warehouse* on
`stock.warehouse` and assigning a *Retail Pricelist*. All internal
locations under that warehouse become retail locations automatically.

The **markup (378)** and **deferred VAT (4428)** accounts are resolved
in this order:

1. `stock.location.l10n_ro_account_markup_id` /
   `l10n_ro_account_deferred_vat_id`, walking up the parent locations,
   so a shop is configured once and its shelves and bins inherit it
2. `product.template.l10n_ro_account_markup_id` /
   `l10n_ro_account_deferred_vat_id` (per company)
3. `product.category.l10n_ro_account_markup_id` /
   `l10n_ro_account_deferred_vat_id` (per company)
4. `res.company.l10n_ro_account_markup_id` /
   `l10n_ro_account_deferred_vat_id` (defaults)

## The retail price is held VAT included

A price on a retail pricelist is the PVA: the figure on the shelf
label, what the customer pays, and what account 371 carries. The
product taxes are used to split it into the base the markup is measured
against and the deferred VAT inside it — never to add VAT on top.

Those taxes are the product's sale taxes **mapped through the fiscal
position of the shop** (`l10n_ro_fiscal_position_id` on the warehouse,
the one Romanian stock accounting already uses to map valuation
accounts). A company selling both retail and B2B keeps one set of taxes
on the product and maps them per shop — to the VAT included variants a
till works with, or to another rate — and the VAT loaded on 4428 has to
be the one that shop will actually collect. The same fiscal position
maps the three accounts an entry touches, so a shop keeping its goods
on a 371 of its own says it once instead of overriding every product,
category and location.

A mapping to the *price included* variants of the same taxes changes
nothing: the shelf price is read as VAT inclusive whatever the tax says
about itself, so only a change of rate moves the split.

Only the VAT part of those taxes reaches 4428. A charge collected for
somebody else — a packaging deposit (SGR), an eco fee — is kept out of
the split and out of the value carried on 371; nothing on a tax says
whether it is VAT, so mark those with *Not VAT (Retail)* on the tax
(on every variant a fiscal position can map to). Sale taxes are treated
as VAT unless it is ticked.

The shelf price has to come from a rule on the retail pricelist of the
warehouse. There is no fallback on the product sale price: that is a
price *without* VAT in a standard Romanian setup, so booking it as a
PVA would put the wrong figure on 371, understate the markup and
compute the deferred VAT on a different base — silently. A product
without a price on the shop's retail pricelist is refused instead.

## Accounting flow

Standard `l10n_ro_stock_account` keeps booking the cost. This module
adds the markup leg when a `stock.move` crosses the retail boundary:

- **Into a retail location**: `Dr 371 / Cr 378` (markup) and
  `Dr 371 / Cr 4428` (VAT)
- **Out of a retail location**: `Dr 378 / Cr 371` and
  `Dr 4428 / Cr 371`
- **Between two retail warehouses**: both legs are booked, the source
  releasing its own markup and the destination loading its own. A
  multi-step transfer reaches the same result on its own, the transit
  location not being a retail one.
- Moves that stay inside one retail warehouse book nothing.

A shelf price below cost is refused, since it books a negative markup;
a shop that legitimately sells below cost ticks *Allow Selling Below
Cost* on the warehouse.

## The markup ledger

Odoo 19 has no `stock.valuation.layer`: a move carries its cost on
`stock.move.value` and nothing else. The markup and deferred VAT that
sit between cost and shelf price therefore have nowhere to live, and
recomputing them from the current pricelist when the goods leave is
wrong as soon as the price has moved in between — the release does not
match what was loaded, and the difference stays on 378 and 4428 for
good.

`l10n.ro.retail.markup.line` is the subsidiary ledger that holds them.
Every event that changes what 371 carries writes a row: a move crossing
the boundary, a posted price change, later a landed cost or a purchase
price difference. A release is always taken from the balance carried,
prorated over the quantity that carries it — the *coeficient de
repartizare a adaosului comercial* applied per movement — so the last
unit out closes both accounts to zero.

Returns are settled against the move they return, not against today's
price, so a sale return puts back exactly what the sale released.

The ledger is visible under *Inventory → Reporting → Retail Markup
Ledger*, and `stock.quant` publishes the share carried by each quant
next to its cost.

## Demo

`demo/setup_demo.py` builds a complete Romanian retail shop and walks it
through every case the family handles - transfers, sales, returns,
sublocations, a clearance shop, price changes, landed costs, a purchase
price difference, a point of sale session, and both printable documents.
Run it with `odoo-bin shell -d <database> < demo/setup_demo.py`. Each
section is guarded by the module it exercises, so it runs with the kernel
alone and covers more as more of the family is installed.

Code Analysis

Views touched (9)
XML IDNameModelTypeStatus
product_template_retail_form_view product.template.form.l10n.ro.retail product.template form Inherits l10n_ro_stock_account.product_template_form_view
res_config_settings_view_form_retail res.config.settings.form.l10n.ro.retail res.config.settings form Inherits l10n_ro_config.res_config_settings_view_form
view_location_form_retail stock.location.form.l10n.ro.retail stock.location form Inherits l10n_ro_stock_account.view_location_form_inherit
view_product_category_retail_form view_product_category_retail_form product.category form Inherits l10n_ro_stock_account.view_product_category_add_romania_form
view_retail_markup_line_list l10n.ro.retail.markup.line.list l10n.ro.retail.markup.line list New
view_retail_markup_line_search l10n.ro.retail.markup.line.search l10n.ro.retail.markup.line search New
view_retail_opening_balance_form l10n.ro.retail.opening.balance.form l10n.ro.retail.opening.balance form New
view_tax_form account.tax.form.l10n.ro.retail account.tax form Inherits account.view_tax_form
view_warehouse_retail stock.warehouse.form.l10n.ro.retail stock.warehouse form Inherits stock.view_warehouse
HTTP endpoints (0)

No HTTP endpoints found for this module.

Models touched (13)

New fields (1)
  • l10n_ro_retail_non_vat Boolean
    help='Tick this on a sale tax that is not VAT - a packaging deposit (SGR), an eco fee, a WEEE contribution. In a retail warehouse the shelf price is split into cost, markup (378) and the VAT that becomes exigible on sale (4428); a charge collected on behalf of somebody else is none of those, so it is kept out of the split and out of the value carried on 371.\nTick it on every variant of the charge a fiscal position can map to, the VAT included one included.' string='Not VAT (Retail)'
Public methods (0)

No public methods.

New fields (15)
  • account_move_id Many2one → account.move
    index='btree_not_null' ondelete='set null' args: 'account.move'
  • company_currency_id Many2one → res.currency
    readonly=True related='company_id.currency_id' args: 'res.currency'
  • company_id Many2one → res.company
    default=<expr> index=True required=True args: 'res.company'
  • cost Monetary
    currency_field='company_currency_id' help='Signed cost this event moved. Together with the markup and the deferred VAT it makes up what the event put on, or took off, account 371.' string='Cost (371)'
  • date Date
    default=fields.Date.context_today help='Accounting date of the event, the same one its journal entry carries.' index=True required=True
  • location_id Many2one → stock.location
    index=True ondelete='restrict' required=True args: 'stock.location'
  • markup Monetary
    currency_field='company_currency_id' help='Signed amount posted to the markup account by this event.' string='Markup (378)'
  • move_id Many2one → stock.move
    index='btree_not_null' ondelete='set null' string='Stock Move' args: 'stock.move'
  • origin_type Selection
    default='move' index=True required=True args: [('move', 'Stock Move'), ('price_change', 'Price Change'), ('landed_cost', 'Landed Cost'), ('price_difference', 'Price Difference'), ('opening', 'Opening Balance'), ('manual', 'Manual')]
  • product_id Many2one → product.product
    index=True ondelete='restrict' required=True args: 'product.product'
  • quantity Float
    digits='Product Unit of Measure' help='Signed quantity: positive when goods enter the shop, negative when they leave. Price changes and cost corrections carry zero.'
  • reference Char
  • retail_value Monetary
    compute='_compute_retail_value' currency_field='company_currency_id' help='Cost plus markup plus deferred VAT: what this event put on, or took off, account 371.' store=True string='Retail Value (371)'
  • vat Monetary
    currency_field='company_currency_id' help='Signed amount posted to the deferred VAT account by this event.' string='Deferred VAT (4428)'
  • warehouse_id Many2one → stock.warehouse
    help='Warehouse the markup was loaded on, written once when the row is created.' index=True ondelete='restrict' readonly=True args: 'stock.warehouse'
Public methods (1)
  • create(self, vals_list)
    @api.model_create_multi
    Stamp the warehouse at creation instead of following the location. It used to be ``related="location_id.warehouse_id", store=True``, which is the same value right up to the day somebody reorganises the locations: moving a shelf under another warehouse rewrote the warehouse on every row that had ever mentioned it, and a subsidiary ledger that reconciles against the trial balance cannot have its past rewritten by a configuration change made today.

New fields (6)
  • company_id Many2one → res.company
    default=<expr> required=True args: 'res.company'
  • date Date
    default=fields.Date.context_today help='Date of the entry, and of the ledger rows it produces.' required=True
  • has_shortfall Boolean
    compute='_compute_has_shortfall'
  • journal_id Many2one → account.journal
    compute='_compute_journal_id' domain="[('company_id', '=', company_id)]" readonly=False store=True args: 'account.journal'
  • line_ids One2many → l10n.ro.retail.opening.balance.line
    string='Lines' args: 'l10n.ro.retail.opening.balance.line', 'wizard_id'
  • warehouse_ids Many2many → stock.warehouse
    domain="[('l10n_ro_retail', '=', True), ('company_id', '=', company_id)]" help='Leave empty for every retail warehouse of the company.' string='Shops' args: 'stock.warehouse'
Public methods (2)
  • action_post(self)
    Book the opening balance and record it in the ledger.
  • action_refresh(self)
    Measure the gap and show what would be posted.

New fields (11)
  • below_cost Boolean
    help='The shelf price does not cover the cost, so the opening balance would book a negative markup.' string='Priced Below Cost'
  • company_id Many2one
    related='wizard_id.company_id'
  • cost Monetary
    help='What that quantity cost, as the quants value it. It is already on 371, so it is recorded rather than posted.'
  • currency_id Many2one
    related='company_id.currency_id'
  • markup Monetary
    string='Markup (378)'
  • price_unit Monetary
    string='Shelf Price / Unit'
  • product_id Many2one → product.product
    required=True args: 'product.product'
  • quantity Float
    digits='Product Unit of Measure' help='Quantity on hand that the ledger has no record of.'
  • vat Monetary
    string='Deferred VAT (4428)'
  • warehouse_id Many2one → stock.warehouse
    required=True args: 'stock.warehouse'
  • wizard_id Many2one → l10n.ro.retail.opening.balance
    ondelete='cascade' required=True args: 'l10n.ro.retail.opening.balance'
Public methods (0)

No public methods.

New fields (2)
  • l10n_ro_account_deferred_vat_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Deferred VAT account used for retail accounting. Falls back to the company default if empty.' string='Deferred VAT Account (4428)' args: 'account.account'
  • l10n_ro_account_markup_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Markup account used for retail accounting. Falls back to the company default if empty.' string='Markup Account (378)' args: 'account.account'
Public methods (0)

No public methods.

New fields (0)

No new fields.

Public methods (0)

No public methods.

New fields (2)
  • l10n_ro_account_deferred_vat_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Deferred VAT account for this product. If empty, falls back to category, then to company default.' string='Deferred VAT Account (4428)' args: 'account.account'
  • l10n_ro_account_markup_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Markup account for this product. If empty, falls back to category, then to company default.' string='Markup Account (378)' args: 'account.account'
Public methods (0)

No public methods.

New fields (2)
  • l10n_ro_account_deferred_vat_id Many2one → account.account
    check_company=True domain=ACCOUNT_DOMAIN help='Default deferred VAT account used as a fallback when no override is set on the location, product, or category.' string='Default Retail Deferred VAT Account (4428)' args: 'account.account'
  • l10n_ro_account_markup_id Many2one → account.account
    check_company=True domain=ACCOUNT_DOMAIN help='Default markup account used as a fallback when no override is set on the location, product, or category.' string='Default Retail Markup Account (378)' args: 'account.account'
Public methods (0)

No public methods.

New fields (2)
  • l10n_ro_account_deferred_vat_id Many2one → account.account
    readonly=False related='company_id.l10n_ro_account_deferred_vat_id' args: 'account.account'
  • l10n_ro_account_markup_id Many2one → account.account
    readonly=False related='company_id.l10n_ro_account_markup_id' args: 'account.account'
Public methods (0)

No public methods.

New fields (3)
  • l10n_ro_account_deferred_vat_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Account used for the VAT included in the retail price but not yet collected. Applies to this location and, unless they override it, to its sublocations. Overrides the product / category / company defaults.' string='Deferred VAT Account (4428)' args: 'account.account'
  • l10n_ro_account_markup_id Many2one → account.account
    company_dependent=True domain=ACCOUNT_DOMAIN help='Account used for the commercial markup between cost and retail price without VAT. Applies to this location and, unless they override it, to its sublocations. Overrides the product / category / company defaults.' string='Markup Account (378)' args: 'account.account'
  • l10n_ro_retail Boolean
    compute='_compute_l10n_ro_retail' help='Internal location belonging to a retail warehouse. Stock movements in/out generate the 378/4428 markup entries.' store=True string='Retail Location'
Public methods (0)

No public methods.

New fields (1)
  • l10n_ro_retail_markup_line_ids One2many → l10n.ro.retail.markup.line
    readonly=True string='Retail Markup Ledger' args: 'l10n.ro.retail.markup.line', 'move_id'
Public methods (0)

No public methods.

New fields (3)
  • l10n_ro_retail_markup_value Monetary
    compute='_compute_l10n_ro_retail_values' currency_field='currency_id' groups='stock.group_stock_manager' string='Markup (378)'
  • l10n_ro_retail_value Monetary
    compute='_compute_l10n_ro_retail_values' currency_field='currency_id' groups='stock.group_stock_manager' help='Cost plus the markup and deferred VAT actually loaded on this stock - what account 371 carries for it.' string='Retail Value (371)'
  • l10n_ro_retail_vat_value Monetary
    compute='_compute_l10n_ro_retail_values' currency_field='currency_id' groups='stock.group_stock_manager' string='Deferred VAT (4428)'
Public methods (0)

No public methods.

New fields (3)
  • l10n_ro_retail Boolean
    help='Mark this warehouse as retail. Goods in its internal locations are valued at retail price (PVA) on account 371, with the markup booked on 378 and the deferred VAT on 4428.' string='Retail Warehouse'
  • l10n_ro_retail_allow_negative_markup Boolean
    help='By default a shelf price lower than the cost is refused, because it books a negative markup on 378 and almost always means the price or the cost is wrong. Tick this for a shop that legitimately sells below cost - clearance, perishables close to expiry, the cases in OG 99/2000 - where the negative markup is intended.' string='Allow Selling Below Cost'
  • l10n_ro_retail_pricelist_id Many2one → product.pricelist
    check_company=True help='Pricelist holding the shelf price (PVA) of the products in this warehouse. Prices on a retail pricelist are always VAT included: it is the price on the shelf label, and the price account 371 carries. Required: a product moving in or out of the shop without a price on this pricelist is refused, rather than valued at its sale price - which is a price without VAT, and would put the wrong figure on 371.' string='Retail Pricelist' args: 'product.pricelist'
Public methods (0)

No public methods.