Bosanska lokalizacija Odoo: PDV 0% primljeni avansi i mapiranje u fiskalnoj poziciji ZPDV CLAN 15 (l10n_ba_data, l10n_ba_fbih_data)


Problem

Fiskalna pozicija ZPDV CLAN 15 se koristi kada izdajemo račune stranim licima za usluge oslobođene PDV-a po članu 15. Zakona o PDV-u (npr. usluge iz inostranstva). Mehanizam je standardan Odoo flow: faktura ima porez PDV 17%, fiskalna pozicija na partneru mapira ga u PDV 0% C15, i porez se obračuna kao 0%.

Pravilo je radilo za dva od tri tipa prodajnih poreza:

Izvorni porezDestinacijski porez
PDV 17%PDV 0% C15
PDV 17% KPPDV 0% C15
PDV 17% primljeni avansi(nije postojalo)

Treći slučaj — primljeni avans od stranog kupca — nije imao pratećeg 0% poreza za destinaciju. Što znači da kad knjigovođa primi avans od stranog kupca i koristi fiskalnu poziciju ZPDV CLAN 15, sistem nije imao šta da mapira — porez PDV 17% primljeni avansi bi ostao kao 17% i avans bi bio neispravno tretiran u PDV obračunu.

Slika ispod prikazuje konačno stanje: kartica “Mapiranje PDV-a” za fiskalnu poziciju ZPDV CLAN 15 sa sva tri potrebna mapiranja, treći (uokvireno narandžasto) je tema ovog posta:

Fiskalna pozicija ZPDV CLAN 15 sa mapiranjem 17%→0% primljeni avansi

Rješenje

Dvije izmjene u dva modula:

l10n_ba_data 16.0.1.3.8 → 16.0.1.3.9

Novi porez i novi mapping zapis u XML data fajlovima:

<!-- account_taxes.xml -->
<record id="ba_sale_vat_0_avansi" model="account.tax">
    <field name="name">PDV 0% primljeni avansi</field>
    <field name="description">0%</field>
    <field name="amount">0</field>
    <field name="amount_type">percent</field>
    <field name="type_tax_use">sale</field>
    <field name="country_id" ref="base.ba"/>
    <field name="tax_group_id" ref="tax_group_vat_0"/>
</record>
<!-- account_fiscal_positions.xml — unutar ZPDV CLAN 15 -->
<record id="fiscal_position_zpdv_clan_15_tax_sale_17avansi_to_0avansi"
        model="account.fiscal.position.tax">
    <field name="position_id" ref="fiscal_position_zpdv_clan_15"/>
    <field name="tax_src_id" ref="ba_sale_vat_17_avansi"/>
    <field name="tax_dest_id" ref="ba_sale_vat_0_avansi"/>
</record>

l10n_ba_fbih_data 16.0.1.5.9 (post_init_hook + post-migrate)

Novi porez ulazi u dvije postojeće liste u post_init_hook:

# Tagovi na repartition liniji (K_BASE na bazi, K na porezu) —
# isti obrazac kao postojeća prod konfiguracija
('ba_sale_vat_0_avansi', 'base', 100, ['tax_tag_K_base']),
('ba_sale_vat_0_avansi', 'tax', 100, ['tax_tag_K']),

# Konto 4710 na tax liniji — isti kao kod 17% primljenih avansa,
# jer su to ista logika obračuna (samo druga stopa)
('PDV 0% primljeni avansi', '4710'),

post_init_hook se okida samo na svježu instalaciju, pa za postojeće tenante migracija post-migrate.py ponovo aplicira tagove i konto kroz _set_repartition_line_tags (idempotentno: DELETE + INSERT u tag M2M tabelama).

Izazov: već smo imali ručno kreirane zapise u produkciji

Prije nego što smo ovo kodificirali u modul, ručno smo kreirali porez PDV 0% primljeni avansi i mapiranje u fiskalnoj poziciji direktno u Odoo UI na produkciji. To je sasvim valjano za hitne intervencije, ali kreira problem za sljedeći deploy:

  • Ručno kreirani zapisi nemaju ir.model.data xml_id (nisu vezani za modul)
  • Kad data loader pokuša učitati novi <record id="ba_sale_vat_0_avansi">, vidi da xml_id ne postoji u bazi i pokušava INSERT novog zapisa
  • Ali postoji business-key constraint account_fiscal_position_tax_tax_src_dest_uniq koji odbija duplikat → deploy crash
  • Ili još gore: tax record nema unique constraint na (name, type_tax_use, country_id), pa bi se dva poreza istog imena kreirala paralelno

Rješenje: pre-migrate.py koji teče prije data load-a i adoptira ručne zapise tako što insertuje ir.model.data red koji veže postojeći res_id za novi xml_id. Data loader onda nailazi na pravu vezu i radi UPDATE umjesto INSERT.

def _adopt_account_tax(env, xml_id, search_domain):
    existing = env["account.tax"].search(search_domain, limit=1)
    if not existing:
        return
    # Ako nema xml_id, kreiraj vezu
    env["ir.model.data"].create({
        "module": "l10n_ba_data",
        "name": "ba_sale_vat_0_avansi",
        "model": "account.tax",
        "res_id": existing.id,
        "noupdate": False,
    })

Idempotentno: ako xml_id već postoji i pokazuje na isti red, skripta ništa ne radi. Ako pokazuje na obrisani red (stale ir.model.data), repointuje ga.

Bonus: data load popravi i pogrešnu konfiguraciju

Postojeći ručno kreirani porez u produkciji imao je tax_group_id = 95 (“PDV 17%” grupa) — što je očito pogrešno za 0% porez. Niko nije primijetio jer Odoo PDV grupa utiče samo na grupisanje u tax summary, ne na samu računicu.

Nakon adoption u pre-migrate, data load radi UPDATE prema XML definiciji (<field name="tax_group_id" ref="tax_group_vat_0"/>) — i automatski popravlja pogrešnu grupu na ispravnu PDV 0% grupu. To je lijep side-effect kodifikacije kroz modul: jednom kad zapis postane “module-owned”, on dobiva i svu validaciju koja u UI ručnom unosu nije postojala.

Verifikacija na produkciji

Prije deploya:

 id  | name                    | tax_group_id | group_name   | module | xml_id
-----+-------------------------+--------------+--------------+--------+--------
 203 | PDV 0% primljeni avansi | 95           | PDV 17%      |        |        ← orphan + pogrešna grupa
 id  | position     | src_tax                  | dst_tax                 | module | xml_id
-----+--------------+--------------------------+-------------------------+--------+--------
 156 | ZPDV CLAN 15 | PDV 17% primljeni avansi | PDV 0% primljeni avansi |        |        ← orphan

Nakon deploya (upgrade_production_nix_service.py --modules l10n_ba_data,l10n_ba_fbih_data):

 id  | name                    | tax_group_id | group_name   | module       | xml_id
-----+-------------------------+--------------+--------------+--------------+----------------------
 203 | PDV 0% primljeni avansi | 97           | PDV 0%       | l10n_ba_data | ba_sale_vat_0_avansi  ✓
 id  | position     | src_tax                  | dst_tax                 | module       | xml_id
-----+--------------+--------------------------+-------------------------+--------------+-----------------------------------------------------------
 156 | ZPDV CLAN 15 | PDV 17% primljeni avansi | PDV 0% primljeni avansi | l10n_ba_data | fiscal_position_zpdv_clan_15_tax_sale_17avansi_to_0avansi  ✓
 id  | repartition_type | factor_percent | account | tags
-----+------------------+----------------+---------+--------
 530 | base             | 100            |         | K_BASE
 531 | tax              | 100            | 4710    | K
 532 | base             | 100            |         | K_BASE
 533 | tax              | 100            | 4710    | K

Bez duplikata, sa očuvanim tagovima i kontom 4710 na tax liniji, sa ispravljenom tax grupom, i sa stabilnim modul vezivanjem za buduće upgrade-ove.

Lekcija

Ručne intervencije u produkciji su prihvatljive za hitne fikseve — ali kad dođe vrijeme da se popravak kodificira u modul, pre-migrate je obavezan. Bez njega data loader ide na sukob sa već postojećim podacima. Sa njim, deploy je idempotentan i bezbjedno može da se ponavlja.

Naš l10n_ba_data modul već je imao isti obrazac za fiscal position mapiranja (vidi migrations/16.0.1.3.8/pre-migrate.py) — ovaj deploy je samo proširio listu adoptiranih zapisa za jedan tax i jedan fpt red.

Napomena

Generisano od strane Claude 🤖


Ernad Husremović, hernad@bring.out.ba