padding acc

This commit is contained in:
2026-04-26 14:35:07 +02:00
parent 9544418897
commit fbfa73110f
11 changed files with 590 additions and 14 deletions

View File

@@ -45,6 +45,8 @@ de negoce physique:
- `modules/purchase_trade/docs/template-rules.md`
- Catalogue des proprietes templates:
- `modules/purchase_trade/docs/template-properties.md`
- Padding facture provisoire vente / validation comptable:
- `modules/purchase_trade/docs/padding-invoice-accounting.md`
## 4) Invariants metier a preserver
@@ -131,6 +133,19 @@ de negoce physique:
- a la validation d'une `sale.line`, si un lot virtuel est cree et qu'aucun
matching purchase n'existe, il faut lancer `generate_from_sale_line()` pour
alimenter le PnL sale-first
- En padding de facture provisoire vente:
- le padding est saisi globalement dans `lot.invoice`
- il est uniquement applique aux factures provisoires cote vente
- il est reparti par lot et stocke dans `lot.sale_invoice_padding`
- la ligne facture affiche `Inc. padding` depuis le lot
- le padding augmente `account.invoice.line.quantity`, donc le move principal
de facture inclut deja le montant padding
- les comptes de padding viennent de `account.configuration`:
`Default Sale Padding` et `Default Accrual Padding`
- la provisoire doit passer le couple sale/accrual, et la finale doit passer
l'inverse pour le montant exact comptabilise en provisoire
- preferer stocker ce montant au moment de la provisoire plutot que le
recalculer depuis la finale
## 5) Conventions de modification
@@ -174,6 +189,9 @@ de negoce physique:
- attribution du `number`
- `Post` ne doit pas reintroduire une difference de session/fresh login cote
client
- Pour le padding provisoire vente, ne pas changer la quantite physique du lot:
l'ecart doit rester porte par `lot.sale_invoice_padding` et visible sur la
facture via `Inc. padding`.
## 7) Definition of done (module `purchase_trade`)

View File

@@ -190,9 +190,11 @@ def register():
dimension.AnalyticDimensionAssignment,
weight_report.WeightReport,
module='purchase', type_='model')
Pool.register(
account.PhysicalTradeIFRS,
module='purchase_trade', type_='model')
Pool.register(
account.Configuration,
account.ConfigurationDefaultAccount,
account.PhysicalTradeIFRS,
module='purchase_trade', type_='model')
Pool.register(
invoice.Invoice,
invoice.InvoiceLine,

View File

@@ -1,12 +1,63 @@
# account.py
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import PoolMeta
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
__all__ = ['PhysicalTradeIFRS']
__all__ = [
'Configuration',
'ConfigurationDefaultAccount',
'PhysicalTradeIFRS',
]
__metaclass__ = PoolMeta
class Configuration(metaclass=PoolMeta):
__name__ = 'account.configuration'
default_sale_padding_account = fields.MultiValue(fields.Many2One(
'account.account', "Default Sale Padding",
domain=[
('closed', '!=', True),
('type.revenue', '=', True),
('company', '=', Eval('context', {}).get('company', -1)),
]))
default_accrual_padding_account = fields.MultiValue(fields.Many2One(
'account.account', "Default Accrual Padding",
domain=[
('closed', '!=', True),
('type.statement', '=', 'balance'),
('company', '=', Eval('context', {}).get('company', -1)),
]))
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field in {
'default_sale_padding_account',
'default_accrual_padding_account',
}:
return pool.get('account.configuration.default_account')
return super().multivalue_model(field)
class ConfigurationDefaultAccount(metaclass=PoolMeta):
__name__ = 'account.configuration.default_account'
default_sale_padding_account = fields.Many2One(
'account.account', "Default Sale Padding",
domain=[
('closed', '!=', True),
('type.revenue', '=', True),
('company', '=', Eval('company', -1)),
])
default_accrual_padding_account = fields.Many2One(
'account.account', "Default Accrual Padding",
domain=[
('closed', '!=', True),
('type.statement', '=', 'balance'),
('company', '=', Eval('company', -1)),
])
class PhysicalTradeIFRS(ModelSQL, ModelView):
'Physical Trade - IFRS Adjustment'
__name__ = 'account.physical_trade_ifrs'

View File

@@ -1,6 +1,12 @@
<?xml version="1.0"?>
<tryton>
<data>
<record model="ir.ui.view" id="account_configuration_view_form">
<field name="model">account.configuration</field>
<field name="inherit" ref="account_product.configuration_view_form"/>
<field name="name">account_configuration_form</field>
</record>
<record model="res.group" id="group_physical_trade_ifrs">
<field name="name">Physical Trade IFRS</field>
</record>

View File

@@ -1,8 +1,8 @@
# Business Rules - Purchase Trade
Statut: `draft`
Version: `v0.6`
Derniere mise a jour: `2026-04-23`
Version: `v0.7`
Derniere mise a jour: `2026-04-26`
Owner metier: `a completer`
Owner technique: `a completer`
@@ -426,11 +426,16 @@ Owner technique: `a completer`
- deux lots factures ensemble avec un padding global de `1000` recoivent
chacun `500` de padding
- la ligne facture affiche la quantite augmentee
- la ligne facture expose `Included padding`
- la ligne facture expose `Inc. padding`
- le lot conserve sa part de padding dans `sale_invoice_padding`
- Hors scope:
- les ecritures comptables specifiques au padding seront traitees dans un
second temps
- Validation comptable:
- au `Validate`, le move principal de facture inclut deja le padding car il
est integre a `account.invoice.line.quantity`
- la provisoire doit utiliser le couple de comptes configure
`Default Sale Padding` / `Default Accrual Padding`
- la finale doit passer l'ecriture inverse pour exactement le montant padding
comptabilise lors de la provisoire
- voir `modules/purchase_trade/docs/padding-invoice-accounting.md`
- Priorite:
- `importante`

View File

@@ -0,0 +1,294 @@
# Padding facture provisoire vente - notes comptables
Statut: `draft`
Derniere mise a jour: `2026-04-26`
Scope: `lot.invoice` -> `account.invoice` -> validation comptable.
## 1) Objectif metier
Le padding sert a augmenter temporairement la quantite facturee sur une facture
provisoire cote vente, afin de constituer une provision avant la facture finale.
Exemple:
- 2 lots factures ensemble
- padding global saisi dans le wizard: `1000`
- repartition actuelle: `500` par lot
- chaque ligne facture affiche:
- `quantity = quantite reelle du lot + padding du lot`
- `Inc. padding = padding du lot`
Le padding ne modifie pas la quantite physique du lot. Il doit rester visible et
traçable comme ecart entre la facture provisoire et le lot.
## 2) Donnees creees ou utilisees
### `lot.lot`
- Champ: `sale_invoice_padding`
- Role: memoire de la part de padding appliquee au lot pour la facture
provisoire vente.
- UI: readonly sur le lot.
### `account.invoice.line`
- Champ fonctionnel: `included_padding`
- Label UI: `Inc. padding`
- Role: affichage de `line.lot.sale_invoice_padding` sur la ligne facture.
- La valeur n'est pas stockee sur la ligne; elle lit le padding du lot.
### Wizard `lot.invoice`
- Champ: `sale_padding` (`Global padding`)
- Visible seulement pour:
- `type = sale`
- `action = prov`
- Repartition actuelle:
- padding global / nombre de lots selectionnes
## 3) Flux de creation de facture avec padding
1. L'utilisateur ouvre le wizard `lot.invoice` depuis les lots physiques.
2. Le wizard prepare deux listes:
- `lot_p` pour achat
- `lot_s` pour vente
3. En mode vente provisoire, l'utilisateur saisit `Global padding`.
4. Au clic `Invoice`, le wizard:
- calcule la part de padding par lot
- ecrit cette part dans `lot.sale_invoice_padding`
- appelle `sale.sale._process_invoice(..., action='prov')`
5. `sale.line.get_invoice_line(lots, action)` cree une ligne facture par lot
physique.
6. Pour chaque ligne positive `out` / `Pro forma`, le module ajoute le padding
du lot a `invoice_line.quantity`.
7. L'amount de la ligne augmente naturellement via:
- `quantity * unit_price`
## 4) Ce qui se passe actuellement lors de `Validate` d'une facture
Bouton UI: `Validate` sur `account.invoice`.
Code principal:
- `modules/account_invoice/invoice.py`
- `Invoice.validate_invoice`
- `Invoice.get_move`
- `InvoiceLine.get_move_lines`
- `Invoice.do_lot_invoicing`
- `Invoice.cleanMoves`
### Etapes de `validate_invoice`
1. Verifie les taxes avec `_check_taxes`.
2. Attribue le numero via `set_number`.
3. Stocke les caches de montants via `_store_cache`.
4. Pour chaque facture:
- appelle `invoice.get_move()`
- affecte le move a `invoice.move` s'il est nouveau
- appelle `invoice.do_lot_invoicing()`
5. Sauvegarde les moves crees.
6. Nettoie certaines lignes de move via `cleanMoves`.
7. Sauvegarde les factures.
Note importante deja actee dans le projet:
- `Validate` cree deja le `account.move` et attribue le `number` aussi pour les
factures client (`type = out`).
- `Post` ne doit plus etre le premier moment ou ces donnees apparaissent cote
client.
### `get_move()`
`get_move()` construit le move comptable principal de la facture:
- appelle `update_taxes(exception=True)`
- parcourt `invoice.lines`
- pour chaque ligne, appelle `line.get_move_lines()`
- ajoute les lignes de taxes via `tax.get_move_lines()`
- ajoute la ligne tiers / receivable-payable via `_get_move_line`
- ajoute eventuellement une ligne de change via `_get_exchange_move_line`
- cree un `account.move` avec:
- `journal = invoice.journal`
- `period` trouve depuis la date comptable
- `origin = invoice`
- `lines = move_lines`
### `account.invoice.line.get_move_lines()`
Pour une ligne facture `type = line`:
- cree une ligne `account.move.line`
- rattache `line.lot = self.invoice.lines[0].lot`
- attention: comportement existant, pas forcement la ligne courante
- convertit le montant si devise differente
- pour une facture client (`type = out`):
- montant positif => credit du compte de revenu de la ligne
- montant negatif => debit
- met `line.account = self.account`
- met `line.origin = self`
- calcule les lignes de taxe
Pour le padding actuel, comme il augmente `invoice_line.quantity`, il est deja
inclus dans le montant de revenu du move principal.
### `do_lot_invoicing()`
`do_lot_invoicing()` traite des ajustements lies aux lots:
- groupe les lignes facture par `i.lot`
- calcule:
- `var_price`
- `var_qt`
- retrouve le mouvement stock:
- fournisseur pour `type = in`
- client pour `type = out`
- declenche des actions stock / shipment si necessaire
- calcule le COG du lot
- prepare des lignes d'ajustement via `_get_move_lines`
- actuellement, le move d'ajustement n'est cree/poste que si:
- `adjust_move_lines` existe
- `self.type == 'in'`
Consequence importante pour le padding:
- les ajustements de quantite/prix existants sont principalement effectifs cote
achat (`type = in`)
- une facture client provisoire avec padding augmente deja le move principal de
vente, mais ne genere pas encore d'ecritures specifiques padding separees
### `_get_move_lines(gl, amount, drop, IsUsd=False, stock_move=None)`
Helper existant pour creer des paires de lignes de move liees au stock/COG:
- rattache `lot`, `fee`, `origin`
- gere la conversion devise / second currency
- choisit les comptes stock / stock out / stock in / COG selon le signe, fee et
contexte
- peut creer une variante `drop`
Ce helper est un candidat possible pour reutilisation partielle, mais il est
aujourd'hui pense pour des ajustements stock/COG, pas pour isoler un padding de
facture provisoire vente.
## 5) Question comptable a trancher pour le padding
Le besoin restant est: au `Validate` d'une facture provisoire vente avec padding,
generer des ecritures propres a ce padding.
Points a definir avant implementation:
- Le move padding doit-il etre:
- integre dans le move principal de facture ?
- cree comme `additional_move` ?
- cree comme move separe avec `origin = invoice` ?
- Quels comptes utiliser ?
- compte de revenu temporaire / provision ?
- compte de contrepartie padding ?
- compte lie au produit ?
- compte de configuration dedie ?
- Faut-il neutraliser une partie du revenu principal deja augmente par la
quantity, ou seulement ajouter une ecriture analytique/stock parallele ?
- Le padding doit-il impacter:
- revenu client ?
- stock out ?
- COG ?
- PnL / valuation ?
- Que doit faire la facture finale ?
- reprendre le padding provisoire ?
- le contrepasser ?
- ne rien faire si la finale facture la vraie quantite ?
- Le montant padding a utiliser est:
- `lot.sale_invoice_padding * invoice_line.unit_price`
- dans la devise de facture
- a convertir si devise societe differente selon la logique standard
## 5.1) Decision comptable cible
Comptes de configuration ajoutes sur `account.configuration`:
- `default_sale_padding_account`
- label UI: `Default Sale Padding`
- compte attendu metier: `80021`
- nature: revenu
- `default_accrual_padding_account`
- label UI: `Default Accrual Padding`
- compte attendu metier: `42021`
- nature: bilan / accrual
Facture provisoire vente:
- condition:
- `invoice.type = out`
- `invoice.reference = Provisional`
- `invoice_line.description = Pro forma`
- `invoice_line.lot.sale_invoice_padding > 0`
- montant:
- `padding_amount = lot.sale_invoice_padding * invoice_line.unit_price`
- le prix est celui de la facture provisoire, pas un prix recalcule plus tard
- ecriture attendue:
- couple `80021 / 42021`
- rattachement au lot obligatoire sur les lignes si possible
Facture finale vente:
- condition:
- lot deja facture en provisoire avec padding
- la finale doit inverser le padding deja provisionne
- montant:
- exactement le montant comptabilise lors de la provisoire
- ne pas recalculer avec le prix de la finale
- ecriture attendue:
- couple inverse `42021 / 80021`
Decision technique retenue:
- ne pas ajouter de nouveau champ montant pour l'instant
- utiliser le lien deja ecrit sur le lot:
- `lot.sale_invoice_line_prov`
- au moment de la finale, retrouver la ligne provisoire via ce champ
- calculer le montant a extourner avec:
- `lot.sale_invoice_padding * lot.sale_invoice_line_prov.unit_price`
- utiliser la devise, le taux et la date de la facture provisoire pour obtenir
le meme montant societe que l'ecriture initiale
- garder le rattachement comptable par `account.move.line.lot`
Raison:
- evite les ecarts si le prix final change
- reutilise le lien direct deja maintenu entre le lot et la ligne provisoire
- rend la reprise finale audit-friendly
- garde le pivot comptable lot coherent
## 6) Invariants a preserver
- Le padding ne change pas la quantite physique du lot.
- Le padding est uniquement pour facture provisoire vente:
- `invoice.type = out`
- `invoice.reference = Provisional`
- lignes `description = Pro forma`
- L'utilisateur doit voir clairement l'ecart:
- `Quantity`
- `Inc. padding`
- Le lien principal reste:
- `account.invoice.line.lot`
- `lot.sale_invoice_padding`
- Les futures ecritures doivent etre rattachees au lot autant que possible pour
rester visibles dans le pivot comptable du lot.
## 7) Tests a prevoir pour la suite
- Validation d'une facture provisoire vente avec padding:
- move principal existe
- montant facture inclut le padding
- ecritures specifiques padding creees selon la regle retenue
- lignes rattachees au bon lot
- Facture provisoire vente sans padding:
- aucun move padding specifique
- Facture finale vente:
- aucun nouveau padding applique automatiquement
- comportement de reprise/contrepassation selon decision metier
- Facture achat:
- aucun impact padding vente
- Multi-lots:
- padding reparti correctement
- ecritures detaillees par lot si la regle comptable l'exige

View File

@@ -15,6 +15,180 @@ from trytond.modules.purchase.purchase import (
class Invoice(metaclass=PoolMeta):
__name__ = 'account.invoice'
def do_lot_invoicing(self):
super().do_lot_invoicing()
self._create_sale_padding_moves()
@classmethod
def _post(cls, invoices):
pool = Pool()
Move = pool.get('account.move')
super()._post(invoices)
padding_moves = []
for invoice in invoices:
padding_moves.extend(invoice._create_sale_padding_moves())
padding_moves.extend([
move for move in (invoice.additional_moves or [])
if move.description in {
invoice._get_sale_padding_move_description(False),
invoice._get_sale_padding_move_description(True),
}
and move.state != 'posted'
])
if padding_moves:
cls.save(invoices)
Move.post([m for m in padding_moves if m.state != 'posted'])
def _get_sale_padding_accounts(self):
Configuration = Pool().get('account.configuration')
config = Configuration(1)
sale_account = config.get_multivalue(
'default_sale_padding_account', company=self.company.id)
accrual_account = config.get_multivalue(
'default_accrual_padding_account', company=self.company.id)
if not sale_account or not accrual_account:
raise UserError(
'Default Sale Padding and Default Accrual Padding '
'accounts must be configured.')
return sale_account, accrual_account
def _has_sale_padding_move(self, reversal=False):
description = self._get_sale_padding_move_description(reversal)
return any(
move.description == description
for move in (self.additional_moves or []))
@staticmethod
def _get_sale_padding_move_description(reversal=False):
if reversal:
return 'Sale padding reversal'
return 'Sale padding accrual'
def _get_padding_company_amount(self, invoice_line, padding):
Currency = Pool().get('currency.currency')
invoice = invoice_line.invoice
amount = Decimal(str(padding or 0)) * Decimal(
str(invoice_line.unit_price or 0))
amount = invoice.currency.round(amount)
if invoice.currency == invoice.company.currency:
return amount, amount
if invoice.rate:
company_amount = invoice.company.currency.round(
amount / invoice.rate)
else:
with Transaction().set_context(date=invoice.currency_date):
company_amount = Currency.compute(
invoice.currency, amount, invoice.company.currency)
return amount, company_amount
def _get_sale_padding_entries(self):
if self.type != 'out':
return []
entries = []
for line in self.lines or []:
if getattr(line, 'type', None) != 'line':
continue
lot = getattr(line, 'lot', None)
if not lot:
continue
padding = Decimal(str(
getattr(lot, 'sale_invoice_padding', 0) or 0))
if padding <= 0:
continue
if self.reference == 'Provisional' and line.description == 'Pro forma':
entries.append((lot, line, padding, False))
elif self.reference == 'Final' and line.description == 'Final':
provisional_line = getattr(lot, 'sale_invoice_line_prov', None)
if provisional_line:
entries.append((lot, provisional_line, padding, True))
return entries
def _get_sale_padding_move_lines(self, entries):
MoveLine = Pool().get('account.move.line')
sale_account, accrual_account = self._get_sale_padding_accounts()
move_lines = []
for lot, invoice_line, padding, reversal in entries:
padding_amount, company_amount = self._get_padding_company_amount(
invoice_line, padding)
if not company_amount:
continue
sale_line = MoveLine()
accrual_line = MoveLine()
for move_line in (sale_line, accrual_line):
move_line.lot = lot
move_line.origin = invoice_line
if move_line.account and move_line.account.party_required:
move_line.party = self.party
if not reversal:
sale_line.account = sale_account
sale_line.debit = company_amount
sale_line.credit = Decimal(0)
accrual_line.account = accrual_account
accrual_line.debit = Decimal(0)
accrual_line.credit = company_amount
else:
accrual_line.account = accrual_account
accrual_line.debit = company_amount
accrual_line.credit = Decimal(0)
sale_line.account = sale_account
sale_line.debit = Decimal(0)
sale_line.credit = company_amount
if self.currency != self.company.currency:
sale_line.second_currency = self.currency
accrual_line.second_currency = self.currency
sale_line.amount_second_currency = padding_amount.copy_sign(
sale_line.debit - sale_line.credit)
accrual_line.amount_second_currency = padding_amount.copy_sign(
accrual_line.debit - accrual_line.credit)
if sale_line.account.party_required:
sale_line.party = self.party
if accrual_line.account.party_required:
accrual_line.party = self.party
move_lines.extend([sale_line, accrual_line])
return move_lines
def _create_sale_padding_moves(self):
pool = Pool()
Move = pool.get('account.move')
Period = pool.get('account.period')
Date = pool.get('ir.date')
entries = self._get_sale_padding_entries()
if not entries:
return []
reversal = entries[0][3]
if self._has_sale_padding_move(reversal):
return []
move_lines = self._get_sale_padding_move_lines(entries)
if not move_lines:
return []
with Transaction().set_context(company=self.company.id):
today = Date.today()
accounting_date = self.accounting_date or self.invoice_date or today
period = Period.find(self.company, date=accounting_date)
move = Move()
move.journal = self.journal
move.period = period
move.date = accounting_date
move.origin = self
move.company = self.company
move.description = self._get_sale_padding_move_description(reversal)
move.lines = move_lines
Move.save([move])
self.additional_moves = tuple(self.additional_moves or ()) + (move,)
return [move]
@staticmethod
def _format_report_number(value, digits='0.0000', keep_trailing_decimal=False,
strip_trailing_zeros=True):

View File

@@ -106,7 +106,7 @@ class InvoiceLine(metaclass=PoolMeta):
lot = fields.Many2One('lot.lot',"Lot")
fee = fields.Many2One('fee.fee',"Fee")
included_padding = fields.Function(
fields.Numeric("Included padding", digits=(1, 5)),
fields.Numeric("Inc. padding", digits=(1, 5)),
'get_included_padding')
def get_included_padding(self, name):

View File

@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<data>
<xpath expr="/form/field[@name='default_category_account_revenue']"
position="after">
<label name="default_sale_padding_account"/>
<field name="default_sale_padding_account"/>
<label name="default_accrual_padding_account"/>
<field name="default_accrual_padding_account"/>
</xpath>
</data>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<data>
<xpath expr="/tree/field[@name='quantity']" position="after">
<field name="included_padding" optional="1"/>
<field name="included_padding" symbol="unit"/>
</xpath>
</data>

View File

@@ -94,3 +94,19 @@ Scope: templates Relatorio + ponts `report_*` Python.
- `operator` filtre sur `OPERATOR`.
- les quotas/pricings doivent fallback sur `quantity` si `quantity_theorical` est vide.
- `sale.line` / `purchase.line`: en mode `basis`, si aucun `price_component` n'est defini, le prix et la progression doivent remonter depuis la ligne `Summary` / `pricing.summary` sans component.
## 6) Session 2026-04-26 - Padding facture provisoire vente
- Le wizard `lot.invoice` peut appliquer un padding global uniquement sur les
factures provisoires cote vente.
- Le padding global est reparti par lot et conserve sur `lot.lot` dans
`sale_invoice_padding`.
- La ligne facture affiche ce montant via `Inc. padding`, avec la meme unite que
`Quantity`.
- La quantite facturee est augmentee du padding, mais la quantite physique du
lot ne doit pas changer.
- Au `Validate` d'une facture, le move principal inclut deja le padding car il
est integre dans `account.invoice.line.quantity`.
- Les ecritures comptables specifiques au padding restent a definir; point
d'entree documente:
`modules/purchase_trade/docs/padding-invoice-accounting.md`.