l10n.gr suite API for partners
What the l10n.gr suite adds to Odoo that you can use in your own modules: models you inherit, fields you read or write, methods you call and hooks you override with super(). We keep all of these compatible across releases.
How to use it
- In your module's __manifest__.py, add to depends the l10n.gr module you use, as shown next to each section title. Odoo then loads your module after it.
- Use l10n.gr models, fields and methods through the ORM: self.env['l10n_gr.doy'], _inherit = 'account.move', record.l10n_gr_doy_id. Your code only needs the usual Odoo imports, such as from odoo import api, fields, models.
- Do not import from odoo.addons.l10n_gr_*. The suite's code is not a public API, and the pipeline flags every such import.
Anything not listed here is internal to the suite and may change without notice. Transmissions, series numbering and MARK/UID/QR are protected, and the pipeline blocks any override of them.
Want to try your code on staging without waiting for the pipeline? Live development with l10n-gr dev →
AADE Reference Tables
depends: ['l10n_gr_nt_aade_tables']
The official AADE (myDATA) code tables as Odoo models (VAT categories, VAT exemption reasons, document types, income and expense classifications, movement purposes, withholding taxes) and the fields that link them to taxes.
aade.expense.category
model aade.expense.category (myDATA table 8.10): code Char (required), name Char (required), l10n_gr_allow_null_e3 Boolean (default False), display_name Char (stored)Expense classification category (myDATA 8.10). The AADE expense categories (category2_1, category2_5, ...), with the same l10n_gr_allow_null_e3 flag as the income table. Use it as reference data for your purchase-side fields and filters.
Example
The accountant wants every product category to carry a fixed myDATA expense category, so purchases of that category are classified the same way every time.
# Customer need: each product category books its purchases under one myDATA
# expense category, chosen once by the accountant.
# depends: ['l10n_gr_nt_aade_tables']
# The suite tables are reached through the ORM, never imported from odoo.addons.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ProductCategory(models.Model):
_inherit = 'product.category'
x_expense_category_id = fields.Many2one(
'aade.expense.category', string='myDATA expense category',
domain=[('l10n_gr_allow_null_e3', '=', False)])
@api.constrains('x_expense_category_id')
def _check_x_expense_category(self):
# the domain guards the form only; imports go through this check
for categ in self.filtered('x_expense_category_id'):
if categ.x_expense_category_id.l10n_gr_allow_null_e3:
raise ValidationError(_('%s is valid without an E3 code: choose a category for regular purchases.',
categ.x_expense_category_id.display_name))aade.expense.type
model aade.expense.type (myDATA table 8.11): code Char (required, E3 code), name Char (required), display_name Char (stored)Expense classification type / E3 code (myDATA 8.11). The E3 codes for expense lines. Read-only reference for your own reports or fields on the purchase side.
Example
A customer moving from a legacy ERP needs its own cost codes mapped to the official E3 expense codes before the first purchase is booked in Odoo.
# Customer need: the legacy ERP coded costs with its own codes; before go-live,
# map each one to the E3 code the accountant chose.
# Run in odoo-bin shell from the console (env is given). x_legacy_code and
# x_expense_type_id are your own fields on product.category;
# your module's depends: ['l10n_gr_nt_aade_tables']
MAPPING = { # legacy cost code -> E3 code, as agreed with the accountant
'K100': 'E3_102_001',
'K200': 'E3_102_002',
}
E3 = env['aade.expense.type']
for legacy_code, e3_code in MAPPING.items():
e3 = E3.search([('code', '=', e3_code)], limit=1)
if not e3:
print('Unknown E3 code', e3_code, 'for', legacy_code)
continue
categs = env['product.category'].search([('x_legacy_code', '=', legacy_code)])
categs.write({'x_expense_type_id': e3.id})
print(legacy_code, '->', e3.display_name, '(%s categories)' % len(categs))
env.cr.commit()aade.income.category
model aade.income.category (myDATA table 8.8): code Char (required, e.g. 'category1_1'), name Char (required), l10n_gr_allow_null_e3 Boolean (default False), display_name Char (stored)Income classification category (myDATA 8.8). The AADE income categories (category1_1, category1_2, ...). l10n_gr_allow_null_e3 marks the informative categories that are valid without an E3 code. Use it as the target of your own Many2one fields or to read a product's override (product.template.l10n_gr_income_category_id).
Example
A company that sells through an e-shop and through wholesale wants each sales team linked to its myDATA income category, so revenue can be reviewed per category.
# Customer need: the e-shop team and the wholesale team must be reported under
# their own myDATA income category; the accountant groups invoices by it.
# depends: ['l10n_gr_nt_aade_tables', 'sale']
# The suite tables are reached through the ORM, never imported from odoo.addons.
from odoo import fields, models
class CrmTeam(models.Model):
_inherit = 'crm.team'
x_income_category_id = fields.Many2one(
'aade.income.category', string='myDATA income category',
domain=[('l10n_gr_allow_null_e3', '=', False)])
class AccountMove(models.Model):
_inherit = 'account.move'
# stored, so the accountant can group and pivot invoices by it
x_income_category_id = fields.Many2one(
related='team_id.x_income_category_id', store=True,
string='Team income category')aade.income.type
model aade.income.type (myDATA table 8.9): code Char (required, E3 code), name Char (required), display_name Char (stored)Income classification type / E3 code (myDATA 8.9). The E3 codes for income lines (for example E3_561_001). Read it when your module needs to show or filter by the E3 code that goes with an income category.
Example
During a migration from a legacy ERP, the partner keeps a reviewable table that turns each old revenue code into a myDATA income category and E3 code.
# Customer need: migrating from a legacy ERP whose revenue codes must become a
# myDATA category + E3 code; keep the mapping as data the accountant can review.
# depends: ['l10n_gr_nt_aade_tables'] (add an ir.model.access line for acme.income.map)
from odoo import fields, models
class AcmeIncomeMap(models.Model):
_name = 'acme.income.map'
_description = 'Legacy revenue code to myDATA classification'
legacy_code = fields.Char(required=True, index=True)
category_id = fields.Many2one('aade.income.category', required=True)
type_id = fields.Many2one('aade.income.type', string='E3 code')
e3_code = fields.Char(related='type_id.code')
_legacy_code_uniq = models.Constraint('UNIQUE(legacy_code)', 'One mapping per legacy code.')
def _incomplete(self):
# categories valid without an E3 code may go without one; all others need one
return self.filtered(
lambda m: not m.type_id and not m.category_id.l10n_gr_allow_null_e3)aade.invoice.type
model aade.invoice.type (myDATA table 8.1): code Char (required), name Char (required), display_name Char (stored), sequence, active, book Selection, issuer_role Selection, flow Selection, supported Boolean (stored compute), variant_ids, correlated_type_ids, correlation_required, provider_required and per-slot rule Selections; ordered by sequence, codeDocument type (myDATA 8.1). One record per AADE document type (1.1, 2.1, 5.1, 11.1 and so on) with its rules: which book it lands in, who issues it, which Odoo flow it belongs to, whether it is issued today, its allowed variants and correlated types. Read it to branch your own logic on the document type of a journal or move, for example code == '11.1' for retail receipts. Do not create or edit types.
Example
At month end the accountant wants a dashboard with how many documents of each AADE type were issued, to cross-check against the myDATA summary.
# Customer need: the accountant's month-end dashboard counts the documents of
# each AADE type (1.1, 2.1, 11.1 ...) posted in the period.
# depends: ['l10n_gr_nt_chart_ext'] (journal field; brings l10n_gr_nt_aade_tables)
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def acme_documents_by_type(self, date_from, date_to):
groups = self._read_group(
[('state', '=', 'posted'), ('company_id', '=', self.env.company.id),
('date', '>=', date_from), ('date', '<=', date_to),
('journal_id.l10n_gr_invoice_type_id', '!=', False)],
['journal_id'], ['__count'])
counts = {}
for journal, count in groups:
# read only: never create or edit document types
doc_type = journal.l10n_gr_invoice_type_id
counts[doc_type] = counts.get(doc_type, 0) + count
# keyed by record, so a type archived after posting is still counted
return [(t.code, t.name, n) for t, n in
sorted(counts.items(), key=lambda kv: (kv[0].sequence, kv[0].code))]aade.move.purpose
model aade.move.purpose (myDATA table 8.14): code Integer (required, indexed), name Char (required), active Boolean (default True), scope Selection [internal, external, both] (required, default both); ordered by codeMovement purpose (myDATA 8.14). The purposes of a goods movement on delivery notes (sale, transfer between own premises, return, ...). scope is a UI rule of the suite, not an AADE rule: it says whether a purpose is offered for internal transfers, for movements to or from third parties, or for both. Read it to filter purposes in your own wizards; an accountant may change scope or active from the UI.
Example
Warehouse staff transferring goods between the company's own branches should only see the movement purposes that apply to internal transfers.
# Customer need: the warehouse moves stock between the customer's own branches
# and must pick a movement purpose; offer only purposes for internal moves.
# depends: ['l10n_gr_nt_aade_tables', 'stock'] (add an ir.model.access line for the wizard)
from odoo import _, fields, models
class AcmeBranchTransfer(models.TransientModel):
_name = 'acme.branch.transfer'
_description = 'Transfer between own branches'
picking_id = fields.Many2one('stock.picking', required=True)
purpose_id = fields.Many2one(
'aade.move.purpose', required=True,
# the accountant sets scope and active from the UI: filter, never hard-code ids
domain=[('scope', 'in', ('internal', 'both'))])
def action_confirm(self):
self.ensure_one()
self.picking_id.message_post(
body=_('Movement purpose: %(code)s %(name)s',
code=self.purpose_id.code, name=self.purpose_id.name))
return {'type': 'ir.actions.act_window_close'}aade.vat.category
model aade.vat.category (myDATA table 8.2): code Integer (required), name Char (required), percent Float, display_name Char (stored compute, '[code] name'); ordered by codeVAT category (myDATA 8.2). Reference table of the AADE VAT categories, one record per code with its rate. Use it to look up the category for a rate or to filter taxes by category in your own module. Treat it as read-only reference data: the records ship with the module and follow the AADE table. Requires l10n_gr_nt_aade_tables installed.
Example
The accountant needs the month's net sales split by myDATA VAT category to reconcile them with the periodic VAT return.
# Customer need: a monthly dashboard of sales net value per VAT category, so the
# accountant can cross-check it with the VAT return.
# depends: ['l10n_gr_nt_aade_tables']
from odoo import models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def acme_sales_by_vat_category(self, date_from, date_to):
base_domain = [
('parent_state', '=', 'posted'), ('company_id', '=', self.env.company.id),
('move_id.move_type', 'in', ('out_invoice', 'out_refund')),
('date', '>=', date_from), ('date', '<=', date_to),
('display_type', '=', 'product')]
result = []
for cat in self.env['aade.vat.category'].search([]): # ordered by code
lines = self.search(base_domain + [('tax_ids.aade_vat_category_id', '=', cat.id)])
if lines:
# sales are credits: negate the balance to show positive net value
result.append((cat.display_name, cat.percent, -sum(lines.mapped('balance'))))
return resultaade.vat.exemption.category
model aade.vat.exemption.category (myDATA table 8.3): code Integer (required), name Char (required), name_n5144 Char, l10n_gr_foreign_vat_line Boolean; ordered by codeVAT exemption reason (myDATA 8.3). Reference table of the AADE VAT exemption reasons. name holds the reason under the previous VAT Code numbering and name_n5144 the wording under Law 5144/2024 where it differs; display_name shows the code, the current wording and the former article. l10n_gr_foreign_vat_line marks reasons where the line tax is another member state's VAT (OSS). Read it to show or check the exemption reason of a tax; do not edit the records.
Example
A customer that buys VAT-free under a specific exemption must never get an order confirmed with a tax that cites a different exemption reason.
# Customer need: a shipping customer buys VAT-free under an exemption certificate;
# block orders whose taxes carry a different exemption reason.
# depends: ['l10n_gr_nt_aade_tables', 'sale']
from odoo import _, fields, models
from odoo.exceptions import UserError
class ResPartner(models.Model):
_inherit = 'res.partner'
x_vat_exemption_id = fields.Many2one('aade.vat.exemption.category', string='VAT exemption reason')
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
for order in self:
reason = order.partner_id.commercial_partner_id.x_vat_exemption_id
if not reason:
continue
wrong = order.order_line.filtered(
lambda l: not l.display_type and reason not in l.tax_ids.aade_vat_exemption_category_id)
if wrong:
raise UserError(_('%(lines)s: use a tax with exemption %(reason)s.',
lines=', '.join(wrong.mapped('product_id.display_name')),
reason=reason.display_name))
return super().action_confirm()aade.vat.exemption.category.l10n_gr_printed_reason
l10n_gr_printed_reason(self) -> str (ensure_one)Exemption wording for the printed document. Returns the exemption wording that must appear on the printed invoice: the Law 5144/2024 wording when the record has one, otherwise the original name. Call it on a single record (it raises on a multi-record set) when you build your own report or QWeb template, so the paper never cites a repealed article.
Example
A partner building the customer's own invoice layout must print the legal exemption wording that is in force today on every VAT-exempt invoice.
<!-- Customer need: a custom invoice layout must print the exemption reason with
the current (Law 5144/2024) wording, never a repealed article. -->
<!-- depends: ['l10n_gr_nt_aade_tables', 'account'] -->
<odoo>
<template id="acme_invoice_exemption_note" inherit_id="account.report_invoice_document">
<xpath expr="//div[hasclass('page')]" position="inside">
<t t-set="acme_reasons" t-value="o.invoice_line_ids.tax_ids.aade_vat_exemption_category_id"/>
<div t-if="acme_reasons" class="mt-3 small">
<strong>VAT exemption:</strong>
<ul class="list-unstyled mb-0">
<!-- l10n_gr_printed_reason() works on one record, hence the loop -->
<li t-foreach="acme_reasons" t-as="r">
<span t-out="r.code"/> - <span t-out="r.l10n_gr_printed_reason()"/>
</li>
</ul>
</div>
</xpath>
</template>
</odoo>aade.withheld.tax
model aade.withheld.tax (myDATA table 8.13): code Integer (required), name Char (required), value Float, fixed_amount Boolean; ordered by codeWithholding tax (myDATA 8.13). The AADE withholding tax codes. value is a percentage unless fixed_amount is set, in which case it is an amount. Read it when your module needs the official code or rate of a withholding.
Example
The purchasing team wants to see on each freelancer's bill how much will be withheld, using the official AADE withholding code and rate.
# Customer need: purchasing wants freelancers' service bills to show the expected
# withholding before the accountant posts them (preview only; the withholding
# itself is applied by the suite's tax at payment).
# depends: ['l10n_gr_nt_aade_tables']
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
x_withheld_tax_id = fields.Many2one('aade.withheld.tax', string='Usual withholding')
class AccountMove(models.Model):
_inherit = 'account.move'
x_expected_withholding = fields.Monetary(compute='_compute_x_expected_withholding')
@api.depends('commercial_partner_id.x_withheld_tax_id', 'amount_untaxed', 'move_type', 'currency_id')
def _compute_x_expected_withholding(self):
for move in self:
wht = move.commercial_partner_id.x_withheld_tax_id
if not wht or move.move_type != 'in_invoice':
move.x_expected_withholding = 0.0
elif wht.fixed_amount: # value is an amount, not a rate
move.x_expected_withholding = wht.value
else:
move.x_expected_withholding = move.currency_id.round(move.amount_untaxed * wht.value / 100)account.tax.aade_vat_category_id
Many2one('aade.vat.category'), ondelete='restrict'Tax: myDATA VAT category. The VAT category (table 8.2) of the tax. Read it to classify lines by VAT category. You may set it on taxes your module creates; when the category is code 7 (exempt), a constraint requires aade_vat_exemption_category_id to be set as well, so set both in the same write.
Example
After a migration, the partner wants a weekly check that flags any VAT tax without a myDATA VAT category before invoices using it reach the filing deadline.
# Customer need: after a migration some VAT taxes were created by hand; every week
# before the filing deadline, flag VAT taxes still missing a myDATA VAT category.
# Scheduled action (ir.cron), Python code on model account.tax;
# module installed: l10n_gr_nt_aade_tables. Server-action code takes no imports.
missing = model.search([
('company_id', 'in', env.companies.ids),
('mydata_type', '=', '0'),
('type_tax_use', 'in', ('sale', 'purchase')),
('aade_vat_category_id', '=', False),
])
if missing:
used = env['account.move.line'].search_count(
[('tax_ids', 'in', missing.ids), ('parent_state', '=', 'posted')])
log('VAT taxes without myDATA VAT category: %s (on %s posted lines)'
% (', '.join(missing.mapped('name')), used), level='warning')
# Fix from the tax form; for category 7 (exempt) set aade_vat_exemption_category_id
# in the same write, or the suite's constraint rejects it.account.tax.aade_vat_exemption_category_id
Many2one('aade.vat.exemption.category'), ondelete='restrict'Tax: VAT exemption reason. The exemption reason (table 8.3) of a 0% or exempt tax. Required when the VAT category is code 7. Read it to print the reason (through l10n_gr_printed_reason) or to group lines by exemption.
Example
An exporting company's accountant needs each quarter's VAT-exempt sales totalled per exemption reason to match the VAT return.
# Customer need: an exporter's accountant reconciles exempt sales per exemption
# reason with the VAT return each quarter.
# depends: ['l10n_gr_nt_aade_tables']
from odoo import models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def acme_exempt_sales_by_reason(self, date_from, date_to):
lines = self.search([
('parent_state', '=', 'posted'), ('company_id', '=', self.env.company.id),
('move_id.move_type', 'in', ('out_invoice', 'out_refund')),
('date', '>=', date_from), ('date', '<=', date_to),
('tax_ids.aade_vat_exemption_category_id', '!=', False)])
totals = {}
for line in lines:
for reason in line.tax_ids.aade_vat_exemption_category_id:
totals[reason] = totals.get(reason, 0.0) - line.balance
return [(r.code, r.l10n_gr_printed_reason(), amount)
for r, amount in sorted(totals.items(), key=lambda kv: kv[0].code)]account.tax.mydata_type
Selection [('0','VAT'),('1','Withholding Tax'),('2','Fees'),('3','Other Taxes'),('4','Stamp Duty'),('5','Deductions'),('6','Other')], required, default '0'Tax: myDATA tax type. Which myDATA tax family the tax belongs to. Read it to separate VAT from withholdings, fees, stamp duty and the rest in your own reports. When your module creates a tax that is not VAT, set the right value; the default is VAT.
Example
The collections team needs a list of posted invoices that carry a withholding tax, so it can request the withholding certificate from each client.
# Customer need: collections must chase the client's withholding certificate for
# every invoice that carries a withholding tax; give them a filter for it.
# depends: ['l10n_gr_nt_aade_tables']
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
x_has_withholding = fields.Boolean(
compute='_compute_x_has_withholding', store=True, string='Has withholding')
@api.depends('invoice_line_ids.tax_ids.mydata_type')
def _compute_x_has_withholding(self):
for move in self:
# '1' = Withholding Tax in the myDATA tax family
move.x_has_withholding = any(
t.mydata_type == '1' for t in move.invoice_line_ids.tax_ids)
# In a search view: <filter name="withholding" string="With withholding"
# domain="[('x_has_withholding', '=', True), ('state', '=', 'posted')]"/>Invoicing the Public Sector
depends: ['l10n_gr_nt_b2g']
Issues the electronic invoice to public bodies with the data the Peppol network requires, and tracks the public body's response.
account.move.b2g
Boolean, compute='_compute_b2g', store=True, readonly=False, copy=FalseInvoice: public authority invoice. True when the customer (its commercial partner) has a contracting authority code and the move is a sale invoice or credit note (not a receipt). Read it to branch your own logic or views for B2G invoices. It is editable, but it recomputes when the customer changes.
Example
A supplier to hospitals and ministries wants every public-sector invoice to create a payment follow-up for the collections team, because public bodies pay late.
# Customer need: public bodies pay late; every posted B2G invoice gets a
# follow-up activity for the collections team 60 days after the invoice date.
# depends: ['l10n_gr_nt_b2g']
from datetime import timedelta
from odoo import _, models
class AccountMove(models.Model):
_inherit = 'account.move'
def _post(self, soft=True):
posted = super()._post(soft=soft)
for move in posted.filtered('b2g'):
move.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=(move.invoice_date or move.date) + timedelta(days=60),
summary=_('Check payment by the public body'),
user_id=move.invoice_user_id.id or self.env.uid)
return postedaccount.move.b2g_buyer_reference
Char, copy=FalseInvoice: buyer reference. The reference the public body gave for this purchase (for example its department). Required before a B2G invoice can be posted. Carry it from the order or set it in your own wizard.
Example
A hospital supplier must capture the buyer reference the hospital gives on every order, so the invoice is not blocked at posting.
# Customer need: hospitals give a department/requisition reference with each
# order; it must reach the invoice, which cannot be posted without it.
# depends: ['l10n_gr_nt_b2g', 'sale']
from odoo import _, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
for order in self:
if (order.partner_id.commercial_partner_id.b2g_party_identification
and not order.client_order_ref):
raise UserError(_('%s is for a public body: fill in the customer reference.', order.name))
return super().action_confirm()
def _prepare_invoice(self):
vals = super()._prepare_invoice()
if self.client_order_ref:
vals['b2g_buyer_reference'] = self.client_order_ref
return valsaccount.move.b2g_contract_document_reference
Char, copy=FalseInvoice: contract identifier (ΑΔΑΜ). The ΑΔΑΜ of the contract, or 0 when there is none. Required before a B2G invoice can be posted. Fill it from your own contract or sale order data before posting.
Example
A company that wins public tenders wants the contract's ΑΔΑΜ entered once on the order and carried to every invoice issued under it.
# Customer need: tenders produce a contract with an ΑΔΑΜ; sales enter it once on
# the order and every invoice of the order carries it (0 when there is none).
# depends: ['l10n_gr_nt_b2g', 'sale']
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_adam = fields.Char(
string='Contract ΑΔΑΜ', copy=False,
help='Leave empty when the sale has no contract; the invoice then gets 0.')
def _prepare_invoice(self):
vals = super()._prepare_invoice()
vals['b2g_contract_document_reference'] = (self.x_adam or '0').strip().upper()
return valsaccount.move.b2g_party_identification
Char, compute='_compute_b2g', store=True, readonly=False, copy=FalseInvoice: contracting authority code. Filled from the customer's code, and editable per invoice because one tax number can have several authorities. Write it before posting when your module knows the right authority for this sale. It is one of the fields the suite checks before a B2G invoice can be posted.
Example
A supplier to a ministry with several contracting authorities under one tax number needs each invoice to carry the authority code of the delivery address.
# Customer need: one ministry (one tax number) has several contracting
# authorities; each delivery address carries its own code, and the invoice must
# use the code of the address the goods went to.
# depends: ['l10n_gr_nt_b2g', 'sale']
from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _prepare_invoice(self):
vals = super()._prepare_invoice()
# the company partner keeps its own code too: that is what marks the move as B2G
code = self.partner_shipping_id.b2g_party_identification
if code:
vals['b2g_party_identification'] = code
return valsres.partner.b2g_party_identification
Char; constraint: Latin capitals, digits and dots only (e.g. 1032.E00400.0001)Customer: contracting authority code. The contracting authority code the public body gives you. Setting it on a customer is what marks invoices to that customer as B2G. You may set it from your own import or sync; a value with Greek letters or other characters is rejected by a constraint, so normalise it to Latin capitals first. Requires l10n_gr_nt_b2g (which depends on l10n_gr_nt_mydata_invoice).
Example
A partner importing a list of public-body customers must clean the authority codes, which often contain Greek look-alike letters, before the field accepts them.
# Customer need: importing public-body customers from a tender list where codes
# were typed with Greek letters that look Latin (Ε, Ο, Α ...); normalise first,
# because the field accepts only Latin capitals, digits and dots.
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_b2g
import re
GREEK_TO_LATIN = str.maketrans('ΑΒΕΖΗΙΚΜΝΟΡΤΥΧ', 'ABEZHIKMNOPTYX')
rows = [ # (tax number, authority code) as received
('094000001', '1032.Ε00400.0001'),
('094000002', '1045.e00210.0003'),
]
for vat, raw in rows:
code = raw.strip().upper().translate(GREEK_TO_LATIN)
if not re.fullmatch(r'[A-Z0-9.]+', code):
print('Skipped, still invalid:', raw)
continue
partner = env['res.partner'].search(
[('vat', 'in', (vat, 'EL' + vat)), ('is_company', '=', True)], limit=1)
if partner:
partner.b2g_party_identification = code
else:
print('No customer with tax number', vat)
env.cr.commit()Greek GAAP Chart of Accounts: Extension
depends: ['l10n_gr_nt_chart_ext']
Adds to every account, tax and journal the data that transaction classification needs, and seeds the document series and the withholding, stamp duty and fee taxes.
account.journal.l10n_gr_invoice_type_id
Many2one('aade.invoice.type')Journal: AADE document type. The table 8.1 document type issued from this journal. It also drives the series numbering, which must be continuous per type, so treat it as configuration done in the journal form: read it in your code to find the journal for a document type, but do not change it from code on a journal that already has posted documents.
Example
A business that sells both goods and services wants service-only orders invoiced automatically from the Service Invoice series.
# Customer need: a company that sells goods and services must issue service-only
# orders as Service Invoices (2.1) from that series, not the goods series.
# depends: ['l10n_gr_nt_chart_ext', 'sale']
from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _prepare_invoice(self):
vals = super()._prepare_invoice()
lines = self.order_line.filtered(lambda l: not l.display_type)
if lines and all(l.product_id.type == 'service' for l in lines):
# read only: the document type is set on the journal form, never from code
journal = self.env['account.journal'].search([
('company_id', '=', self.company_id.id), ('type', '=', 'sale'),
('l10n_gr_invoice_type_id.code', '=', '2.1')], limit=1)
if journal:
vals['journal_id'] = journal.id
return valsaccount.move.l10n_gr_fees_total
Monetary(currency_field='currency_id'), compute='_compute_l10n_gr_fees_total', store=TrueInvoice: total fees. The sum of the fee amounts on the invoice lines. Stored, so you can search, group and report on it. Read only: change the fees through the products, not this field.
Example
A beverage distributor needs the recycling fees it collected each month, net of credit notes, to file its monthly fee declaration.
# Customer need: a beverage distributor declares the recycling fees it collected
# each month; the accountant wants the monthly totals, credit notes deducted.
# depends: ['l10n_gr_nt_chart_ext']
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def acme_fees_by_month(self, year):
domain = [
('company_id', '=', self.env.company.id), ('state', '=', 'posted'),
('move_type', 'in', ('out_invoice', 'out_refund')),
('invoice_date', '>=', f'{year}-01-01'), ('invoice_date', '<=', f'{year}-12-31'),
('l10n_gr_fees_total', '!=', 0)]
result = {}
for month, move_type, total in self._read_group(
domain, ['invoice_date:month', 'move_type'], ['l10n_gr_fees_total:sum']):
sign = -1 if move_type == 'out_refund' else 1
result[month] = result.get(month, 0.0) + sign * total
return resultaccount.tax.l10n_gr_deductible
Boolean, default TrueTax: VAT is deductible. VAT only: whether the VAT of this tax is deductible under Article 30 of the VAT Code. When off, the tax is added to the cost (non-deductible VAT account for expenses, acquisition value for assets) and the line is classified as category2_5. It says nothing about income-tax deductibility. Read it in your own purchase logic; set it on non-deductible purchase taxes your module creates (passenger cars, hospitality).
Example
A company with a passenger-car fleet wants to stop vendor bills for fuel or rentals from being posted with deductible VAT.
# Customer need: fuel and rentals for passenger cars carry non-deductible VAT;
# stop a vendor bill from being posted with deductible VAT on such costs.
# depends: ['l10n_gr_nt_chart_ext']
from odoo import _, fields, models
from odoo.exceptions import UserError
class ProductCategory(models.Model):
_inherit = 'product.category'
x_passenger_car_cost = fields.Boolean(string='Passenger car cost')
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
for move in self.filtered(lambda m: m.move_type in ('in_invoice', 'in_refund')):
wrong = move.invoice_line_ids.filtered(
lambda l: l.product_id.categ_id.x_passenger_car_cost
and l.tax_ids.filtered(lambda t: t.mydata_type == '0' and t.l10n_gr_deductible))
if wrong:
raise UserError(_('%(bill)s: use the non-deductible VAT on %(lines)s.',
bill=move.display_name,
lines=', '.join(wrong.mapped('product_id.display_name'))))
return super().action_post()account.tax.l10n_gr_tax_code
Char, readonly, copy=False (e.g. 'VAT-S-24')Tax: localization code. The tax's code in the localization's tax table, for example VAT-S-24. Not shown to customers. Use it to find a tax reliably from code instead of relying on its name, which users may rename. Read only.
Example
During a product migration the partner needs each legacy VAT code mapped to the right sale and purchase tax, even if users have renamed the taxes.
# Customer need: migrating products from a legacy ERP whose VAT codes are 1/2/3;
# map them to taxes by localization code, not by name (users rename taxes).
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_chart_ext
LEGACY_TO_CODES = { # legacy VAT code -> (sale tax code, purchase tax code)
'1': ('VAT-S-24', 'VAT-P-24'),
'2': ('VAT-S-13', 'VAT-P-13'),
'3': ('VAT-S-6', 'VAT-P-6'),
}
Tax = env['account.tax']
company_domain = Tax._check_company_domain(env.company)
products = [('ACME-001', '1'), ('ACME-002', '2')] # (internal reference, legacy VAT code)
for ref, legacy in products:
sale_code, purchase_code = LEGACY_TO_CODES[legacy]
sale_tax = Tax.search([*company_domain, ('l10n_gr_tax_code', '=', sale_code)], limit=1)
purchase_tax = Tax.search([*company_domain, ('l10n_gr_tax_code', '=', purchase_code)], limit=1)
product = env['product.template'].search([('default_code', '=', ref)], limit=1)
if product and sale_tax and purchase_tax:
product.write({'taxes_id': [(6, 0, sale_tax.ids)],
'supplier_taxes_id': [(6, 0, purchase_tax.ids)]})
else:
print('Not mapped:', ref, legacy)
env.cr.commit()l10n_gr.fee
model l10n_gr.fee (per-unit charge, AADE 8.7/8.5/8.6): name Char (required), sequence, active, company_id (required), l10n_gr_tax_type Selection [fees_category, other_tax, stamp_duty] (required, default fees_category), fees_category_id / other_tax_id / stamp_duty_id, per_unit Boolean (default True), amount Float (required, > 0), legal_basis Char, account_id, l10n_gr_in_vat_base Boolean (default True), l10n_gr_receipt_label Char, l10n_gr_verified Boolean, tax_id (readonly, managed), l10n_gr_tax_group_id (readonly, managed); ordered by sequence, idPer-unit fee. A charge collected with a product, such as the recycling fee on a plastic bottle or the environmental levy on a bag. Each fee belongs to one company and carries its AADE code from table 8.7, 8.5 or 8.6, an amount per unit (or, when per_unit is off, a percentage of the line's net value), the account it is credited to and whether it is in the VAT base. The amount must be greater than zero. The suite creates and keeps its own tax and tax group, so do not edit tax_id or l10n_gr_tax_group_id. Read fees in your module, or create one for a company and link it to products through product.template.l10n_gr_fee_id.
Example
Before go-live the partner wants a reminder listing every per-unit fee the accountant has not yet verified, with how many products use it.
# Customer need: before go-live the accountant confirms every per-unit fee
# (amount, AADE code, account); list the fees nobody has verified yet.
# Scheduled action (ir.cron), Python code on model l10n_gr.fee;
# module installed: l10n_gr_nt_chart_ext. Server-action code takes no imports.
unverified = model.search([
('company_id', 'in', env.companies.ids),
('l10n_gr_verified', '=', False),
])
for fee in unverified:
used_by = env['product.template'].search_count([('l10n_gr_fee_id', '=', fee.id)])
log('Fee "%s" (%s %s, %s) not verified, used by %s products' % (
fee.name, fee.amount, 'per unit' if fee.per_unit else '% of net value',
fee.company_id.name, used_by), level='warning')
# Read only here: the suite manages tax_id and l10n_gr_tax_group_id of each fee.product.template.l10n_gr_fee_id
Many2one('l10n_gr.fee'), domain: company_id = current companyProduct: per-unit fee. The fee charged together with this product, one per product. The fee is computed on the invoice line and totalled on the invoice. Set it from your own product import or sync, using a fee of the product's company.
Example
A beverage distributor importing its supplier catalogue needs every plastic-bottle product to carry the recycling fee automatically.
# Customer need: the supplier catalogue marks which items are plastic bottles;
# those products must carry the company's recycling fee from the first sale.
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_chart_ext
company = env.company
fee = env['l10n_gr.fee'].search([
('company_id', '=', company.id), ('l10n_gr_tax_type', '=', 'fees_category'),
('name', 'ilike', 'recycl')], limit=1)
assert fee, 'Create the recycling fee for %s first' % company.name
catalogue = [('BTL-500', True), ('BTL-1500', True), ('CAN-330', False)] # (reference, plastic bottle)
refs = [ref for ref, plastic in catalogue if plastic]
# the fee belongs to one company: link it only to that company's (or shared) products
products = env['product.template'].search([
('default_code', 'in', refs), ('company_id', 'in', (company.id, False))])
products.write({'l10n_gr_fee_id': fee.id})
print('%s products now carry %s' % (len(products), fee.name))
env.cr.commit()product.template.l10n_gr_income_category_id
Many2one('aade.income.category')Product: AADE income category override. Set only when the product goes to a different income category from the one of its income account; empty means use the account's classification, which is right for most products. Set it from your own product data when a product needs a different classification.
Example
A retailer that also sells installation services needs those services classified as services income in myDATA, although they share the goods' income account.
# Customer need: installation services post to the same income account as the
# goods, but myDATA wants them as services income (category1_3); override only them.
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_chart_ext
services = env['aade.income.category'].search([('code', '=', 'category1_3')], limit=1)
assert services, 'category1_3 not found'
products = env['product.template'].search([
('type', '=', 'service'),
('categ_id.name', '=', 'Installation'),
('l10n_gr_income_category_id', '=', False), # keep any override already set
])
products.write({'l10n_gr_income_category_id': services.id})
print('%s installation services now classified as %s' % (len(products), services.display_name))
# Empty override = the income account's classification, right for every other product.
env.cr.commit()res.partner.l10n_gr_reduced_vat_status
Selection ['normal', 'taxable', 'legal_person'] (labels: normal regime; reduced, taxable person established on the island; reduced, non-taxable legal person), required, default 'normal'; commercial fieldPartner: island reduced VAT status. Whether the reduced island VAT rate may apply to this partner. The place alone is not enough: an individual living on the island stays on the normal rate. It is a commercial field, so set it on the company partner and it propagates to its addresses. Set it from your own onboarding or import; requires l10n_gr_nt_chart_ext.
Example
A wholesaler on an island migrating its customers needs the reduced island VAT status carried over only for the customers that qualify.
# Customer need: a wholesaler on Rhodes migrates customers from its old ERP,
# which flagged island businesses; carry the flag so the reduced rate applies
# only to those customers.
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_chart_ext
LEGACY_STATUS = {'N': 'normal', 'I': 'taxable', 'L': 'legal_person'}
rows = [('094000001', 'I'), ('094000002', 'N'), ('094000003', 'L')] # (tax number, legacy flag)
Partner = env['res.partner']
for vat, flag in rows:
partner = Partner.search([('vat', 'in', (vat, 'EL' + vat))], limit=1).commercial_partner_id
if not partner:
print('Not found:', vat)
continue
# commercial field: set it on the company, Odoo copies it to its addresses
partner.l10n_gr_reduced_vat_status = LEGACY_STATUS[flag]
env.cr.commit()res.partner.l10n_gr_vat45_buyer
BooleanPartner: Article 45 taxable buyer. Marks a VAT-registered buyer with a Greek tax number who buys goods under Article 45 of the VAT Code. On sale lines of Article 45 products to such a buyer the suite replaces the VAT with the reverse-charge tax; other products keep their tax. Set it on the commercial partner from your own data; read it if your module computes prices or taxes on its own.
Example
A seller of Article 45 goods wants quotations to qualifying buyers to state up front that VAT on those goods will be reverse charged.
<!-- Customer need: quotations to Article 45 buyers must tell the buyer that the
VAT of Article 45 goods is reverse charged, before the invoice arrives. -->
<!-- depends: ['l10n_gr_nt_chart_ext', 'sale'] -->
<odoo>
<template id="acme_quote_art45_note" inherit_id="sale.report_saleorder_document">
<xpath expr="//div[hasclass('page')]" position="inside">
<p t-if="doc.partner_id.commercial_partner_id.l10n_gr_vat45_buyer" class="mt-3 small">
Article 45 goods on this quotation are invoiced with reverse charge of VAT;
the other goods keep their VAT.
</p>
</xpath>
</template>
</odoo>Chart of Accounts Import
depends: ['l10n_gr_nt_chart_import']
Brings in the customer's chart of accounts from Excel, matched to a template account with its classification and taxes.
account.account.l10n_gr_legacy_code
Char, index='btree_not_null', copy=FalseAccount: previous system code. The code the account had in the chart it was imported from (for example the old ΕΓΛΣ code when the company moves to ΕΛΠ). The accounting team still knows accounts by it, so use it to map data from the customer's previous system to the new accounts in your own migration or import module. Indexed for search. Filled by the import; requires l10n_gr_nt_chart_import.
Example
A company moving to the new chart of accounts needs its opening balances, exported from the old ERP by old account code, booked on the matching new accounts.
# Customer need: opening balances come from the old ERP keyed by the old ΕΓΛΣ
# codes; book them as one draft opening entry on the new accounts.
# Run in odoo-bin shell from the console (env is given); module installed: l10n_gr_nt_chart_import
from odoo import Command, fields
balances = [('38.00.00', 1520.40), ('38.03.00', 820.40), ('40.00.00', -2340.80)] # (old code, debit - credit)
Account = env['account.account']
lines, missing = [], []
for old_code, amount in balances:
acc = Account.search([('company_ids', 'in', env.company.id),
('l10n_gr_legacy_code', '=', old_code)], limit=1)
if not acc:
missing.append(old_code)
continue
lines.append(Command.create({'account_id': acc.id, 'name': 'Opening balance',
'debit': max(amount, 0.0), 'credit': max(-amount, 0.0)}))
assert not missing, 'No account for old codes: %s' % missing
journal = env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', env.company.id)], limit=1)
move = env['account.move'].create({
'move_type': 'entry', 'journal_id': journal.id, 'date': fields.Date.to_date('2027-01-01'),
'ref': 'Opening balances from legacy ERP', 'line_ids': lines})
print('Draft opening entry', move.id, '- the accountant reviews and posts it')
env.cr.commit()Intra-EU Recapitulative Statements (VIES)
depends: ['l10n_gr_nt_ec_purchase']
Builds the recapitulative statements of intra-EU supplies and acquisitions from the period's entries, with the corrective statement and the TAXISnet file.
account.move.l10n_gr_triangular
Boolean, copy=False; constraint on postingInvoice: triangular transaction. Marks the company as a link in a triangular chain (Article 15 §2 of the VAT Code); the value then goes to column 6 of the recapitulative statement instead of column 5. Set it from your own logic, for example from a sale order flag, before posting. On posting a constraint requires at least one line with an intra-EU tax tag, unless the document is excluded from the statement, so set the intra-EU tax on the lines too.
Example
A trader whose EU supplier ships directly to an EU customer needs those sales marked as triangular so they land in the right column of the recapitulative statement.
# Customer need: a Greek trader buys from an Italian supplier and the goods ship
# straight to a German customer; those invoices go to column 6 of the VIES statement.
# depends: ['l10n_gr_nt_ec_purchase', 'sale']
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_triangular = fields.Boolean(
string='Triangular (shipped from another EU state)', copy=False,
help="Set when the goods travel from the supplier's EU state straight to the customer.")
def _prepare_invoice(self):
vals = super()._prepare_invoice()
eu = self.env.ref('base.europe').country_ids
country = self.partner_id.commercial_partner_id.country_id
# posting also needs an intra-EU tax on the lines: use the EU fiscal position
vals['l10n_gr_triangular'] = bool(
self.x_triangular and country in eu and country.code != 'GR')
return valsRecipient Type Check
depends: ['l10n_gr_nt_guard_partner_rule']
Limits each journal to the recipient type that belongs to it, so that a wholesale invoice is not issued to a private individual.
res.company.l10n_gr_vat_exempt_small_entity
BooleanCompany: VAT-exempt small business. Declares that the company is a VAT-exempt small business. When on, the maximum amount of the simplified invoice is not enforced, because Article 10 §1 of Law 4308/2014 allows it without limit in this case. It is declared, not computed: the turnover threshold changes by circular and joining the regime is the business's choice. It is a company field shown on the journal form, where it is set; read it in your own checks that depend on the regime.
Example
A small business under the VAT exemption regime must not confirm any order that still charges VAT on a line.
# Customer need: a VAT-exempt small business must never charge VAT; stop an
# order that still carries a VAT tax on any line.
# depends: ['l10n_gr_nt_guard_partner_rule', 'sale']
from odoo import _, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
for order in self.filtered(lambda o: o.company_id.l10n_gr_vat_exempt_small_entity):
taxed = order.order_line.filtered(
lambda l: l.tax_ids.filtered(lambda t: t.mydata_type == '0' and t.amount))
if taxed:
raise UserError(_('%(company)s is a VAT-exempt small business: remove VAT from %(lines)s.',
company=order.company_id.name,
lines=', '.join(taxed.mapped('product_id.display_name'))))
return super().action_confirm()Digital Movement
depends: ['l10n_gr_nt_movement']
Follows the delivery note of a transfer or invoice through its lifecycle at AADE (registered, in transit, delivered, returned, completed), with vehicles, distribution and van-sales routes, and product matching on the recipient side. Install l10n_gr_nt_movement; it brings l10n_gr_nt_stock, stock_barcode, sms and the received-documents module with it.
account.move.l10n_gr_movement_id
fields.Many2one('l10n_gr.movement', compute='_compute_l10n_gr_movement', string='Digital Movement') (not stored)Movement of an invoice. For an invoice that also serves as delivery note, its digital movement, or empty. Computed and not stored: read it on a record, do not search on it.
Example
The customer's invoice that also serves as delivery note prints the delivery-note number, the vehicle and the current delivery status.
<!-- depends: ['l10n_gr_nt_movement'] -->
<!-- Customer need: the printed invoice that is also the delivery note shows the note, the truck and where the goods are -->
<template id="report_invoice_acme_movement" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='informations']" position="inside">
<t t-set="movement" t-value="o.l10n_gr_movement_id"/>
<div class="col" t-if="movement" name="acme_movement">
<strong>Delivery note</strong>
<div t-field="movement.name"/>
<div t-if="movement.current_vehicle_id">
Vehicle: <span t-field="movement.current_vehicle_id.license_plate"/>
</div>
<div t-if="movement.status_id">
Status: <span t-field="movement.status_id"/>
</div>
</div>
</xpath>
</template>l10n_gr.movement
_name = 'l10n_gr.movement' (Digital Movement); _inherit = ['mail.thread', 'mail.activity.mixin']; main fields: name (delivery note), role ('issuer' | 'recipient'), company_id, picking_id, move_id, status_id, event_idsDigital movement. One record per delivery note, created by the suite when a delivery note is issued (role 'issuer') or received (role 'recipient'). It links the note to its transfer or invoice and holds its current status, carrier and vehicle. Almost every field is readonly: they reflect declarations already made. Use it to read the state of a delivery, to add your own fields, or to hook into status changes through _l10n_gr_on_status_change. Do not create movements or write their readonly fields from your code.
Example
A warehouse system stores its own shipment reference on every delivery note and pulls the notes that are still open.
# depends: ['l10n_gr_nt_movement']
# Customer need: the 3PL's WMS keeps its own shipment reference on each delivery note
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.l10n_gr_*
from odoo import api, fields, models
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
acme_wms_ref = fields.Char(string='WMS Shipment', copy=False, index=True)
@api.model
def acme_open_issued(self, company):
"""Issued delivery notes not yet final, for the WMS sync (2 cancelled and 8 completed are final)."""
return self.search([
('company_id', '=', company.id),
('role', '=', 'issuer'),
('status_id.code', 'not in', (2, 8)),
], order='id desc')l10n_gr.movement._l10n_gr_match_product
_l10n_gr_match_product(self, code, name) -> product.product (possibly empty)Product matching on receipt. Recipient side: given the item code and the description of a line on a received delivery note, returns the matching product. It tries, in order, the supplier's product code for the movement's supplier, the barcode, the internal reference and finally the exact product name, and returns an empty recordset when nothing matches. It runs on a single movement. Override it to add your own rule (an old code, a GTIN field of yours) and fall back to super() so the standard order still applies.
Example
Received delivery notes whose lines carry the supplier's old ERP codes are matched to the right products automatically.
# depends: ['l10n_gr_nt_movement']
# Customer need: suppliers still print the item codes of the old ERP; match them before the standard rules
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
acme_legacy_code = fields.Char(string='Legacy ERP Code', index=True)
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
def _l10n_gr_match_product(self, code, name):
self.ensure_one()
if code:
product = self.env['product.product'].search([
('acme_legacy_code', '=', code.strip()),
('company_id', 'in', (False, self.company_id.id)),
], limit=1)
if product:
return product
# standard order: supplier code, barcode, internal reference, exact name
return super()._l10n_gr_match_product(code, name)l10n_gr.movement._l10n_gr_on_status_change
_l10n_gr_on_status_change(self, old, new) -> NoneStatus change hook. Called on one movement right after its status has changed, only when the code is really different; old and new are the integer status codes (see status_id). The suite uses it to draft a return and a credit-note activity after a failed, partial or rejected delivery, to close open activities when the status becomes final, to close deadline activities and to notify the customer at departure. Override it in your module to add your own reaction, and always call super() first so the suite's behaviour runs. The declaration behind the change has already been made, so keep your code light and do not raise from it; put anything that may fail (emails, external calls) inside a savepoint.
Example
An external WMS or EDI hub is notified as soon as a delivery is completed, fails or starts returning.
# depends: ['l10n_gr_nt_movement']
# Customer need: the 3PL's WMS is told at once when a delivery fails, completes or returns
import logging
import requests
from odoo import models
_logger = logging.getLogger(__name__)
NOTIFY = {7: 'failed', 8: 'completed', 9: 'returning'}
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
def _l10n_gr_on_status_change(self, old, new):
super()._l10n_gr_on_status_change(old, new) # the suite's own reactions first
url = self.env['ir.config_parameter'].sudo().get_param('acme.wms_webhook_url')
if self.role != 'issuer' or new not in NOTIFY or not url:
return
try: # the declaration is already made: never raise from here
with self.env.cr.savepoint():
requests.post(url, timeout=10, json={
'delivery_note': self.name, 'picking': self.picking_id.name, 'event': NOTIFY[new],
}).raise_for_status()
except Exception:
_logger.exception('WMS notification failed for %s', self.name)l10n_gr.movement.current_vehicle_id
fields.Many2one('l10n_gr.movement.vehicle', string='Current Vehicle', readonly=True)Current vehicle. The vehicle last declared for the movement (at departure or at a change of vehicle). Empty when the movement runs without a vehicle of the register, for example with an external carrier. Read it to show where the goods are or to group deliveries per vehicle.
Example
A dispatch screen counts the deliveries still on the road per truck of the company's fleet.
# depends: ['l10n_gr_nt_movement']
# Customer need: the dispatcher sees how many deliveries each truck is still carrying
from odoo import api, models
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
@api.model
def acme_loads_per_vehicle(self):
groups = self._read_group(
[('role', '=', 'issuer'),
('status_id.code', '=', 3), # in transit
('current_vehicle_id', '!=', False),
('company_id', 'in', self.env.companies.ids)],
groupby=['current_vehicle_id'],
aggregates=['__count'],
)
return [{'plate': vehicle.license_plate, 'deliveries': count} for vehicle, count in groups]l10n_gr.movement.delivered_at
fields.Datetime(compute='_compute_l10n_gr_kpi', store=True, string='Delivered')Delivered at. When a delivery outcome was first confirmed: the AADE time of the earliest confirmed outcome event, or the device time when AADE gave none. It is set by any confirmed outcome, a failed delivery included, so check the movement's status when you need goods actually handed over. Empty until an outcome is confirmed. It is stored, so you can search, sort and group by it in your own reports and dashboards.
Example
Management tracks on-time delivery by comparing when goods were handed over with the planned date of the transfer.
# depends: ['l10n_gr_nt_movement']
# Customer need: a punctuality report of deliveries handed over more than a day after the planned date
from datetime import timedelta
from odoo import api, fields, models
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
acme_late = fields.Boolean(string='Late Delivery', compute='_compute_acme_late', store=True)
@api.depends('delivered_at', 'status_id', 'picking_id.scheduled_date')
def _compute_acme_late(self):
for movement in self:
planned = movement.picking_id.scheduled_date
# delivered_at is also set by a failed delivery: count only goods handed over (5, 8)
movement.acme_late = bool(
planned and movement.delivered_at
and movement.status_id.code in (5, 8)
and movement.delivered_at > planned + timedelta(days=1)
)l10n_gr.movement.event
_name = 'l10n_gr.movement.event' (Digital Movement Event); fields: movement_id, event_type, state ('queued' | 'sent' | 'confirmed' | 'failed'), outcome ('FULL' | 'PARTIAL' | 'NONE'), vehicle_id, carrier_id, lat, lon, local_ts, aade_ts, origin ('odoo' | 'external'), user_idMovement event. Each step recorded for a movement (issue, departure or change of vehicle, delivery outcome, rejection, return, cancellation), with who declared it, when, from where and with what result. Events are written by the suite's declaration flow and by the driver device; creating or editing them from your code is not supported. Read them for timelines, driver reports or punctuality analysis.
Example
The logistics manager receives a monthly report of full, partial and failed deliveries per driver.
# depends: ['l10n_gr_nt_movement']
# Customer need: a monthly driver report of confirmed delivery outcomes (full, partial, failed)
# Events are read only (the suite and the driver device write them): the report lives on the movement
from dateutil.relativedelta import relativedelta
from odoo import api, models
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
@api.model
def acme_driver_month(self, month_start):
groups = self.env['l10n_gr.movement.event']._read_group(
[('state', '=', 'confirmed'),
('outcome', '!=', False),
('local_ts', '>=', month_start),
('local_ts', '<', month_start + relativedelta(months=1))],
groupby=['user_id', 'outcome'],
aggregates=['__count'],
)
report = {}
for user, outcome, count in groups:
report.setdefault(user.name or '-', {'FULL': 0, 'PARTIAL': 0, 'NONE': 0})[outcome] = count
return reportl10n_gr.movement.l10n_gr_track_url
fields.Char(compute='_compute_l10n_gr_track_url', string='Tracking Link') (not stored)Tracking link. The public link where the customer follows the delivery. It exists only once the movement has an access token, which the suite creates when it sends the departure notification by email or SMS to a customer who asked for it; otherwise it stays empty. Use it in your own email templates, SMS or portal pages, and check that it is set before you send it.
Example
The e-shop's shipping email gives the buyer a link to follow the delivery, only when such a link exists.
<!-- depends: ['l10n_gr_nt_movement'] -->
<!-- Customer need: the e-shop's 'order shipped' email carries the delivery tracking link when there is one -->
<record id="mail_template_acme_shipped" model="mail.template">
<field name="name">Acme: order shipped</field>
<field name="model_id" ref="stock.model_stock_picking"/>
<field name="subject">Your order {{ object.origin }} is on its way</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<div>
<p>Dear <t t-out="object.partner_id.name"/>,</p>
<p>Your order <t t-out="object.origin"/> has left our warehouse.</p>
<!-- empty until the suite has created the access token: never send a blank link -->
<t t-set="track" t-value="object.l10n_gr_movement_id.l10n_gr_track_url"/>
<p t-if="track">Follow the delivery: <a t-att-href="track">track your order</a></p>
</div>
</field>
</record>l10n_gr.movement.status_id
fields.Many2one('aade.delivery.status', string='Status', readonly=True, tracking=True, index=True)Status. The current AADE status of the delivery note. The status codes are: 1 registered, 2 cancelled, 3 in transit, 4 rejected, 5 delivered by the carrier, 7 failed delivery, 8 completed, 9 returning; 2 and 8 are final. It changes only through declarations and status checks made by the suite, and every change is tracked in the chatter. Read it, filter or group by it; never write it.
Example
Sales staff see on each sales order the AADE status of its deliveries without opening the transfers.
# depends: ['l10n_gr_nt_movement', 'sale_stock']
# Customer need: sales staff see on the order whether the goods have reached the customer
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
acme_delivery_status = fields.Char(string='Delivery Status', compute='_compute_acme_delivery_status')
def _compute_acme_delivery_status(self):
# search movements by transfer: picking.l10n_gr_movement_id is not stored
movements = self.env['l10n_gr.movement'].search([
('role', '=', 'issuer'),
('picking_id', 'in', self.picking_ids.ids),
], order='id desc')
for order in self:
mine = movements.filtered(lambda m: m.picking_id in order.picking_ids)
order.acme_delivery_status = ', '.join(mine.mapped('status_id.name')) or Falsel10n_gr.movement.vehicle
_name = 'l10n_gr.movement.vehicle' (Movement Vehicle); _rec_name = 'license_plate'; fields: license_plate (required), transport_type_id, p_number (trailer), carrier_id (owner, default company partner), company_id, activeVehicle register. The company's vehicles used in movements, named by their licence plate. The plate is stored in capitals without spaces or dashes and is unique per company. A new vehicle defaults to a private-use truck (AADE means of transport code 2) and to the company as owner. You may create vehicles from your own fleet integration, add fields with _inherit, and archive vehicles that are out of use instead of deleting them, since past events refer to them.
Example
Vehicles kept in the Fleet app are created or archived as movement vehicles automatically.
# depends: ['l10n_gr_nt_movement', 'fleet']
# Customer need: trucks registered in Fleet become movement vehicles without retyping the plate
from odoo import fields, models
class FleetVehicle(models.Model):
_inherit = 'fleet.vehicle'
acme_movement_vehicle_id = fields.Many2one('l10n_gr.movement.vehicle', copy=False, readonly=True)
def acme_sync_movement_vehicle(self):
Vehicle = self.env['l10n_gr.movement.vehicle'].with_context(active_test=False)
for car in self.filtered('license_plate'):
company = car.company_id or self.env.company
# the register stores plates in capitals without spaces or dashes
plate = car.license_plate.upper().replace(' ', '').replace('-', '')
vehicle = Vehicle.search([('license_plate', '=', plate), ('company_id', '=', company.id)], limit=1)
if not vehicle:
vehicle = Vehicle.create({'license_plate': plate, 'company_id': company.id})
vehicle.active = car.active # archive, never delete: past events refer to it
car.acme_movement_vehicle_id = vehiclel10n_gr.route.load.line
_name = 'l10n_gr.route.load.line' (Route Load Line); fields: batch_id (required), product_id (storable), uom_id, quantity (loaded), sold_qty, returned_qty, available_qty (computed)Van load line. What was loaded on a van sales route, per product, with what has been sold at the stops, what returned to the warehouse, and what is still available on the van (loaded minus sold minus returned). The quantities sold and returned are computed from the route's stops and returns and are not stored. Read it for stock-on-van screens or end-of-day reports.
Example
The office gets an end-of-day summary of loaded, sold, returned and remaining quantities on each van-sales route.
# depends: ['l10n_gr_nt_movement']
# Customer need: at the end of the day the office sees what is still on the van, per product
from markupsafe import Markup
from odoo import _, models
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
def acme_post_van_stock(self):
for route in self.filtered(lambda r: r.l10n_gr_route_kind == 'van_sales'):
lines = self.env['l10n_gr.route.load.line'].search([('batch_id', '=', route.id)])
rows = [
_('%(product)s: loaded %(loaded)s, sold %(sold)s, returned %(ret)s, on van %(left)s %(uom)s',
product=line.product_id.display_name, loaded=line.quantity, sold=line.sold_qty,
ret=line.returned_qty, left=line.available_qty, uom=line.uom_id.name)
for line in lines if line.available_qty
]
body = Markup('<br/>').join(rows) if rows else _('Nothing left on the van.')
route.message_post(body=body)stock.picking.batch.l10n_gr_route_kind
fields.Selection([('distribution', 'Distribution'), ('van_sales', 'Van Sales')], string='Route Type')Route type. Turns a batch into a route. Distribution: one delivery note per stop, issued before departure. Van sales: one aggregate delivery note for the whole load, to which each sale at a stop is correlated. Empty means an ordinary batch that the suite does not treat as a route. Set it when you create the route from your planning module; do not change it after the route has been loaded.
Example
A planning module creates distribution routes from the day's ready deliveries, so the suite issues one delivery note per stop.
# depends: ['l10n_gr_nt_movement']
# Customer need: the route planner turns the day's ready deliveries into a distribution route in one click
from odoo import Command, _, models
from odoo.exceptions import UserError
class StockPicking(models.Model):
_inherit = 'stock.picking'
def acme_create_distribution_route(self):
pickings = self.filtered(
lambda p: p.picking_type_code == 'outgoing' and p.state == 'assigned' and not p.batch_id)
if not pickings:
raise UserError(_('No ready deliveries to plan.'))
if len(pickings.company_id) > 1:
raise UserError(_('Plan one company at a time.'))
# set the kind at creation; never change it once the route is loaded
return self.env['stock.picking.batch'].create({
'l10n_gr_route_kind': 'distribution',
'company_id': pickings.company_id.id,
'picking_ids': [Command.set(pickings.ids)],
})stock.picking.l10n_gr_movement_id
fields.Many2one('l10n_gr.movement', compute='_compute_l10n_gr_movement', string='Digital Movement') (not stored)Movement of a transfer. The digital movement whose delivery note was issued for this transfer, or empty. It is computed and not stored, so read it on a record but do not use it in a search domain; to search, query l10n_gr.movement by its transfer instead.
Example
An EDI despatch advice sent to a retail chain includes the delivery-note number and status of the transfer.
# depends: ['l10n_gr_nt_movement']
# Customer need: the supermarket chain's EDI despatch advice must carry the AADE delivery-note number
from odoo import models
class StockPicking(models.Model):
_inherit = 'stock.picking'
def acme_edi_desadv_payload(self):
self.ensure_one()
movement = self.l10n_gr_movement_id # computed, not stored: read it, never search on it
return {
'despatch_number': self.name,
'order_reference': self.origin,
'delivery_note': movement.name or None,
'aade_status': movement.status_id.code or None,
'vehicle': movement.current_vehicle_id.license_plate or None,
'lines': [
{'gtin': m.product_id.barcode, 'qty': m.quantity, 'uom': m.product_uom.name}
for m in self.move_ids
],
}stock.picking.l10n_gr_route_id
fields.Many2one('stock.picking.batch', string='Van Sales Route', index=True, copy=False, domain=van-sales routes in transit)Van sales route of a stop. For a sale made at a stop of a van sales route, the route it belongs to; its delivery note is correlated to the route's aggregate delivery note. The stop is linked through this field rather than being a member of the batch. It is set by the van sales flow; read it to report per route, and do not reassign it once the stop's delivery note exists.
Example
Each van-sales route shows how many sales were completed at its stops, for comparison between routes.
# depends: ['l10n_gr_nt_movement']
# Customer need: the sales manager compares how many stops each van-sales route actually served
from odoo import fields, models
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
acme_stop_count = fields.Integer(string='Stops Served', compute='_compute_acme_stop_count')
def _compute_acme_stop_count(self):
# stops are linked through l10n_gr_route_id, not as members of the batch
counts = dict(self.env['stock.picking']._read_group(
[('l10n_gr_route_id', 'in', self.ids), ('state', '=', 'done')],
groupby=['l10n_gr_route_id'],
aggregates=['__count'],
))
for route in self:
route.acme_stop_count = counts.get(route, 0)Digital Movement, driver app
depends: ['l10n_gr_nt_movement_mobile']
The driver device for digital movements: routes assigned to a driver, departure and delivery declarations from the road, and proof of delivery with signature, photos and position. Install l10n_gr_nt_movement_mobile (requires l10n_gr_nt_movement and hr).
l10n_gr.movement.proof
_name = 'l10n_gr.movement.proof' (Delivery Proof); fields: movement_id, event_id, employee_id (driver), outcome (related to the event), signature (Image), signer_name, signer_role, photo_ids, note, lat, lon, accuracy, distance_m, without_position, local_ts, map_urlDelivery proof. What the driver collected at the delivery: the recipient's signature, name and capacity, photos, a note, the device position with its accuracy and the distance from the delivery address, or a flag when no position was available. It is created by the driver device together with the delivery event. Read it to attach proof to customer emails, show it in the portal or audit deliveries far from the address.
Example
A daily check flags deliveries whose proof was signed far from the customer's address or without a device position.
# depends: ['l10n_gr_nt_movement_mobile']
# Customer need: the logistics manager reviews deliveries signed far from the address or without a position
# Proofs are read only (the driver device creates them): the check lives on the movement
from datetime import timedelta
from odoo import _, api, fields, models
class L10nGrMovement(models.Model):
_inherit = 'l10n_gr.movement'
@api.model
def _cron_acme_flag_far_deliveries(self, max_distance=500):
proofs = self.env['l10n_gr.movement.proof'].search([
('local_ts', '>=', fields.Datetime.now() - timedelta(days=1)),
'|', ('without_position', '=', True), ('distance_m', '>', max_distance),
])
for proof in proofs:
where = _('no position') if proof.without_position else _('%s m from the address', proof.distance_m)
proof.movement_id.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Check delivery proof'),
note=_('Signed by %(name)s, %(where)s.', name=proof.signer_name or '-', where=where),
user_id=proof.movement_id.picking_id.user_id.id or self.env.user.id,
)stock.picking.batch.l10n_gr_driver_id
fields.Many2one('hr.employee', string='Driver', index=True, check_company=True)Route driver. The driver who sees this route on the driver device. When empty, any driver of the vehicle sees it and the first one who declares the departure takes it. Write it from your own dispatch or planning module before the route leaves; the employee must belong to the route's company.
Example
The dispatch module assigns each route to a specific driver so that only that driver sees it on the device.
# depends: ['l10n_gr_nt_movement_mobile']
# Customer need: dispatch assigns each route to the driver on shift before the van leaves
from odoo import _, models
from odoo.exceptions import UserError
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
def acme_assign_driver(self, employee):
for route in self:
if not route.l10n_gr_route_kind:
raise UserError(_('%s is not a route.', route.name))
if employee.company_id != route.company_id:
raise UserError(_('%(driver)s does not work for %(company)s.',
driver=employee.name, company=route.company_id.name))
# only this driver sees the routes on the driver device
self.l10n_gr_driver_id = employeeYear-end tax adjustments
depends: ['l10n_gr_nt_mydata_adjust']
The tax add-back statement (Κατάσταση Φορολογικής Αναμόρφωσης) built from the books, and the check of E3 figures against what was transmitted to myDATA, per company and financial year. Install l10n_gr_nt_mydata_adjust (requires l10n_gr_nt_mydata_invoice and l10n_gr_nt_chart_ext).
account.account.l10n_gr_kfa_line_id
fields.Many2one('l10n_gr.kfa.line', string='Tax add-back statement line', domain=[('is_total', '=', False)])Add-back line of an account. For an expense account with an add-back breakdown, the line of the tax add-back statement that the account's add-back entries are booked to, for example code 2011 for entertainment expenses. The total line cannot be chosen, and the field is required once the breakdown is enabled on the account. The statement sums the posted add-back entries, so a change here applies to entries created after it, not to entries already posted. Set it in your module's data or setup code when you add expense accounts of your own.
Example
Expense accounts the partner adds for the customer are linked at install time to the right line of the tax add-back statement.
# depends: ['l10n_gr_nt_mydata_adjust']
# Customer need: the customer's own entertainment sub-accounts feed line 2011 of the tax add-back statement
# __manifest__.py: 'post_init_hook': 'post_init_hook'
def post_init_hook(env):
kfa_line = env['l10n_gr.kfa.line'].search([('code', '=', '2011'), ('is_total', '=', False)], limit=1)
if not kfa_line:
return
accounts = env['account.account'].search([
('code', '=like', '64.98.05.%'), # the customer's entertainment sub-accounts
('l10n_gr_kfa_line_id', '=', False),
])
# applies to add-back entries created from now on, not to entries already posted
accounts.write({'l10n_gr_kfa_line_id': kfa_line.id})l10n_gr.e3.check
_name = 'l10n_gr.e3.check'; one per (year, company_id); fields: line_ids, status, income_book, income_mydata, income_limit, income_breach, expense_book, expense_mydata, expense_limit, expense_breach, expense_pending_aade, adjustment_move_ids, computed_on; action_refresh(self) -> TrueE3 check against myDATA. Compares the income and expenses of the books with what myDATA holds for the year, judged on the totals of each side. Book income below 70% of the transmitted income, or book expenses above 130% of the transmitted expenses, is flagged as a breach, since the return would be refused. While any posted purchase document of the year has no myDATA mark recorded in Odoo, the expense side is not judged (expense_pending_aade). action_refresh() reads both sides again. Settlement entries are created by the accountant from the screen; leave that step to them and only read adjustment_move_ids.
Example
A scheduled check refreshes the E3-against-myDATA comparison and gives the accountant a to-do when the return would be refused.
# depends: ['l10n_gr_nt_mydata_adjust']
# Customer need: before the income tax return, the accountant is warned when books and myDATA disagree
from odoo import _, api, fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model
def _cron_acme_e3_watch(self):
accountants = self.env.ref('account.group_account_manager').all_user_ids
checks = self.env['l10n_gr.e3.check'].search([('year', '=', fields.Date.today().year - 1)])
for check in checks:
check.action_refresh()
expense_judged = not check.expense_pending_aade
if not (check.income_breach or (expense_judged and check.expense_breach)):
continue
user = accountants.filtered(lambda u: check.company_id in u.company_ids)[:1]
# settlement entries stay with the accountant, from the screen
check.company_id.partner_id.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('E3 vs myDATA %s', check.year),
note=check.status,
user_id=(user or self.env.user).id,
)l10n_gr.kfa.line
_name = 'l10n_gr.kfa.line'; fields: code (unique), name, line_group, form_number, is_total, e3_code, e3_reconcile_code, legal_basis, activeAdd-back statement lines. The lines of the AADE form, as legal data: code is the identity of a line and form_number its position on the paper, which do not follow the same order. One total line (is_total) sums the others and goes to the E3. The lines load with noupdate, so the accountant can add a new case from the screen without waiting for a release. Read them or search by code; do not edit or delete the shipped lines, archive instead.
Example
During migration, the legacy ERP's mapping of expense accounts to add-back lines is loaded by line code.
# odoo-bin shell snippet, run through the console (staging first)
# depends: ['l10n_gr_nt_mydata_adjust']
# Customer need: carry the legacy ERP's add-back mapping (account -> form line code) into Odoo
import csv
import io
MAPPING = """account;kfa
64.98.05.0000;2011
""" # exported from the legacy ERP, one row per expense account
for row in csv.DictReader(io.StringIO(MAPPING), delimiter=';'):
line = env['l10n_gr.kfa.line'].search([('code', '=', row['kfa']), ('is_total', '=', False)], limit=1)
account = env['account.account'].search([('code', '=', row['account'])], limit=1)
if not (line and account):
print('skipped', row)
continue
account.l10n_gr_kfa_line_id = line
env.cr.commit()l10n_gr.kfa.report
_name = 'l10n_gr.kfa.report'; one per (year, company_id); fields: line_ids, amount_total, e3_amount, e3_reconcile_amount, e3_matches, e3_status, l10n_gr_pending_count, withholding_declared, computed_on; action_compute(self) -> TrueTax add-back statement. The statement of one company for one financial year (one record per year and company), defaulting to the previous year because it accompanies the income tax return. action_compute() reads the posted add-back entries again and rebuilds every line of the form, zero lines included. It gives the total of the form, the amount for E3 box 455, the reconciliation amount from the off-balance accounts, whether the two agree, and how many posted documents of the year charge an expense with an add-back breakdown but still have no add-back entry. Read it for your own reporting or call action_compute() to refresh it.
Example
The year-end management pack includes the tax add-back total, the E3 amount and any documents still missing an add-back entry.
# depends: ['l10n_gr_nt_mydata_adjust']
# Customer need: the CFO's year-end pack shows the add-back total, E3 box 455 and whether they agree
from odoo import _, models
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
def acme_kfa_year_summary(self, year):
self.ensure_one()
report = self.env['l10n_gr.kfa.report'].search(
[('year', '=', year), ('company_id', '=', self.id)], limit=1)
if not report:
raise UserError(_('No tax add-back statement for %s yet.', year))
report.action_compute()
return {
'total': report.amount_total,
'e3_455': report.e3_amount,
'off_balance': report.e3_reconcile_amount,
'agrees': report.e3_matches,
'note': report.e3_status,
'documents_without_add_back': report.l10n_gr_pending_count,
}myDATA base data
depends: ['l10n_gr_nt_mydata_base']
The reference data and settings every myDATA document relies on: accounting categories for classification, AADE payment methods and units, withholding and fee rules, and company settings such as the retail cash limit. Install l10n_gr_nt_mydata_base (brought in by every invoicing module of the suite).
aade.accounting.category
_name = 'aade.accounting.category' (Accounting category); fields: name, company_id, product_type ('consu' | 'service'), preferred_classification_ids, preferred_expense_classification_idsAccounting category. Groups products for the myDATA classification: each category, per company, lists the income and expense classifications offered on the lines of its products. A product without a category falls back to the first category of its product type. The classification actually written on a line comes from the line's account; the category only decides which options are offered. Create categories that fit the customer's catalogue and assign them to products; their classifications are set by the accountant on the category.
Example
Products are grouped into accounting categories that match the customer's catalogue, so invoice lines offer the right classifications.
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: a furniture maker separates own production from traded goods so the right income classifications are offered
# __manifest__.py: 'post_init_hook': 'post_init_hook'
CATEGORIES = [
('Own production', 'consu', 'Furniture'),
('Traded goods', 'consu', 'Accessories'),
('Installation services', 'service', 'Services'),
]
def post_init_hook(env):
company = env.company
for name, ptype, product_categ in CATEGORIES:
category = env['aade.accounting.category'].search(
[('name', '=', name), ('company_id', '=', company.id)], limit=1)
if not category:
category = env['aade.accounting.category'].create(
{'name': name, 'product_type': ptype, 'company_id': company.id})
env['product.template'].search([('categ_id.name', '=', product_categ)]).write(
{'aade_accounting_category_id': category.id})
# the accountant sets each category's classifications from the screenaade.payment.method
_name = 'aade.payment.method' (myDATA Payment Method (8.12)); fields: code (Integer), nameAADE payment method. The AADE payment methods of table 8.12 (cash, bank, card, on credit and so on), shipped as data. Read and search by code; do not add or change records.
Example
A migration from a legacy ERP translates its payment codes into the AADE payment methods.
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: invoices migrated from the legacy ERP keep how they were paid, as AADE payment methods (table 8.12)
from odoo import api, models
LEGACY_TO_AADE = {
'ΜΕΤ': 3, # cash
'ΤΡΑ': 1, # domestic bank account
'ΚΑΡ': 7, # POS / e-POS
'ΕΠΙ': 4, # cheque
}
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def acme_aade_method_for(self, legacy_code):
code = LEGACY_TO_AADE.get((legacy_code or '').strip().upper(), 5) # unknown: on credit
method = self.env['aade.payment.method'].search([('code', '=', code)], limit=1)
return method # shipped data: read it, never create or change recordsaade.withholding.rule
_name = 'aade.withholding.rule' (Withholding, Fee or Contribution Rule); _inherit = ['l10n_gr.tax.group.owner']; fields: sequence, name, code, active, company_id, rate, base, mydata_slot, mydata_code, threshold_amount, cap_per_document, cap_per_month, effect, tax_id, tax_purchase_id, account_id, payable_account_id, l10n_gr_account_effective_id, legal_basisWithholding rule. A withholding, fee or contribution per company: its rate and base, the myDATA field and category it goes to, an optional threshold and caps per document and per month, and whether it is withheld at payment or only declared. A Greek company gets the standard rules when it is set up. The suite creates the matching Odoo taxes and tax group automatically (tax_id, tax_purchase_id), which users then pick on invoice lines. l10n_gr_account_effective_id shows the account the amount will be written to today. Create rules for the customer's cases from data or code; do not edit the generated taxes directly.
Example
rules = self.env['aade.withholding.rule'].search([('company_id', '=', self.env.company.id), ('active', '=', True)])
rates = {r.code: r.rate for r in rules}payment.method.aade_payment_method_id
fields.Many2one('aade.payment.method', string='AADE Payment Method')AADE method of an Odoo payment method. Links an Odoo payment method to the AADE payment method it is declared as. Set it on payment methods your module adds.
Example
A new e-shop payment method is mapped to the AADE payment method it must be declared as.
<!-- depends: ['l10n_gr_nt_mydata_base', 'payment'] -->
<!-- Customer need: the IRIS instant-payment method added for the e-shop is declared to AADE as IRIS (code 8) -->
<record id="payment_method_acme_iris" model="payment.method">
<field name="name">IRIS</field>
<field name="code">acme_iris</field>
<field name="image" type="base64" file="acme_custom/static/img/iris.png"/>
<field name="aade_payment_method_id" model="aade.payment.method" search="[('code', '=', 8)]"/>
</record>product.template.aade_accounting_category_id
fields.Many2one('aade.accounting.category', string='Accounting Category')Product accounting category. The product's accounting category, which decides the myDATA classifications offered on its invoice lines. Keep it consistent with the product's income account, since the account decides what is actually written; the product form warns when the two disagree. Set it when you import or create products.
Example
Products imported from the legacy ERP get their accounting category from the old product group.
# odoo-bin shell snippet, run through the console (staging first)
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: products migrated from the legacy ERP arrive with their myDATA accounting category
LEGACY_GROUP_TO_CATEGORY = {'ΕΜΠ': 'Traded goods', 'ΠΡΟ': 'Own production', 'ΥΠΗ': 'Installation services'}
ROWS = [('A-1001', 'ΕΜΠ'), ('P-2040', 'ΠΡΟ'), ('S-0003', 'ΥΠΗ')] # (internal ref, legacy group)
categories = {
c.name: c for c in env['aade.accounting.category'].search([('company_id', '=', env.company.id)])
}
for ref, group in ROWS:
product = env['product.template'].search([('default_code', '=', ref)], limit=1)
category = categories.get(LEGACY_GROUP_TO_CATEGORY.get(group))
if not (product and category):
print('skipped', ref, group)
continue
# keep it consistent with the income account: the account decides what is written
product.aade_accounting_category_id = category
env.cr.commit()product.template.l10n_gr_taric_no
fields.Char(string='TARIC Code', size=10)TARIC code. The TARIC code of the item (EU customs nomenclature), exactly 10 digits or empty; any other value raises a validation error. It is carried on the lines of delivery documents and is mostly needed for intra-community and customs movements.
Example
An importer of intra-community goods loads TARIC codes from supplier price lists in the 10-digit form the documents need.
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: supplier price lists print TARIC codes with dots and spaces; store them clean, report bad ones
import re
from odoo import api, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def acme_set_taric_codes(self, rows):
"""rows: iterable of (internal reference, TARIC as printed). Returns the refs not set."""
skipped = []
for ref, raw in rows:
taric = re.sub(r'\D', '', raw or '')
if len(taric) == 8:
taric += '00' # 8-digit CN code with no TARIC subdivision
product = self.search([('default_code', '=', ref)], limit=1)
if product and len(taric) == 10: # anything else fails the field's validation
product.l10n_gr_taric_no = taric
else:
skipped.append(ref)
return skippedproduct.template.peppol_cpv
fields.Char(string='PEPPOL CPV')CPV code. The CPV code of the item, used for documents to public bodies (B2G).
Example
A supplier to the public sector cannot confirm an order for a public body until every product has its CPV code.
# depends: ['l10n_gr_nt_mydata_base', 'sale']
# Customer need: orders for public bodies cannot be confirmed while a product lacks its CPV code
from odoo import _, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
public = self.env.ref('acme_custom.partner_tag_public_body', raise_if_not_found=False)
for order in self:
if not public or public not in order.partner_id.commercial_partner_id.category_id:
continue
missing = order.order_line.filtered(lambda l: l.product_id and not l.product_id.peppol_cpv)
if missing:
raise UserError(_('Add a CPV code to: %s', ', '.join(missing.product_id.mapped('display_name'))))
return super().action_confirm()res.company.l10n_gr_cash_limit_retail
fields.Monetary(string='Cash limit, retail', default=500.0, currency_field='currency_id')Retail cash limit. The amount from which a retail document (to a consumer, or to a contact without VAT number) must be paid electronically. The suite refuses a customer document whose total, VAT included, reaches the limit and that has any cash payment line: the limit is measured on the whole document, not on the cash part. 0 turns the check off. Read it in your own sales or POS flows to steer payment early.
Example
Sales staff are warned while quoting that a consumer order at or above the cash limit must be paid electronically.
# depends: ['l10n_gr_nt_mydata_base', 'sale']
# Customer need: sales staff learn at quotation time that a retail order must be paid electronically
from odoo import _, api, models
from odoo.tools import format_amount
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.onchange('order_line', 'partner_id')
def _onchange_acme_cash_limit(self):
company = self.company_id
limit = company.l10n_gr_cash_limit_retail # 0 means the check is off
retail = not self.partner_id.commercial_partner_id.vat
if not limit or not retail or self.currency_id != company.currency_id:
return
if self.amount_total >= limit: # measured on the whole document, VAT included
return {'warning': {
'title': _('Electronic payment required'),
'message': _('Retail orders of %s or more cannot be paid in cash, not even in part.',
format_amount(self.env, limit, company.currency_id)),
}}res.company.l10n_gr_provider_licence
fields.Char(string='ΥΠΑΗΕΣ licence', compute=..., store=False, readonly=True)Provider licence number. The software licence number of the certified e-invoicing provider the company issues through, as printed on documents. It follows the company's connection and is read-only. Read it only to print it in custom layouts.
Example
A custom document layout prints the licence number of the certified e-invoicing provider the company issues through.
<!-- depends: ['l10n_gr_nt_mydata_base'] -->
<!-- Customer need: the customer's own document layout must still print the provider's licence number -->
<template id="acme_external_layout_licence" inherit_id="web.external_layout_standard">
<xpath expr="//div[@t-field='company.report_footer']" position="after">
<div t-if="company.l10n_gr_provider_licence" class="small text-muted">
Αρ. άδειας ΥΠΑΗΕΣ: <span t-field="company.l10n_gr_provider_licence"/>
</div>
</xpath>
</template>res.company.retail_partner_id
fields.Many2one('res.partner', string='Retail Partner')Retail contact. The company's retail contact setting. A branch company copies it from its parent together with the other non-secret myDATA settings.
Example
Anonymous daily sales from an external till are booked as drafts on the company's retail contact.
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: daily anonymous sales from the shop's external till are booked on the company's retail contact
from odoo import Command, _, api, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def acme_create_retail_draft(self, lines, company=None):
"""lines: iterable of (product, quantity, unit price on the same tax basis as the product's taxes)."""
company = company or self.env.company
if not company.retail_partner_id:
raise UserError(_('Set the retail contact of %s first.', company.name))
return self.with_company(company).create({
'move_type': 'out_invoice',
'partner_id': company.retail_partner_id.id,
'invoice_line_ids': [
Command.create({'product_id': p.id, 'quantity': q, 'price_unit': price})
for p, q, price in lines
],
}) # stays draft: the accountant posts itres.partner.l10n_gr_self_invoicing_agreement
fields.Binary(string='Self-billing agreement', attachment=True, copy=False)Self-billing agreement. The signed agreement with the supplier that lets the company issue the supplier's documents itself. Without it the self-billing indication is not recorded. Your module can attach it from a document workflow.
Example
A company that self-bills its suppliers attaches each signed agreement from its document workflow and lists suppliers still without one.
# depends: ['l10n_gr_nt_mydata_base']
# Customer need: an olive-oil mill self-bills its farmers; the scanned agreement lands on the farmer
from odoo import _, api, models
from odoo.exceptions import UserError
class ResPartner(models.Model):
_inherit = 'res.partner'
def acme_set_self_billing_agreement(self, attachment):
self.ensure_one()
if attachment.mimetype != 'application/pdf':
raise UserError(_('The self-billing agreement must be a signed PDF.'))
supplier = self.commercial_partner_id
supplier.l10n_gr_self_invoicing_agreement = attachment.datas
supplier.message_post(body=_('Self-billing agreement attached.'))
@api.model
def acme_farmers_without_agreement(self):
tag = self.env.ref('acme_custom.partner_tag_farmer')
return self.search([
('category_id', 'in', tag.ids),
('l10n_gr_self_invoicing_agreement', '=', False),
])uom.uom.aade_unit_id
fields.Many2one('aade.unit.measure', string='AADE Category')AADE unit. Maps an Odoo unit of measure to the AADE quantity type (table 8.13). Set it on any unit your module adds that appears on invoice lines.
Example
A custom unit of measure used on invoices is mapped to the AADE quantity type it is declared with.
<!-- depends: ['l10n_gr_nt_mydata_base', 'uom'] -->
<!-- Customer need: a building-materials trader sells by the pallet of 40 pieces; the unit must map to an AADE quantity type (8.13) -->
<record id="uom_acme_pallet" model="uom.uom">
<field name="name">Pallet (40)</field>
<field name="relative_factor">40</field>
<field name="relative_uom_id" ref="uom.product_uom_unit"/>
<!-- 7: pieces, other cases -->
<field name="aade_unit_id" model="aade.unit.measure" search="[('code', '=', 7)]"/>
</record>l10n.gr — Expense Reports
depends: ['l10n_gr_nt_mydata_expense']
Classifies employee expenses for myDATA and sends them to AADE.
hr.expense.classification_category
fields.Selection (the AADE classification categories, e.g. category2_x for expenses), string 'myDATA Expense Category'myDATA expense category. The AADE classification category of the expense. In the expense form, the suite fills it from the expense account when the product, account, taxes or series change, and a manual choice stays until one of those changes again. That fill is a form onchange: it does not run when code creates or writes expenses. An expense cannot be sent to myDATA without it. Set it yourself when your module creates expenses (imports, card feeds) so they arrive classified, or use it to list expenses that still need a category before they are sent. Write only valid selection values. Precondition: l10n_gr_nt_mydata_expense installed (it brings hr_expense).
Example
Company-card transactions imported as expenses arrive with their myDATA expense category already set.
# depends: ['l10n_gr_nt_mydata_expense']
# Customer need: company-card transactions imported nightly arrive already classified for myDATA
from odoo import api, fields, models
# the form onchange does not run for code: set the category ourselves (merchant category code -> AADE)
MCC_TO_CATEGORY = {
'5541': 'category2_4', # fuel: general expenses with VAT deduction
'5812': 'category2_5', # restaurants: general expenses without VAT deduction
}
class HrExpense(models.Model):
_inherit = 'hr.expense'
@api.model
def acme_import_card_line(self, employee, product, txn):
return self.create({
'name': txn['merchant'],
'employee_id': employee.id,
'product_id': product.id,
'total_amount_currency': txn['amount'],
'date': fields.Date.to_date(txn['date']),
'payment_mode': 'company_account',
# empty: the user picks the category before the expense can be sent
'classification_category': MCC_TO_CATEGORY.get(txn['mcc'], False),
})myDATA invoicing
depends: ['l10n_gr_nt_mydata_invoice']
Adds to invoices and bills what Greek documents need: issue time, AADE payment lines, withholdings, document-level taxes, movement purpose for documents that serve as delivery notes, and line classification. Install l10n_gr_nt_mydata_invoice (requires l10n_gr_nt_mydata_base).
aade.invoice.tax.line
_name = 'aade.invoice.tax.line' (Tax / Withholding Applied to the Invoice); fields: invoice_id, tax_id, base_amount, amount, currency_id, mydata_typeDocument-level tax. A tax, fee or withholding applied to the whole document rather than to a line, chosen among taxes that have a myDATA type. Its base is the document's untaxed amount; its amount is the tax's fixed amount, or its rate applied to the lines' subtotal. It is shown on the document's tax tab (account.move.invoice_tax_ids). Read it in reports; add it through the document form.
Example
A custom invoice layout prints the fees, stamp duty or withholdings applied to the whole document.
<!-- depends: ['l10n_gr_nt_mydata_invoice'] -->
<!-- Customer need: the customer's invoice layout lists document-level fees and stamp duty under the totals -->
<template id="report_invoice_acme_document_taxes" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='total']" position="after">
<table t-if="o.invoice_tax_ids" class="table table-sm mt-2">
<thead>
<tr><th>Document tax</th><th class="text-end">Base</th><th class="text-end">Amount</th></tr>
</thead>
<tbody>
<tr t-foreach="o.invoice_tax_ids" t-as="tax_line">
<td t-field="tax_line.tax_id.name"/>
<td class="text-end" t-field="tax_line.base_amount"/>
<td class="text-end" t-field="tax_line.amount"/>
</tr>
</tbody>
</table>
</xpath>
</template>account.move.invoice_time
fields.Datetime(string='Invoice Time', copy=False)Issue time. The issue time of the document, a mandatory indication. It is filled once, when the posted document gets its myDATA record, and is never copied to reversals or duplicates. Stored in UTC; printed in the issuer's time zone. Read it for reports; do not write it.
Example
A retail business analyses its sales by hour of issue, in local time.
# depends: ['l10n_gr_nt_mydata_invoice']
# Customer need: the shop's BI tool needs sales per hour of issue, in Greek local time
import pytz
from odoo import api, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def acme_sales_per_hour(self, date_from, date_to):
tz = pytz.timezone('Europe/Athens') # the issuer's time zone; invoice_time is stored in UTC
moves = self.search([
('move_type', '=', 'out_invoice'), ('state', '=', 'posted'),
('invoice_time', '!=', False),
('invoice_date', '>=', date_from), ('invoice_date', '<=', date_to),
])
hours = {}
for move in moves:
hour = pytz.utc.localize(move.invoice_time).astimezone(tz).hour
hours[hour] = hours.get(hour, 0.0) + move.amount_total_signed
return dict(sorted(hours.items()))account.move.line.classification_category
fields.Selection(selection=CLASSIFICATION_CATEGORY_SELECTION, string='AADE Classification Category', readonly=False)Classification category. The AADE classification category of the line (for example category1_1 income from sale of goods, category1_3 income from services). It is normally proposed from the line's account; the choices offered come from the product's accounting category, and a line still empty at posting is filled from its account. Read it in reports, or set it on draft lines when your flow knows the right category.
Example
A scheduled action flags documents with unclassified lines in time for the accountant to fix them before the myDATA deadline.
# depends: ['l10n_gr_nt_mydata_invoice']
# Customer need: before the monthly myDATA deadline the accountant gets a to-do for every document with unclassified lines
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def _cron_acme_missing_classification(self):
summary = _('myDATA classification missing')
start = fields.Date.start_of(fields.Date.today() - relativedelta(months=1), 'month')
lines = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
('move_id.move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')),
('display_type', '=', 'product'),
('date', '>=', start),
('classification_category', '=', False),
])
for move in lines.move_id:
if move.activity_ids.filtered(lambda a: a.summary == summary):
continue
move.activity_schedule('mail.mail_activity_data_todo', summary=summary,
user_id=move.invoice_user_id.id or self.env.user.id)account.move.move_purpose_id
fields.Many2one('aade.move.purpose', string='Movement Purpose', compute='_compute_shipping_info', store=True, readonly=False)Movement purpose. Why the goods move (AADE table 8.14), for documents that also serve as delivery notes. The suite proposes it from the document and only offers purposes that fit the direction; the user may change it. Your module can set it on the draft before posting.
Example
Invoices that also serve as delivery notes for sample orders carry the sampling purpose instead of sale.
# depends: ['l10n_gr_nt_mydata_invoice', 'sale']
# Customer need: invoices for orders tagged 'Samples' move the goods with purpose 3 (sampling), not 1 (sale)
from odoo import models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _create_invoices(self, grouped=False, final=False, date=None):
moves = super()._create_invoices(grouped=grouped, final=final, date=date)
tag = self.env.ref('acme_custom.sale_tag_samples', raise_if_not_found=False)
sampling = self.env['aade.move.purpose'].search([('code', '=', 3)], limit=1)
if not (tag and sampling):
return moves
for move in moves.filtered(lambda m: m.state == 'draft'):
if tag in move.line_ids.sale_line_ids.order_id.tag_ids:
move.move_purpose_id = sampling # draft value: the user can still change it
return movesaccount.move.payment_line_ids
fields.One2many('account.move.payment.line', 'move_id', string='Payment Lines', copy=True)Payment lines. How the document is paid, as AADE payment methods with amounts (each line has an aade.payment.method and an amount). They are a declaration only and write nothing to the books; the actual payment is still registered with Odoo's Pay button. An empty tab means on credit for the whole amount. Your sales or e-shop flow can fill them on the draft before posting; cash lines on retail documents are checked against the company's retail cash limit.
Example
Invoices for e-shop orders are filled with the AADE payment method the customer actually used online.
# depends: ['l10n_gr_nt_mydata_invoice', 'sale']
# Customer need: e-shop invoices declare how the customer actually paid instead of 'on credit'
from odoo import Command, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _create_invoices(self, grouped=False, final=False, date=None):
moves = super()._create_invoices(grouped=grouped, final=final, date=date)
for move in moves.filtered(lambda m: m.state == 'draft' and not m.payment_line_ids):
orders = move.line_ids.sale_line_ids.order_id
tx = orders.transaction_ids.filtered(lambda t: t.state == 'done')[:1]
aade_method = tx.payment_method_id.aade_payment_method_id
if aade_method:
# a declaration only: the payment itself is still registered with Pay
move.payment_line_ids = [Command.create({
'aade_payment_method_id': aade_method.id,
'amount': move.amount_total,
})]
return movesaccount.move.received_from_mydata
fields.Boolean(string='Received from myDATA', default=False)Received from myDATA. True on vendor bills created from documents received through myDATA. Use it to filter or to branch your own logic for such bills; the suite sets it, so do not write it.
Example
Vendor bills received through myDATA that match no purchase order are sent to the buyer for review.
# depends: ['l10n_gr_nt_mydata_invoice', 'purchase']
# Customer need: bills that arrive through myDATA without a purchase order go to the buyer for review
from odoo import _, api, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def _cron_acme_review_received_bills(self):
bills = self.search([
('received_from_mydata', '=', True), # set by the suite, never written here
('move_type', '=', 'in_invoice'),
('state', '=', 'draft'),
'!', ('invoice_line_ids.purchase_line_id', '!=', False), # no line linked to a PO
('activity_ids', '=', False),
])
for bill in bills:
bill.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Bill from myDATA without purchase order'),
user_id=bill.invoice_user_id.id or self.env.user.id,
)account.move.withholding_line_ids
fields.One2many('l10n_gr.withholding.line', 'move_id', string='Withholdings', copy=False)Withholdings. What was withheld from the fee, one line per aade.withholding.rule with its base, amount and whether a cap applied. The user picks which rules apply and the suite works out the amounts. Read them for statements or payment advice; not copied to duplicates.
Example
At year end, each freelancer receives a statement of fees and tax withheld, per withholding rule.
# depends: ['l10n_gr_nt_mydata_invoice']
# Customer need: at year end each freelancer gets a statement of the tax withheld from their fees
from odoo import models
class ResPartner(models.Model):
_inherit = 'res.partner'
def acme_withholding_statement(self, year):
self.ensure_one()
bills = self.env['account.move'].search([
('commercial_partner_id', '=', self.commercial_partner_id.id),
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('date', '>=', f'{year}-01-01'), ('date', '<=', f'{year}-12-31'),
])
totals = {}
for line in bills.withholding_line_ids:
row = totals.setdefault(line.rule_id.name, {'base': 0.0, 'amount': 0.0})
row['base'] += line.base_amount
row['amount'] += line.amount
return totalsaccount.move.withholding_total
fields.Monetary(string='Total Withheld', compute='_compute_withholding_total', store=True, currency_field='currency_id')Total withheld. Everything withheld on the document, whichever way it came: the withholding lines plus the withholding taxes on the invoice lines. Stored, so you can sum and group it in reports.
Example
The accountant sees per month how much tax the company withheld from suppliers and how much its customers withheld from it.
# depends: ['l10n_gr_nt_mydata_invoice']
# Customer need: the accountant plans the monthly payment of withheld tax (bills) and tracks what customers withheld (invoices)
from odoo import api, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def acme_withheld_per_month(self, year):
groups = self._read_group(
[('move_type', 'in', ('in_invoice', 'out_invoice')),
('state', '=', 'posted'),
('date', '>=', f'{year}-01-01'), ('date', '<=', f'{year}-12-31'),
('withholding_total', '!=', 0)],
groupby=['move_type', 'date:month'],
aggregates=['withholding_total:sum'],
)
# in_invoice: withheld by us, to pay; out_invoice: withheld by customers
return [(move_type, month.strftime('%m/%Y'), total) for move_type, month, total in groups]l10n.gr — Point of Sale: Transmission
depends: ['l10n_gr_nt_mydata_pos']
Connects Odoo Point of Sale with myDATA: every receipt becomes a Greek fiscal document.
pos.order.l10n_gr_document_type_name
fields.Char, related to the AADE document type name of the order's accounting document series; not storedKind of document. The name of the Greek document type of the order, for example a retail receipt or an invoice, taken from the series of the accounting document behind the order. It is empty until the order has that document. Use it to show the kind of document on your own receipts, reports or exports, or to split orders by document kind in a session summary. It is a related field and not stored: read it on records, or search on it, but it cannot be grouped on in read_group or in list views; group in Python instead. Precondition: l10n_gr_nt_mydata_pos installed.
Example
The shop owner wants the closing note of every Point of Sale session to show how many receipts and how many invoices were issued, with their totals.
# Customer need: the shop owner wants each session's closing note to split receipts from invoices.
# depends: ['point_of_sale', 'l10n_gr_nt_mydata_pos']
# The suite field is reached through the ORM; never import from odoo.addons.l10n_gr_*.
from markupsafe import Markup
from odoo import _, models
class PosSession(models.Model):
_inherit = 'pos.session'
def _acme_totals_by_document_kind(self):
self.ensure_one()
orders = self.order_ids.filtered(lambda o: o.state != 'cancel')
# Related and not stored: group in Python, read_group cannot use it.
return [
(kind or _('No fiscal document yet'), len(group), sum(group.mapped('amount_total')))
for kind, group in orders.grouped('l10n_gr_document_type_name').items()
]
def action_pos_session_closing_control(self, *args, **kwargs):
res = super().action_pos_session_closing_control(*args, **kwargs)
# A cash difference can return a wizard instead of closing: note only closed sessions.
for session in self.filtered(lambda s: s.state == 'closed'):
rows = session._acme_totals_by_document_kind()
session.message_post(body=Markup('<br/>').join('%s: %s / %.2f' % row for row in rows))
return resl10n.gr — Received Documents
depends: ['l10n_gr_nt_mydata_received']
Brings in from myDATA the documents other businesses issued to the company's VAT number, keeps each one as received, links it to a vendor bill and tracks the expense classification deadline.
account.move.l10n_gr_classification_deadline
fields.Date, computed, stored (depends on invoice_date, received_from_mydata, company_id); empty when the bill did not come from myDATA or has no invoice dateClassification deadline. For a vendor bill that came in through myDATA, the last day by which its expense classification should be sent: the last day of the month after the invoice month. The law ties the deadline to the VAT return of the period (monthly for double-entry books, quarterly for single-entry); the field applies the monthly rule because it is the stricter one. Use it to sort, filter or remind: dashboards of bills close to their deadline, scheduled activities, mail digests. It is stored, so it can be used in domains and group-bys. Read it, do not write it. Precondition: l10n_gr_nt_mydata_received installed.
Example
The accountant gets a reminder on every vendor bill from myDATA whose expense classification is still pending five days before the deadline.
# Customer need: the accountant gets a to-do on every myDATA bill not yet classified, 5 days before its deadline.
# depends: ['l10n_gr_nt_mydata_received']
# Run it from an ir.cron on model account.move, code: model._cron_acme_classification_reminder()
from odoo import _, api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def _cron_acme_classification_reminder(self):
today = fields.Date.context_today(self)
docs = self.env['l10n_gr.mydata.received.doc'].search([
('state', '=', 'matched'), # linked to a bill, not classified yet
('move_id.l10n_gr_classification_deadline', '>=', today),
('move_id.l10n_gr_classification_deadline', '<=', fields.Date.add(today, days=5)),
])
for bill in docs.move_id.filtered(lambda m: not m.activity_ids):
bill.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=bill.l10n_gr_classification_deadline,
summary=_('Expense classification due'),
user_id=bill.invoice_user_id.id or self.env.user.id,
)l10n_gr.mydata.received.doc
models.Model, _inherit mail.thread + mail.activity.mixin; key fields: company_id (Many2one res.company), state (Selection: new, matched, classified, ignored, cancelled; tracked), move_id (Many2one account.move, tracked), partner_id (Many2one res.partner), issue_date (Date), issuer_vat, issuer_name, series, aa (Char), total_net, total_vat, total_gross (Monetary), line_ids (One2many l10n_gr.mydata.received.line); one record per AADE registration per company (DB constraint)Received document. One record per document a third party issued to the company and registered with AADE. It is a statement by the issuer, not an accounting entry of yours: it may match a vendor bill you already have, match nothing, or be cancelled later by the issuer. The vendor bill link (move_id) and the state record the accountant's decision, and both are tracked in the chatter. Use it to build reports, reminders or automation on top of what suppliers declared, for example open documents with no bill, or documents per supplier. Records are created by the suite when it fetches from myDATA; do not create them yourself, and treat the registration identifiers and the raw document as read-only evidence. Precondition: l10n_gr_nt_mydata_received installed. Access: Invoicing users read, create and write; Accounting managers can also delete. Your module adds fields or methods with _inherit = 'l10n_gr.mydata.received.doc'; if you override write, call super() first, because the suite re-evaluates the classification mode of the linked bill there.
Example
The finance manager wants every supplier document marked as ignored to carry a written reason, so nothing declared by a supplier is dropped silently.
# Customer need: the finance manager wants a written reason whenever a supplier document is ignored instead of billed.
# depends: ['l10n_gr_nt_mydata_received']
# The model is extended through _inherit; never import from odoo.addons.l10n_gr_*.
from odoo import _, fields, models
from odoo.exceptions import UserError
class ReceivedDoc(models.Model):
_inherit = 'l10n_gr.mydata.received.doc'
x_ignore_reason = fields.Char(string='Reason ignored', tracking=True)
def write(self, vals):
# super() first: the suite re-evaluates the linked bill's classification mode there.
res = super().write(vals)
if vals.get('state') == 'ignored' and self.filtered(lambda d: not d.x_ignore_reason):
# Raising rolls back the whole write, the state included.
raise UserError(_('Write why this document needs no vendor bill before ignoring it.'))
return res
# Add x_ignore_reason to the form with an xpath on the suite's form view.l10n_gr.mydata.received.line
models.Model; key fields: doc_id (Many2one l10n_gr.mydata.received.doc, required, cascade), company_id (related, stored), line_number (Integer), rec_type (Integer, AADE line type), item_description, item_code (Char, often empty on invoices), quantity (Float), net_value, vat_amount, other_taxes_amount, stamp_duty_amount, fees_amount (Monetary), vat_category, vat_exemption_category (Char), issuer_income_category, issuer_income_type (Char, what the issuer declared)Received document line. The lines of a received document, as the issuer sent them. Invoices usually carry amounts only, with no description or item code, so issuer_income_category and issuer_income_type (the income classification the issuer declared) are the best hint of what was bought. A line with rec_type 6 is a gift voucher and is not classified by the recipient. Use it for analyses by VAT category or by the issuer's classification, or to drive your own suggestions for the expense side. Lines are deleted with their document. Precondition and access: as for l10n_gr.mydata.received.doc.
Example
Before closing the month, the accountant sees the net and VAT amounts that suppliers declared on documents not yet entered as bills, grouped by the supplier's income category and VAT category.
# Customer need: at month end the accountant sees what still-unbilled supplier documents declare, by income and VAT category.
# depends: ['l10n_gr_nt_mydata_received']
from odoo import api, models
class ReceivedLine(models.Model):
_inherit = 'l10n_gr.mydata.received.line'
@api.model
def _acme_unbilled_summary(self, date_from, date_to):
groups = self._read_group(
[
('company_id', '=', self.env.company.id),
('doc_id.state', '=', 'new'),
('doc_id.issue_date', '>=', date_from),
('doc_id.issue_date', '<=', date_to),
('rec_type', '!=', 6), # gift vouchers are not classified by the recipient
],
['issuer_income_category', 'vat_category'],
['net_value:sum', 'vat_amount:sum'],
)
return [
{'income_category': cat or '-', 'vat_category': vat or '-', 'net': net, 'vat': tax}
for cat, vat, net, tax in groups
]Greek partner and company data
depends: ['l10n_gr_nt_partner']
Tax office register, legal forms, legal name, Commercial Registry number and VIES status on contacts and companies, plus helpers that return what the Greek documents must print. Install l10n_gr_nt_partner (requires l10n_eu_account_vies).
l10n_gr.doy
_name = 'l10n_gr.doy' (Tax Service); fields: code, name, service_type ('doy' | 'yfe' | 'kefode' | 'kebeis'), doy_class, source_date, active, superseded_by_idTax office. The register of tax offices and other tax services, as published by AADE. An office that was abolished or merged is archived, never deleted, and points to its successor through superseded_by_id, because older documents still refer to it. Read it and search it, including archived records when you deal with history; do not create offices from free text.
Example
After a merger of tax offices, the back office gets a to-do for each customer still filed under an abolished office, to confirm the new office with the customer.
# Customer need: after an AADE merger, the back office reviews the customers still filed under an abolished tax office.
# depends: ['l10n_gr_nt_partner']
# Offices are read through the ORM (self.env['l10n_gr.doy']); never import from odoo.addons.l10n_gr_*.
from odoo import _, api, models
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _acme_review_abolished_offices(self):
abolished = self.env['l10n_gr.doy'].with_context(active_test=False).search([
('active', '=', False),
('superseded_by_id', '!=', False),
])
partners = self.search([('l10n_gr_doy_id', 'in', abolished.ids), ('is_company', '=', True)])
for partner in partners:
office = partner.l10n_gr_doy_id
# Do not rewrite the office here: confirm with the customer, then update the contact.
partner.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Tax office %(old)s was merged into %(new)s',
old=office.name,
new=office.l10n_gr_current_service().name), # follows repeated mergers
)
return partnersl10n_gr.doy._l10n_gr_find_by_name
@api.model _l10n_gr_find_by_name(self, text) -> l10n_gr.doy (possibly empty)Find an office by name. Returns the office whose name matches a free-text name, ignoring case, dots, apostrophes and extra spaces, or an empty recordset. Archived offices count, because the text may predate a merger. It never creates a record. Use it in imports and integrations that receive the office as text, and leave the field empty when nothing is found.
Example
During the migration from the legacy ERP, customers are imported with their tax office matched from free text, and the names that match no office are listed for manual review.
# Customer need: load the customers exported from the legacy ERP, where the tax office is free text such as "Δ.Ο.Υ. Α' ΑΘΗΝΩΝ".
# depends: ['l10n_gr_nt_partner']
# Reached through the ORM (self.env['l10n_gr.doy']); never import from odoo.addons.l10n_gr_*.
from odoo import api, models
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _acme_import_legacy_customers(self, rows):
"""rows: dicts with afm, name, doy. Returns the office names that were not found."""
Doy = self.env['l10n_gr.doy']
greece = self.env.ref('base.gr')
unmatched = set()
for row in rows:
doy = Doy._l10n_gr_find_by_name(row['doy']) if row.get('doy') else Doy
if row.get('doy') and not doy:
unmatched.add(row['doy']) # leave it empty, never create an office from text
self.create({
'name': row['name'],
'is_company': True,
'country_id': greece.id,
'vat': 'EL%s' % row['afm'],
'l10n_gr_doy_id': doy.id,
})
return sorted(unmatched)l10n_gr.doy.code
fields.Char(string='Code', index=True)TAXIS code. The four-digit TAXIS code of the office; only tax offices proper have one, and each code belongs to one office. It stays empty until it is confirmed, from an official text or from the code AADE's registry returns for a VAT number, since a wrong code would be printed on documents; an office without a code still works everywhere else. Search by it when your data carries the code, and do not fill missing codes yourself.
Example
An EDI integration with a retail chain receives customer master data with the tax office's TAXIS code, and falls back to the office name when the code is missing.
# Customer need: the retail chain's EDI customer master sends the TAXIS code of the tax office, sometimes only its name.
# depends: ['l10n_gr_nt_partner']
from odoo import api, models
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _acme_doy_from_edi(self, code, name):
# Archived offices count: the chain's master data may predate a merger.
Doy = self.env['l10n_gr.doy'].with_context(active_test=False)
code = str(code or '').strip() # the code is a Char; EDI may send it as a number
doy = Doy.search([('code', '=', code)], limit=1) if code else Doy
if not doy and name:
doy = Doy._l10n_gr_find_by_name(name)
return doy
def _acme_apply_edi_master(self, payload):
for partner in self:
doy = self._acme_doy_from_edi(payload.get('TaxOfficeCode'), payload.get('TaxOffice'))
if doy:
# The office belongs to the business: write it on the commercial entity.
partner.commercial_partner_id.l10n_gr_doy_id = doyl10n_gr.doy.l10n_gr_current_service
l10n_gr_current_service(self) -> l10n_gr.doyOffice serving today. Follows the chain of mergers and returns the office that serves the taxpayer today; an office still in force returns itself. One record at a time. Use it when the question is where to go now (correspondence, filings). Keep the stored office as it is on documents: a 2024 invoice must still show the office that was in force then.
Example
The accountant sees on the contact both the tax office printed on its documents and the office that serves it today, so correspondence goes to the right office.
# Customer need: next to the stored tax office, the accountant sees the office that serves the customer today, for letters and filings.
# depends: ['l10n_gr_nt_partner']
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
x_doy_today_id = fields.Many2one(
'l10n_gr.doy',
string='Tax office serving today',
compute='_compute_x_doy_today_id',
)
@api.depends('l10n_gr_doy_id', 'l10n_gr_doy_id.superseded_by_id')
def _compute_x_doy_today_id(self):
for partner in self:
office = partner.l10n_gr_doy_id
# One record at a time; the stored office stays as printed on past documents.
partner.x_doy_today_id = office.l10n_gr_current_service() if office else Falsel10n_gr.legal.form
_name = 'l10n_gr.legal.form' (Legal Form); fields: code, name, sequence, active, legal_basis, require_gemi, require_legal_form_label, capital_mode, require_guarantee_contributions, require_liquidation_note, require_website, require_exact_address, l10n_gr_verifiedLegal form. The Greek legal forms (AE, EPE, IKE, OE, EE, sole trader and other) and, for each, which indications the law requires on documents: Commercial Registry number, legal form label, capital, guarantee contributions, liquidation notice, website, exact address. l10n_gr_verified says whether an accountant has confirmed the list. Read it to decide what your own layouts print.
Example
A custom quotation layout prints the company's legal form, ΓΕΜΗ number and website only where its legal form requires them.
<!-- Customer need: the customer's own quotation layout prints the ΓΕΜΗ number and the legal form only when the company's legal form requires them. -->
<!-- depends: ['sale', 'l10n_gr_nt_partner'] -->
<template id="acme_quotation_company_indications" inherit_id="sale.report_saleorder_document">
<xpath expr="//div[hasclass('page')]" position="inside">
<t t-set="acme_company" t-value="doc.company_id.partner_id"/>
<t t-set="acme_form" t-value="acme_company.l10n_gr_legal_form_id"/>
<div class="small text-muted mt-4">
<span t-out="acme_company.l10n_gr_display_name()"/>
<t t-if="acme_form.require_legal_form_label"> · <span t-out="acme_form.name"/></t>
<t t-if="acme_form.require_gemi and acme_company.l10n_gr_gemi">
· ΓΕΜΗ <span t-out="acme_company.l10n_gr_gemi"/>
</t>
<t t-if="acme_form.require_website and acme_company.website">
· <span t-out="acme_company.website"/>
</t>
</div>
</xpath>
</template>res.company.l10n_gr_aade_vat
l10n_gr_aade_vat(self) -> strCompany VAT without prefix. The issuing company's VAT number without the country prefix, taken from its contact by the same rule as res.partner.l10n_gr_aade_vat().
Example
The order confirmation sent by EDI to a supermarket chain carries the seller's and the buyer's VAT numbers in the plain form the chain expects.
# Customer need: the supermarket chain's EDI order response must carry both VAT numbers without the EL prefix.
# depends: ['sale', 'l10n_gr_nt_partner']
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _acme_edi_order_response(self):
self.ensure_one()
return {
'SenderVat': self.company_id.l10n_gr_aade_vat(),
'ReceiverVat': self.partner_id.commercial_partner_id.l10n_gr_aade_vat(),
'OrderRef': self.client_order_ref or self.name,
'ResponseDate': fields.Date.to_string(fields.Date.context_today(self)),
'Lines': [
{'Ean': line.product_id.barcode, 'Qty': line.product_uom_qty}
for line in self.order_line.filtered(lambda l: not l.display_type)
],
}res.company.l10n_gr_doy_id
fields.Many2one('l10n_gr.doy', string='Tax Office', related='partner_id.l10n_gr_doy_id', readonly=False)Company's tax office. The issuing company's tax office as printed on its documents. It is stored on the company's contact, so writing either one updates both.
Example
The customer statement of account, a custom report, shows the company's VAT number and tax office exactly as its invoices do.
<!-- Customer need: the customer's statement of account (a custom report) prints the company's ΑΦΜ and ΔΟΥ as the invoices do. -->
<!-- depends: ['account', 'l10n_gr_nt_partner'] -->
<!-- t-call it from the statement template, where o is the customer record. -->
<template id="acme_statement_company_block">
<t t-set="acme_company" t-value="o.company_id or res_company"/>
<div class="small mb-3">
<strong t-out="acme_company.partner_id.l10n_gr_display_name()"/><br/>
<span t-out="acme_company.partner_id.l10n_gr_full_address()"/><br/>
ΑΦΜ <span t-out="acme_company.l10n_gr_aade_vat()"/>
<t t-if="acme_company.l10n_gr_doy_id">
· ΔΟΥ <span t-out="acme_company.l10n_gr_doy_id.name"/>
</t>
</div>
</template>res.company.l10n_gr_missing_indications
l10n_gr_missing_indications(self) -> list[tuple[str, str]]Missing document indications. Which mandatory indications are missing from the issuing company's documents, as (indication, legal basis) pairs. An empty list means complete, and it is always empty for a company whose fiscal country is not Greece. A missing legal form is itself reported, since without it nothing can be judged. One company at a time. Use it in your onboarding checks or setup wizards.
Example
Before the first invoice is issued, the consultant checks with one button which indications required by law are still missing from the company's documents.
# Customer need: before go-live, a button in the company form tells the consultant which mandatory indications are still missing.
# depends: ['l10n_gr_nt_partner']
from odoo import _, models
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
def action_acme_go_live_check(self):
problems = []
for company in self:
problems += [
'%s: %s (%s)' % (company.name, indication, basis)
for indication, basis in company.l10n_gr_missing_indications()
]
if problems:
raise UserError(_('Complete these before the first invoice:\n%s', '\n'.join(problems)))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {'message': _('All mandatory indications are set.'), 'type': 'success'},
}res.company.l10n_gr_print_capital
l10n_gr_print_capital(self) -> list[tuple[str, float]]Capital lines to print. What the documents print for the company's capital, as (label, amount) pairs. An empty list means no capital line. For a form that needs both subscribed and paid-up capital, such as an AE, the list is empty on purpose when only one of the two amounts is set: the law wants both or none. One company at a time. Use it in custom layouts instead of reading the amounts yourself.
Example
A custom document footer prints the company's capital only when, and as, its legal form requires, for example both subscribed and paid-up capital for an ΑΕ.
<!-- Customer need: the customer's custom document footer prints the share capital only in the form the law accepts for its legal form. -->
<!-- depends: ['web', 'l10n_gr_nt_partner'] -->
<!-- t-call it from your own external layout, where company is set. -->
<template id="acme_footer_capital">
<t t-set="acme_capital" t-value="company.l10n_gr_print_capital()"/>
<div t-if="acme_capital" class="small">
<t t-foreach="acme_capital" t-as="cap">
<span t-out="cap[0]"/>:
<span t-out="cap[1]"
t-options="{'widget': 'monetary', 'display_currency': company.currency_id}"/>
<t t-if="not cap_last"> · </t>
</t>
</div>
</template>res.partner.action_l10n_gr_check_vies
action_l10n_gr_check_vies(self) -> TrueCheck VIES again. Asks VIES again, now, for the selected contacts. Odoo checks VIES only when the VAT number changes, so a contact can stay unconfirmed if VIES was down at that moment. Requires VAT verification to be enabled in Accounting settings; it raises a UserError when it is not, or when a selected contact has no VAT number.
Example
Every night, EU business customers whose VAT number VIES could not confirm when they were created are checked again, so intra-community invoices are not held up.
# Customer need: EU customers that VIES could not confirm on the day they were created are checked again every night.
# depends: ['l10n_gr_nt_partner']
# Run it from an ir.cron on model res.partner, code: model._cron_acme_recheck_vies()
import logging
from odoo import api, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _cron_acme_recheck_vies(self, limit=50):
eu = self.env.ref('base.europe').country_ids.filtered(lambda c: c.code != 'GR')
candidates = self.search([('is_company', '=', True), ('vat', '!=', False), ('country_id', 'in', eu.ids)])
# l10n_gr_vies_state is computed, not stored: filter in Python.
todo = candidates.filtered(lambda p: p.l10n_gr_vies_state == 'unconfirmed')[:limit]
try:
todo.action_l10n_gr_check_vies()
except UserError as error: # VAT verification disabled in Accounting settings
_logger.warning('VIES re-check skipped: %s', error)res.partner.l10n_gr_aade_vat
l10n_gr_aade_vat(self) -> strVAT number without prefix. The contact's VAT number without the country prefix, by the same rule the suite uses everywhere, so the number you print is the one the documents carry. One record at a time.
Example
The bulk supplier payment file for the bank lists each supplier's VAT number in the plain form the bank requires.
# Customer need: the bank's bulk-payment file needs each supplier's ΑΦΜ without the EL prefix.
# depends: ['account', 'l10n_gr_nt_partner']
import csv
import io
from odoo import models
class AccountPayment(models.Model):
_inherit = 'account.payment'
def _acme_bank_file(self):
out = io.StringIO()
writer = csv.writer(out, delimiter=';')
for pay in self.filtered(lambda p: p.payment_type == 'outbound'):
supplier = pay.partner_id.commercial_partner_id
writer.writerow([
supplier.l10n_gr_aade_vat(),
supplier.l10n_gr_display_name()[:70],
pay.partner_bank_id.acc_number or '',
'%.2f' % pay.amount,
pay.memo or pay.name,
])
return out.getvalue()res.partner.l10n_gr_display_name
l10n_gr_display_name(self) -> strName to print. The name a document prints for this contact: the legal name when set, otherwise the contact's name. One record at a time. Use it in your own reports so they match the suite's documents.
Example
The courier label printed from each delivery shows the customer's registered name, the same name its invoices carry.
<!-- Customer need: the courier label printed from each delivery shows the customer's registered name, not the trade name. -->
<!-- depends: ['stock', 'l10n_gr_nt_partner'] -->
<record id="acme_report_courier_label" model="ir.actions.report">
<field name="name">Courier label</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">acme_custom.courier_label</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
</record>
<template id="courier_label">
<t t-call="web.basic_layout">
<div t-foreach="docs" t-as="o" class="page">
<t t-set="customer" t-value="o.partner_id.commercial_partner_id"/>
<h3 t-out="customer.l10n_gr_display_name()"/>
<div t-out="o.partner_id.l10n_gr_full_address()"/>
<div>ΑΦΜ <span t-out="customer.l10n_gr_aade_vat()"/></div>
<div t-out="o.name"/>
</div>
</t>
</template>res.partner.l10n_gr_doy_id
fields.Many2one('l10n_gr.doy', string='Tax Office', ondelete='restrict', index=True)Contact's tax office. The tax office the contact belongs to; it is printed on documents. It belongs to the business, not to an address: it is kept on the commercial entity and its delivery and invoice addresses follow it, so write it on the company contact. Write it from your imports or CRM sync. An office in use cannot be deleted.
Example
A sales order for a Greek business cannot be confirmed until the customer's tax office is filled in, so the invoice never goes out without it.
# Customer need: a sale order for a Greek business is not confirmed until its tax office is set, because the invoice must print it.
# depends: ['sale', 'l10n_gr_nt_partner']
from odoo import _, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
for order in self:
# The office belongs to the business, so read it on the commercial entity.
customer = order.partner_invoice_id.commercial_partner_id
if (customer.is_company and customer.country_id.code == 'GR'
and customer.vat and not customer.l10n_gr_doy_id):
raise UserError(_(
'Set the tax office of %s before confirming: the invoice must print it.',
customer.display_name,
))
return super().action_confirm()res.partner.l10n_gr_full_address
l10n_gr_full_address(self) -> strAddress on one line. The registered address on one line: street with number, postcode, city. One record at a time. Use it where an address must read as a single indication, which Odoo's multi-line contact widget does not give.
Example
The footer of the customer's online shop shows the company's registered name, address on one line, VAT number, tax office and ΓΕΜΗ number.
<!-- Customer need: the e-shop footer shows the seller's legal identity, with the registered address on one line. -->
<!-- depends: ['website', 'l10n_gr_nt_partner'] -->
<template id="acme_website_legal_footer" inherit_id="website.layout">
<xpath expr="//footer" position="inside">
<t t-set="acme_seller" t-value="res_company.partner_id"/>
<div class="container small text-center py-2">
<span t-out="acme_seller.l10n_gr_display_name()"/> ·
<span t-out="acme_seller.l10n_gr_full_address()"/> ·
ΑΦΜ <span t-out="res_company.l10n_gr_aade_vat()"/>
<t t-if="res_company.l10n_gr_doy_id">
· ΔΟΥ <span t-out="res_company.l10n_gr_doy_id.name"/>
</t>
<t t-if="acme_seller.l10n_gr_gemi">
· ΓΕΜΗ <span t-out="acme_seller.l10n_gr_gemi"/>
</t>
</div>
</xpath>
</template>res.partner.l10n_gr_full_street
l10n_gr_full_street(self) -> strStreet with number. The street together with its number, when the number is kept in a separate field; when the street already contains the number, it is not repeated. One record at a time.
Example
The integration with a courier sends each delivery's street with its number as one value, without repeating a number that is already in the street.
# Customer need: the courier's API wants street with number, postcode and city as separate values.
# depends: ['stock', 'l10n_gr_nt_partner']
from odoo import _, models
from odoo.exceptions import UserError
class StockPicking(models.Model):
_inherit = 'stock.picking'
def _acme_courier_payload(self):
self.ensure_one()
recipient = self.partner_id
if not (recipient.street and recipient.zip and recipient.city):
raise UserError(_('The delivery address of %s is incomplete.', self.name))
return {
'name': recipient.commercial_partner_id.l10n_gr_display_name(),
'street': recipient.l10n_gr_full_street(), # the number is added once, never twice
'postcode': recipient.zip,
'city': recipient.city,
'phone': recipient.phone or '',
'reference': self.name,
}res.partner.l10n_gr_gemi
fields.Char(string='Commercial Registry Number')Commercial Registry number. The contact's Γ.Ε.ΜΗ. number: exactly twelve digits, and unique per business (a company and its branches share it). Writing a number of another length, or one that already belongs to another business, raises a validation error, so clean the value in your imports first. Whether it must be printed depends on the legal form.
Example
The CRM synchronisation stores each customer's ΓΕΜΗ number after cleaning it, and records a note on the contact when the number already belongs to another business.
# Customer need: the CRM sync writes the ΓΕΜΗ number it receives, cleaned, and reports duplicates instead of stopping the sync.
# depends: ['l10n_gr_nt_partner']
import re
from odoo import _, models
from odoo.exceptions import ValidationError
class ResPartner(models.Model):
_inherit = 'res.partner'
def _acme_sync_gemi(self, raw):
self.ensure_one()
digits = re.sub(r'\D', '', raw or '')
if len(digits) != 12:
return False
business = self.commercial_partner_id # a company and its branches share the number
try:
with self.env.cr.savepoint():
business.l10n_gr_gemi = digits
except ValidationError as error: # already used by another business
business.message_post(body=_('ΓΕΜΗ %(gemi)s not saved: %(error)s', gemi=digits, error=error))
return False
return Trueres.partner.l10n_gr_legal_form_id
fields.Many2one('l10n_gr.legal.form', string='Legal Form', ondelete='restrict')Contact's legal form. The contact's legal form, which decides which indications are printed on documents. On the company's own contact it drives the missing-indications check.
Example
During the migration from a legacy ERP, each customer's legal form, kept there as text, is set on the contact so documents print the right indications.
# Customer need: the legacy ERP stores the legal form as text (ΑΕ, Ε.Π.Ε., ΙΚΕ, ΟΕ, ΕΕ, Ατομική); the migration maps it to the Greek legal forms.
# depends: ['l10n_gr_nt_partner']
from odoo import api, models
# Keys are the legacy text without dots or spaces, in capitals.
LEGACY_FORMS = {
'ΑΕ': 'ae', 'ΕΠΕ': 'epe', 'ΙΚΕ': 'ike', 'ΟΕ': 'oe', 'ΕΕ': 'ee',
'ΑΤΟΜΙΚΗ': 'individual', 'ΑΤΟΜΙΚΉ': 'individual',
}
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _acme_migrate_legal_forms(self, rows):
"""rows: (afm, legacy legal form text) pairs from the legacy export."""
forms = {f.code: f for f in self.env['l10n_gr.legal.form'].search([])}
for afm, legacy in rows:
key = ''.join((legacy or '').replace('.', '').split()).upper()
code = LEGACY_FORMS.get(key)
partner = self.search([('vat', 'in', [afm, 'EL' + afm]), ('is_company', '=', True)], limit=1)
if partner and code in forms:
partner.l10n_gr_legal_form_id = forms[code]res.partner.l10n_gr_legal_name
fields.Char(string='Legal Name')Legal name. The name as registered in the Commercial Registry. Documents print it instead of the trade name; when it is empty, the contact's name is printed. Read it through l10n_gr_display_name() rather than directly, so your layouts follow the same rule.
Example
During the migration, each customer keeps its trade name as the contact name and its registered name for documents, as the legacy ERP held them.
# Customer need: the legacy ERP keeps «επωνυμία» and «διακριτικός τίτλος» apart; Odoo keeps the trade name as name, the registered name for documents.
# depends: ['l10n_gr_nt_partner']
from odoo import api, models
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _acme_migrate_names(self, rows):
"""rows: dicts with afm, eponymia, titlos from the legacy export."""
for row in rows:
afm = row['afm']
partner = self.search([('vat', 'in', [afm, 'EL' + afm]), ('is_company', '=', True)], limit=1)
if not partner:
continue
legal = (row.get('eponymia') or '').strip()
trade = (row.get('titlos') or '').strip()
partner.write({
'name': trade or legal or partner.name,
# Empty when it equals the trade name: documents then print the name.
'l10n_gr_legal_name': legal if legal and legal != trade else False,
})res.partner.l10n_gr_vies_state
fields.Selection([('no_vat', ...), ('domestic', ...), ('disabled', ...), ('valid', ...), ('unconfirmed', ...)], string='VIES status', compute='_compute_l10n_gr_vies_state') (not stored)VIES status. A plain reading of the contact's VIES situation: no VAT number, validated, Greek contact (VIES concerns intra-community trade only), VIES validation not enabled, or not validated. Computed, not stored: read it on records, for example before an intra-community invoice.
Example
An invoice without VAT to an EU business is posted only once VIES has validated the customer's VAT number, with one fresh check if it was not confirmed before.
# Customer need: an intra-community invoice without VAT is not posted for a customer that VIES has not validated.
# depends: ['account', 'l10n_gr_nt_partner']
from odoo import _, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
eu = self.env.ref('base.europe').country_ids
for move in self.filtered(lambda m: m.move_type == 'out_invoice'):
customer = move.commercial_partner_id
zero_vat = not move.invoice_line_ids.tax_ids.filtered(lambda t: t.amount)
if customer.country_id in eu and customer.country_id.code != 'GR' and zero_vat:
if customer.l10n_gr_vies_state == 'unconfirmed':
customer.action_l10n_gr_check_vies() # VIES may have been down that day
if customer.l10n_gr_vies_state != 'valid':
raise UserError(_(
'%s is not validated in VIES: an invoice without VAT needs a valid EU VAT number.',
customer.display_name,
))
return super().action_post()Greek Document Layouts
depends: ['l10n_gr_nt_report_layout']
Prints on documents the details Greek law requires, such as VAT number, tax office, GEMI number and bank accounts.
account.move._l10n_gr_document_title
_l10n_gr_document_title(self) -> str or False; single record (ensure_one)Printed document title. The title printed at the top of the invoice report, which by law must say what the document is, for example «Τιμολόγιο Πώλησης». It returns the AADE type name, with the delivery-note wording added for delivery-note series. It returns False, meaning keep Odoo's own title, when the move is not posted, the company's fiscal country is not Greece, or no AADE type is found. Override it to adapt the wording for a customer's layout; call super() and change only a non-empty result, so drafts and non-Greek companies keep Odoo's title. Other suite modules extend it too, so always go through super(). Precondition: l10n_gr_nt_report_layout installed.
Example
An exporting company prints the legal Greek title of each invoice with its English name next to it when the customer does not read Greek.
# Customer need: an exporter wants the printed title in Greek with the English name next to it for foreign customers.
# depends: ['l10n_gr_nt_report_layout']
# Extend through _inherit and super(); never import from odoo.addons.l10n_gr_*.
from odoo import models
# By AADE document type, so a retail receipt is never called an invoice.
ENGLISH_TITLES = {
'1.1': 'Sales Invoice', '1.2': 'Sales Invoice, Intra-Community Supply',
'1.3': 'Sales Invoice, Export', '2.1': 'Service Invoice',
'2.2': 'Service Invoice, Intra-Community', '2.3': 'Service Invoice, Third Country',
'5.1': 'Credit Note',
}
class AccountMove(models.Model):
_inherit = 'account.move'
def _l10n_gr_document_title(self):
title = super()._l10n_gr_document_title()
# False means keep Odoo's own title (drafts, non-Greek companies): leave it alone.
if not title:
return title
english = ENGLISH_TITLES.get(self._l10n_gr_invoice_type().code)
if english and not (self.partner_id.lang or '').startswith('el'):
return '%s / %s' % (title, english)
return titleaccount.move._l10n_gr_invoice_type
_l10n_gr_invoice_type(self) -> aade.invoice.type record (possibly empty) or False; single record (ensure_one)AADE document type of the move. Returns the AADE document type of one move: from its document series first, because that is what was registered, then the refund type of the journal for credit notes, then the type set on the journal. When none applies, for example a journal without Greek configuration, it returns an empty record or False, so test the result for truth rather than comparing it to a recordset. Use it in your own reports, checks or exports when you need to know what kind of Greek document a move is, instead of guessing from move_type. Call it on a single record. Precondition: l10n_gr_nt_report_layout installed.
Example
Staff cannot post a retail receipt for a business customer with a VAT number, so such customers always receive an invoice.
# Customer need: a retail receipt must not be posted for a business customer with a VAT number; staff must use an invoice series.
# depends: ['l10n_gr_nt_report_layout']
from odoo import _, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
for move in self.filtered(lambda m: m.move_type == 'out_invoice'):
kind = move._l10n_gr_invoice_type()
customer = move.commercial_partner_id
# Test for truth: the method returns an empty record or False when nothing applies.
if kind and kind.code.startswith('11.') and customer.is_company and customer.vat:
raise UserError(_(
'%(doc)s is a retail document (%(kind)s) but %(customer)s is a business: use an invoice series.',
doc=move.display_name,
kind=kind.display_name,
customer=customer.display_name,
))
return super().action_post()Self-supply and Own Use
depends: ['l10n_gr_nt_selfsupply']
Records the self-supply of goods and the own use of services, linking the stock move to the document.
account.move._l10n_gr_self_supply_source_location
_l10n_gr_self_supply_source_location(self) -> stock.location; single record (ensure_one)Where self-supplied goods leave from. Returns the stock location the goods of a self-supply document leave from. By default it is the stock location of the company's first warehouse. The suite calls it both for the stock check before posting and for creating the moves, so an override changes both consistently. Override it when the customer has several warehouses and the goods should leave from a specific one; return one stock.location of the move's company and fall back to super(). Returning an empty location stops posting with an error. Precondition: l10n_gr_nt_selfsupply installed, with the self-supply location set in the company settings.
Example
A company with a central warehouse and several shops makes goods taken for own use always leave from the warehouse chosen in its settings.
# Customer need: the company has a central warehouse and shops; goods for own use always leave from the warehouse set on the company.
# depends: ['stock', 'l10n_gr_nt_selfsupply']
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
x_self_supply_warehouse_id = fields.Many2one('stock.warehouse', string='Self-supply warehouse')
class AccountMove(models.Model):
_inherit = 'account.move'
def _l10n_gr_self_supply_source_location(self):
# The suite uses this for the stock check and for the moves, so both follow.
warehouse = self.company_id.x_self_supply_warehouse_id
if warehouse and warehouse.company_id == self.company_id:
return warehouse.lot_stock_id
return super()._l10n_gr_self_supply_source_location()account.move.l10n_gr_self_supply_entry_id
fields.Many2one('account.move'), copy=False, ondelete='set null'Settlement entry. The journal entry that clears the receivable a self-supply document has to carry in Odoo, so the company's own account shows no open balance. The suite posts and reconciles it when the document is posted. Use it to open or report the settlement next to the document. Read it only: the suite skips the settlement when the field is already set. Precondition: l10n_gr_nt_selfsupply installed, with the self-supply journal and expense account set in the company settings.
Example
The accountant is reminded of every posted self-supply document that has no settlement entry, so no open balance stays on the company's own account.
# Customer need: the accountant is reminded of posted self-supply documents left without a settlement entry, for example after it was deleted.
# depends: ['l10n_gr_nt_selfsupply']
# Run it from an ir.cron on model account.move, code: model._cron_acme_unsettled_self_supply()
from odoo import _, api, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def _cron_acme_unsettled_self_supply(self):
# Read only: the suite posts the settlement itself, never set the field from here.
unsettled = self.search([
('l10n_gr_self_supply_type', '!=', False),
('state', '=', 'posted'),
('l10n_gr_self_supply_entry_id', '=', False),
])
for doc in unsettled.filtered(lambda d: not d.activity_ids):
doc.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Self-supply without settlement entry'),
)account.move.l10n_gr_self_supply_move_ids
fields.One2many('stock.move', 'l10n_gr_self_supply_invoice_id')Stock moves of the self-supply. The stock moves a goods self-supply document created when it was posted, one per storable product line. Services and non-storable lines create none. Use it to answer which stock a document took out, for example in a report or a check. Read it; do not add moves yourself, because the suite creates moves only for a document that has none yet. Precondition: l10n_gr_nt_selfsupply installed.
Example
The warehouse prints, for each self-supply document, a sheet of the products, locations and quantities it took out of stock, for signature.
<!-- Customer need: the warehouse keeps a signed sheet of what each self-supply document took out of stock. -->
<!-- depends: ['stock', 'l10n_gr_nt_selfsupply'] -->
<!-- Bind it with an ir.actions.report on account.move (report_name acme_custom.self_supply_stock_sheet). -->
<template id="self_supply_stock_sheet">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h4><span t-out="o.name"/> · <span t-out="o.invoice_date"/></h4>
<table class="table table-sm">
<tr t-foreach="o.l10n_gr_self_supply_move_ids" t-as="m">
<td t-out="m.product_id.display_name"/>
<td t-out="m.location_id.display_name"/>
<td class="text-end"><span t-out="m.quantity"/> <span t-out="m.product_uom.name"/></td>
</tr>
</table>
<p t-if="not o.l10n_gr_self_supply_move_ids">No stock moves: services or non-storable products only.</p>
<p class="mt-5">Signature: ____________________</p>
</div>
</t>
</t>
</t>
</template>account.move.l10n_gr_self_supply_type
fields.Char, computed from the document series type, stored, indexed; '6.1' (self-supply of goods), '6.2' (own use of services) or FalseSelf-supply type. Tells whether a move is a self-supply document: '6.1' for goods taken out of stock, '6.2' for services used by the business itself, empty for everything else. It follows the document series, so do not write it; choose the series instead. It is stored, so use it in domains, filters and group-bys, for example to keep self-supply documents out of sales reports or to list them for the accountant. Precondition: l10n_gr_nt_selfsupply installed.
Example
The owner's monthly dashboard shows sales separately from self-supply of goods and own use of services, so turnover is not overstated.
# Customer need: the owner's monthly dashboard shows self-supplies apart from sales, so own use never inflates turnover.
# depends: ['l10n_gr_nt_selfsupply']
from odoo import api, models
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model
def _acme_turnover_and_self_supply(self, date_from, date_to):
domain = [
('company_id', '=', self.env.company.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('invoice_date', '>=', date_from),
('invoice_date', '<=', date_to),
]
# Stored and indexed, so it groups in SQL; False means an ordinary sale.
rows = self._read_group(
domain,
['l10n_gr_self_supply_type', 'invoice_date:month'],
['amount_untaxed_signed:sum'],
)
return [{'kind': kind or 'sales', 'month': month, 'net': net} for kind, month, net in rows]stock.move.l10n_gr_self_supply_invoice_id
fields.Many2one('account.move'), copy=False, ondelete='set null', indexedSelf-supply document of the stock move. On a stock move, the self-supply document that created it; empty for every other move. It is the warehouse side of the link, for when the question is why stock left without a delivery. Use it in stock reports and filters, for example to separate self-supply from scrap or sales. Read it; it is set by the suite when it creates the move. Precondition: l10n_gr_nt_selfsupply installed.
Example
The warehouse manager's monthly report shows, per product, how much stock left for own use, how much was scrapped and how much was sold.
# Customer need: the warehouse manager's monthly outflow report separates goods taken for own use from sales and scrap.
# depends: ['sale_stock', 'l10n_gr_nt_selfsupply']
from odoo import api, models
class StockMove(models.Model):
_inherit = 'stock.move'
@api.model
def _acme_outflow_by_reason(self, date_from, date_to):
base = [
('state', '=', 'done'), ('date', '>=', date_from), ('date', '<=', date_to),
('location_id.usage', '=', 'internal'), ('location_dest_id.usage', '!=', 'internal'),
]
reasons = {
'self_supply': [('l10n_gr_self_supply_invoice_id', '!=', False)],
'scrap': [('l10n_gr_self_supply_invoice_id', '=', False), ('scrap_id', '!=', False)],
'sale': [('l10n_gr_self_supply_invoice_id', '=', False), ('sale_line_id', '!=', False)],
}
return {
reason: dict(self._read_group(base + extra, ['product_id'], ['product_qty:sum']))
for reason, extra in reasons.items()
}Digital Delivery Note
depends: ['l10n_gr_nt_stock']
Issues the Greek delivery note from a stock picking, with the shipping details the document needs (movement purpose, vehicle, dispatch date), and links each customer invoice to the delivery notes it declares.
account.move.l10n_gr_connected_picking_ids
fields.Many2many('stock.picking', 'account_move_l10n_gr_connected_picking_rel', 'move_id', 'picking_id', string='Connected Delivery Notes', readonly=True, copy=False)Connected delivery notes of an invoice. The delivery notes that a customer invoice declares as connected documents. When the invoice is posted, the suite fills it (if still empty) with the done, already transmitted outgoing delivery notes of the sale order lines this invoice bills, not every delivery of the order, leaving out any already declared by another posted invoice. From then on the list stays fixed, so the printed and the declared invoice agree. Use it to find, from an invoice, which deliveries it covers (reports, follow-up automations, checks in your own module). Precondition: l10n_gr_nt_stock installed. Treat it as read-only: the suite owns its content, and changing it after posting would make the invoice disagree with what was declared.
Example
When an invoice is posted, the deliveries it declares are marked with its number, so the warehouse management system can close them.
# Customer need: the WMS must know which deliveries an invoice covers, so it can close them on its side.
# depends: ['l10n_gr_nt_stock']
from odoo import fields, models
class StockPicking(models.Model):
_inherit = 'stock.picking'
x_wms_invoice_ref = fields.Char(string='Invoiced by', copy=False, readonly=True)
class AccountMove(models.Model):
_inherit = 'account.move'
def _post(self, soft=True):
posted = super()._post(soft=soft)
# Read after super(): the suite fills the list while posting, then keeps it fixed.
for move in posted.filtered(lambda m: m.move_type == 'out_invoice'):
move.l10n_gr_connected_picking_ids.write({'x_wms_invoice_ref': move.name})
return postedstock.picking.dispatch_date
fields.Datetime(string='Dispatch Date', copy=False)Dispatch date and time. When the shipment leaves, planned or estimated; printed on the delivery note and included in it. The suite does not accept a dispatch day in the past when issuing, and if the field is empty at issue it fills in the moment of issue. When an invoice that also serves as the delivery note (an invoice series combined with a delivery series) covers the picking, the invoice takes its dispatch date from here, falling back to the date the picking was done. Use it from a module that schedules shipments to put the planned departure on the document. Not copied. Precondition: l10n_gr_nt_stock installed.
Example
The route planner assigns the truck and its departure time to the next day's deliveries, so each delivery note shows them without manual entry.
# Customer need: the route planner sets the truck and its departure time on tomorrow's deliveries, so the delivery note carries them.
# depends: ['l10n_gr_nt_stock']
from datetime import timedelta
from odoo import _, fields, models
from odoo.exceptions import UserError
class StockPicking(models.Model):
_inherit = 'stock.picking'
def action_acme_plan_route(self, plate, departure=None):
"""plate: the truck's registration plate; departure: datetime, default in one hour."""
departure = departure or fields.Datetime.now() + timedelta(hours=1)
if departure < fields.Datetime.now():
# The suite itself refuses a dispatch day in the past when the note is issued.
raise UserError(_('The departure time is in the past.'))
# Only deliveries still to ship.
todo = self.filtered(lambda p: p.state not in ('done', 'cancel'))
todo.write({
'vehicle_number': plate,
'dispatch_date': departure,
})
return Truestock.picking.move_purpose_id
fields.Many2one('aade.move.purpose', string='Movement Purpose', compute=..., store=True, readonly=False, precompute=True, domain="[('id', 'in', allowed_move_purpose_ids)]", copy=False)Movement purpose of a picking. Why the goods are moving (AADE table 8.14), printed on the delivery note. The suite proposes a value from the direction of the picking and only offers purposes that fit it: internal ones when the goods go to one of your own locations, the others when they go to or come back from a customer or supplier. A receipt from a supplier has no movement purpose. Where purposes apply, a delivery note cannot be issued without one. It is editable, so a partner module can set it when it creates pickings (for example from a custom flow); a value you set is kept as long as it is one of the offered purposes. Once the delivery note is issued, the suite no longer recomputes it. It is not copied, so a return never inherits the original purpose. Precondition: l10n_gr_nt_stock installed. Set it before the delivery note is issued.
Example
When sales representatives send product samples to prospects, a button sets the movement purpose to sampling, so the delivery note does not declare a sale.
# Customer need: sales reps send product samples to prospects; the delivery note must say sampling, not sale.
# depends: ['stock', 'l10n_gr_nt_stock']
from odoo import _, models
from odoo.exceptions import UserError
class StockPicking(models.Model):
_inherit = 'stock.picking'
def action_acme_mark_as_samples(self):
# AADE table 8.14: code 3 is sampling.
sampling = self.env['aade.move.purpose'].search([('code', '=', 3)], limit=1)
for picking in self:
if picking.state in ('done', 'cancel'):
raise UserError(_('%s is already done or cancelled: set the purpose before shipping.', picking.name))
if sampling not in picking.allowed_move_purpose_ids:
raise UserError(_('%s cannot carry the sampling purpose for this direction.', picking.name))
# Kept as set, since it is one of the purposes offered for this picking.
picking.move_purpose_id = sampling
return Truestock.picking.vehicle_number
fields.Char(string='Vehicle Number', copy=False)Vehicle registration plate. The registration plate of the vehicle carrying the goods; printed on the delivery note and included in it. Use it when your module knows the vehicle (fleet integration, route planning) and should fill it instead of the user. Not copied, so returns and duplicates start empty. Precondition: l10n_gr_nt_stock installed. Fill it before the delivery note is issued.
Example
The dispatcher enters the truck's plate once on a batch of deliveries, and every delivery note of the batch carries it.
# Customer need: the dispatcher sets the truck once on a picking batch, and every delivery in the batch carries its plate.
# depends: ['stock_picking_batch', 'l10n_gr_nt_stock']
from odoo import fields, models
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
x_license_plate = fields.Char(string='Truck plate')
def write(self, vals):
res = super().write(vals)
if 'x_license_plate' in vals:
# Only deliveries still to ship; done pickings keep what they declared.
self.picking_ids.filtered(lambda p: p.state not in ('done', 'cancel')).write({
'vehicle_number': vals['x_license_plate'],
})
return resStock balance
depends: ['l10n_gr_nt_stock_balance']
Opening stock plus receipts minus issues equals closing balance, per product and warehouse, in quantity and value that agree with Odoo's stock valuation; also adds the Greek locations for stock destruction and for theft and loss.
stock.location.l10n_gr_destruction
fields.Boolean(string='Stock destruction', copy=False, readonly=True)Stock destruction location marker. Marks the location «Stock destruction» that the suite creates once per company, with valuation account 67.02.00, and makes it the company's default scrap location. Users may rename it or change its account later; the marker stays, so search by the marker, never by name. Precondition: l10n_gr_nt_stock_balance installed and a company on the Greek (ΕΛΠ) chart of accounts with account 67.02.00; otherwise no location is created and the search returns nothing. An archived location is not found by a default search. The field is read-only: do not set it on other locations.
Example
A food distributor scraps every expired lot in one run, and the loss is booked to the stock destruction account rather than cost of goods sold.
# Need: a food distributor destroys expired lots every month; the loss must land on 67.02.00.
# depends: ['l10n_gr_nt_stock_balance', 'product_expiry']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class StockQuant(models.Model):
_inherit = 'stock.quant'
@api.model
def _acme_destroy_expired_lots(self):
destruction = self.env['stock.location'].search([
('company_id', '=', self.env.company.id),
('l10n_gr_destruction', '=', True), # search by the marker, never by name
], limit=1)
if not destruction:
raise UserError(_('No stock destruction location. Is the Greek chart of accounts installed?'))
quants = self.search([
('company_id', '=', self.env.company.id),
('location_id.usage', '=', 'internal'),
('lot_id.expiration_date', '<', fields.Datetime.now()),
('quantity', '>', 0),
])
scraps = self.env['stock.scrap'].create([{
'product_id': q.product_id.id, 'lot_id': q.lot_id.id, 'scrap_qty': q.quantity,
'location_id': q.location_id.id, 'scrap_location_id': destruction.id,
'origin': _('Expired lots'),
} for q in quants])
for scrap in scraps: # action_validate works on one scrap at a time
scrap.action_validate()stock.location.l10n_gr_theft
fields.Boolean(string='Stock theft and loss', copy=False, readonly=True)Stock theft and loss location marker. Marks the location «Stock theft and loss» that the suite creates once per company, with valuation account 67.03.00. Use it as the destination when your module records stolen or lost goods, so the loss lands on the right account instead of cost of goods sold. Same preconditions as the destination marker: l10n_gr_nt_stock_balance installed, Greek (ΕΛΠ) chart with account 67.03.00. Search by the marker, not the name; read-only.
Example
A retail chain records stolen goods with the police report number, so the loss lands on the theft and loss account instead of cost of goods sold.
# Need: a retail chain records goods found stolen at a store count, with the police report number.
# depends: ['l10n_gr_nt_stock_balance']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from odoo import fields, models, _
from odoo.exceptions import UserError
class AcmeTheftReport(models.TransientModel):
_name = 'acme.theft.report'
_description = 'Record stolen goods'
product_id = fields.Many2one('product.product', required=True)
location_id = fields.Many2one('stock.location', required=True, domain=[('usage', '=', 'internal')])
quantity = fields.Float(required=True)
police_report = fields.Char(required=True)
def action_confirm(self):
self.ensure_one()
theft = self.env['stock.location'].search([
('company_id', '=', self.location_id.company_id.id),
('l10n_gr_theft', '=', True), # search by the marker, never by name
], limit=1)
if not theft:
raise UserError(_('No stock theft and loss location for this company.'))
self.env['stock.scrap'].create({
'product_id': self.product_id.id, 'scrap_qty': self.quantity,
'location_id': self.location_id.id, 'scrap_location_id': theft.id,
'origin': _('Police report %s', self.police_report),
}).action_validate()Product-to-product stock transfer
depends: ['l10n_gr_nt_stock_transfer']
A numbered warehouse document that takes quantity out of one product and puts it into another, either as a conversion (value carried over) or as a wrong-shipment correction (each product at its own cost, difference to cost of goods sold), with a mandatory reason and reversal instead of deletion.
l10n_gr.stock.product.transfer
models.Model, _inherit=['mail.thread'], _check_company_auto=True. Fields: name (Char, number, readonly), mode (Selection: 'conversion' | 'wrong_shipment'), company_id, date (Date), location_id (internal stock.location), reason (Text), state (Selection: 'draft' | 'posted' | 'reversed', readonly), reversal_of_id, reversed_by_id, line_ids (One2many to l10n_gr.stock.product.transfer.line), move_ids (One2many stock.move via l10n_gr_transfer_id)Product transfer document. The header of a product-to-product transfer. It is created in draft, gets its number when posted, and generates, per line, one stock move taking the source product out of the chosen internal location and one bringing the destination product into it, through a transfer location the suite keeps per company and mode. Use it when your module needs to move stock from one product code to another with a proper document: repacking, box to pieces, a code change (mode 'conversion'), or fixing a shipment of the wrong product (mode 'wrong_shipment'). Precondition: l10n_gr_nt_stock_transfer installed; the user needs Inventory / Administrator rights; documents are filtered by the user's companies. A posted document cannot be deleted: reverse it. You can add your own fields with _inherit.
Example
A wholesaler links every wrong-shipment correction to the supplier's purchase order and tracks whether the claim was sent.
# Need: a wholesaler corrects wrong-product shipments and must link each correction
# to the supplier's purchase order, so the claim to the supplier can be followed up.
# depends: ['l10n_gr_nt_stock_transfer', 'purchase']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.l10n_gr_*.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class StockProductTransfer(models.Model):
_inherit = 'l10n_gr.stock.product.transfer'
acme_purchase_id = fields.Many2one('purchase.order', string='Supplier order', check_company=True)
acme_claim_sent = fields.Boolean('Claim sent to supplier', tracking=True)
@api.constrains('mode', 'acme_purchase_id')
def _check_acme_purchase(self):
for transfer in self:
if transfer.mode == 'wrong_shipment' and not transfer.acme_purchase_id:
raise ValidationError(_('A wrong-shipment correction needs the supplier order.'))l10n_gr.stock.product.transfer.action_post
action_post(self) -> TruePost a product transfer. Validates and posts draft transfers: checks the header (draft state, mode, reason, at least one line, date not in the future and not in a locked period) and every line (different products, positive quantities, storable products that are not serial-tracked or valued by lot, lots where tracked, enough stock available after reservations). A conversion also needs a destination product that is not on standard price and compatible valuation accounts; a wrong-shipment correction needs a cost of goods sold account where valuation is automated. It then numbers the document, creates and validates the stock moves at the document date, and sets the state to posted. Raises UserError on any failed check. Call it from your module after creating a transfer. To hook in, override it and call super(): run your own pre-checks before super(), and work with the generated moves after it.
Example
A distributor blocks posting a wrong-shipment correction without a photo of the goods and creates a follow-up for purchasing once it is posted.
# Need: the warehouse manager requires a photo of the goods before a wrong-shipment
# correction is posted, and the purchasing team must be told after posting.
# depends: ['l10n_gr_nt_stock_transfer']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.l10n_gr_*.
from odoo import models, _
from odoo.exceptions import UserError
class StockProductTransfer(models.Model):
# the transfer has mail.thread only; add activities to schedule follow-ups
_inherit = ['l10n_gr.stock.product.transfer', 'mail.activity.mixin']
def action_post(self):
Attachment = self.env['ir.attachment']
wrong = self.filtered(lambda t: t.mode == 'wrong_shipment')
for transfer in wrong:
if not Attachment.search_count([('res_model', '=', self._name), ('res_id', '=', transfer.id)]):
raise UserError(_('Attach a photo of the goods to %s before posting.', transfer.display_name))
res = super().action_post() # suite checks, numbering and stock moves happen here
for transfer in wrong:
transfer.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Claim the wrong shipment from the supplier'),
user_id=transfer.create_uid.id,
)
return resl10n_gr.stock.product.transfer.action_reverse
action_reverse(self) -> dict (ir.actions.act_window opening the reversal)Reverse a posted transfer. Works on one posted transfer (ensure_one). Creates and posts a new transfer, dated today, of the same mode and location in the opposite direction (destination product back to source, with the quantities and lots swapped), linked to the original through reversal_of_id / reversed_by_id, and marks the original as reversed. The outgoing value of the reversal is the current cost of the product, so it is not an exact undo of the original values. Raises UserError if the transfer is not posted, or if the reversal fails any posting check. Returns a window action on the reversal. Override with super() if you need to carry your own fields over to the reversal.
Example
When a posted transfer is reversed, the reversal keeps the link to the supplier order and the accountant gets a task to check its value.
# Need: reversals must keep the supplier order link and alert the accountant,
# because the reversal is valued at today's cost and the figures will differ.
# depends: ['l10n_gr_nt_stock_transfer', 'purchase']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.l10n_gr_*.
from odoo import fields, models, _
class StockProductTransfer(models.Model):
# the transfer has mail.thread only; add activities to schedule follow-ups
_inherit = ['l10n_gr.stock.product.transfer', 'mail.activity.mixin']
acme_purchase_id = fields.Many2one('purchase.order', string='Supplier order')
def action_reverse(self):
res = super().action_reverse() # works on one transfer and posts the reversal
reversal = self.reversed_by_id
reversal.acme_purchase_id = self.acme_purchase_id
accountant = self.env.ref('account.group_account_manager').user_ids[:1]
reversal.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Check the value of the reversal of %s', self.name),
user_id=(accountant or self.env.user).id,
)
return resl10n_gr.stock.product.transfer.line
models.Model, _check_company_auto=True. Fields: transfer_id (required, ondelete='cascade'), product_id, lot_id, quantity (source), dest_product_id, dest_lot_id, dest_quantity (computed from quantity, stored, editable), out_move_id, in_move_id (readonly), out_value, in_value, cogs_difference (computed Monetary)Product transfer line. One source product, quantity and lot going out, and one destination product, quantity and lot coming in. dest_quantity follows the source quantity when both products share the unit; set it yourself when they do not (1 box to 12 pieces). Before posting, the value fields are estimates; after posting, out_move_id and in_move_id point to the generated stock moves, and the value fields show the outgoing and incoming value and, for wrong-shipment corrections, the difference charged to cost of goods sold. Create lines through the transfer's line_ids.
Example
A wholesaler turns the warehouse's weekly box-split file into one numbered conversion document, with box quantities going out and piece quantities coming in.
# Need: a cosmetics wholesaler splits boxes into pieces every week from a CSV the
# warehouse exports (box_sku;piece_sku;boxes;pieces_per_box). Run in odoo-bin shell (env given).
# depends (of the module the data belongs to): ['l10n_gr_nt_stock_transfer']
# The l10n.gr API is reached through the ORM (env[...]), never by importing from odoo.addons.l10n_gr_*.
import csv
from odoo import Command
Product = env['product.product']
stock = env['stock.warehouse'].search([('company_id', '=', env.company.id)], limit=1).lot_stock_id
with open('/tmp/box_split.csv', newline='', encoding='utf-8') as f:
rows = list(csv.DictReader(f, delimiter=';'))
lines = []
for row in rows:
box = Product.search([('default_code', '=', row['box_sku'])], limit=1)
piece = Product.search([('default_code', '=', row['piece_sku'])], limit=1)
if not (box and piece):
print('skipped, unknown item:', row['box_sku'], row['piece_sku'])
continue
boxes = float(row['boxes'])
lines.append(Command.create({
'product_id': box.id, 'quantity': boxes,
# different units (box -> pieces): set dest_quantity yourself
'dest_product_id': piece.id, 'dest_quantity': boxes * float(row['pieces_per_box']),
}))
transfer = env['l10n_gr.stock.product.transfer'].create({
'mode': 'conversion', 'location_id': stock.id,
'reason': 'Weekly box split', 'line_ids': lines,
})
transfer.action_post()
env.cr.commit()stock.move.l10n_gr_transfer_id
fields.Many2one('l10n_gr.stock.product.transfer', string='Product transfer', copy=False, index='btree_not_null', ondelete='restrict')Transfer a stock move came from. Set on the stock moves generated by a product transfer; empty on all other moves. Use it to tell those moves apart in your reports or move-based logic (for example to exclude conversions from sales statistics). The transfer cannot be deleted while moves point to it. Do not set it on your own moves.
Example
A stock-loss report shows each item's adjustments and scrap over 90 days without counting the stock moved out by product-to-product transfers.
# Need: a stock-loss report per item over 90 days (inventory adjustments and scrap).
# Product transfers also leave stock through an inventory-type location, but they are not losses.
# depends: ['l10n_gr_nt_stock_transfer']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from datetime import timedelta
from odoo import fields, models
class ProductProduct(models.Model):
_inherit = 'product.product'
acme_loss_qty_90d = fields.Float('Lost in 90 days', compute='_compute_acme_loss_qty_90d')
def _compute_acme_loss_qty_90d(self):
since = fields.Datetime.now() - timedelta(days=90)
groups = self.env['stock.move']._read_group([
('product_id', 'in', self.ids),
('state', '=', 'done'),
('date', '>=', since),
('location_id.usage', '=', 'internal'),
('location_dest_id.usage', '=', 'inventory'),
('l10n_gr_transfer_id', '=', False), # leave product transfers out
], ['product_id'], ['product_qty:sum'])
qty = {product.id: total for product, total in groups}
for product in self:
product.acme_loss_qty_90d = qty.get(product.id, 0.0)Single Item Coding (TARIC)
depends: ['l10n_gr_nt_taric']
Gives every item that moves a valid TARIC tariff code before 1/1/2028: EU library, suggestions from suppliers and AI, bulk approval, and a block on delivery notes after the obligation date.
l10n_gr.mydata.received.line.l10n_gr_taric_no
fields.Char(size=10), indexedSupplier's TARIC code on a received line. The TARIC code the supplier put on a line of a received document, filled in by the suite when the document is fetched and the supplier sent one; empty otherwise. It is the supplier's statement, not yet a code of yours. Use it to propose or check codes for your items, for example by matching lines to products and validating the code with _l10n_gr_find on the document's issue date. Read it; do not overwrite what the supplier sent. Precondition: l10n_gr_nt_taric installed (it brings l10n_gr_nt_mydata_received).
Example
A scheduled action fills the missing TARIC codes of purchased items from the valid codes suppliers put on their received invoices.
# Need: before 1/1/2028, fill the missing TARIC codes of purchased items from what the
# suppliers already send on their invoices, matched through the supplier's product code.
# depends: ['l10n_gr_nt_taric']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from odoo import api, models, _
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _cron_acme_taric_from_suppliers(self):
Taric = self.env['l10n_gr.taric.code']
lines = self.env['l10n_gr.mydata.received.line'].search([
('l10n_gr_taric_no', '!=', False), ('item_code', '!=', False),
('doc_id.partner_id', '!=', False),
])
for line in lines:
info = self.env['product.supplierinfo'].search([
('partner_id', '=', line.doc_id.partner_id.id),
('product_code', '=', line.item_code),
], limit=1)
tmpl = info.product_tmpl_id
if tmpl and not tmpl.l10n_gr_taric_no and Taric._l10n_gr_find(line.l10n_gr_taric_no, on_date=line.doc_id.issue_date):
tmpl.l10n_gr_taric_no = line.l10n_gr_taric_no # we write our item, never the supplier's line
tmpl.message_post(body=_('TARIC code taken from %s.', line.doc_id.partner_id.display_name))l10n_gr.taric.code
models.Model, _order 'code, suffix'; fields: code (Char 10, required), suffix (Char 2, '80' = goods), name, name_en (Char), complete_name, complete_name_en (stored), level (Integer: 2 chapter ... 10 TARIC), parent_id / child_ids (hierarchy), chapter, heading (stored), declarable (Boolean), date_start, date_end (Date), active; unique (code, suffix)TARIC library. The EU tariff nomenclature, loaded and refreshed by the suite. Each record is one code with its description in Greek and English, its place in the hierarchy and its validity dates. Only declarable codes (goods lines with no subdivisions) can go on an item. Use it to look up codes, validate codes your module receives from elsewhere, or show the full description. Searching by name matches code and both descriptions. Treat it as reference data: read it, do not edit it. Access: read for Inventory, Sales and Invoicing users; Inventory managers have full rights for the library reload. Precondition: l10n_gr_nt_taric installed.
Example
A partner migrating from a legacy ERP converts the old 8-digit customs codes into TARIC codes and gets a list of items that need a human decision.
# Need: migration from a legacy ERP that kept 8-digit customs (CN) codes on items.
# Map each to the single declarable 10-digit TARIC code valid today under it; list the rest for review.
# Run in odoo-bin shell (env given). depends: ['l10n_gr_nt_taric']
# The l10n.gr API is reached through the ORM (env[...]), never by importing from odoo.addons.l10n_gr_*.
import csv
Taric = env['l10n_gr.taric.code']
review = []
with open('/tmp/legacy_items.csv', newline='', encoding='utf-8') as f:
for row in csv.DictReader(f, delimiter=';'): # columns: sku;cn8
tmpl = env['product.template'].search([('default_code', '=', row['sku'])], limit=1)
candidates = Taric.search([
('code', '=like', row['cn8'] + '%'),
('declarable', '=', True),
]).filtered(lambda c: Taric._l10n_gr_find(c.code)) # keep codes valid today
if tmpl and len(candidates) == 1:
tmpl.l10n_gr_taric_no = candidates.code
else:
review.append((row['sku'], row['cn8'], ', '.join(candidates.mapped('code'))))
env.cr.commit()
for sku, cn8, options in review:
print(sku, cn8, options or 'no declarable code')l10n_gr.taric.code._l10n_gr_find
@api.model _l10n_gr_find(self, code, on_date=None) -> l10n_gr.taric.code record or emptyFind a valid TARIC code. Returns the declarable library record for a code that is valid on on_date (today when omitted), or an empty record when the code is empty, unknown, not declarable or not valid on that date. It is the same lookup the suite uses to judge items. Use it whenever your module needs to know if a code is acceptable, for example when importing items or checking a supplier's code on a received line. Precondition: l10n_gr_nt_taric installed.
Example
An item import refuses any TARIC code that is not a declarable code valid on the obligation date, so bad codes never reach the item catalogue.
# Need: items come in daily from a supplier price-list import; a code that is not valid
# on 1/1/2028 must be rejected at save time, not discovered when a delivery note is blocked.
# depends: ['l10n_gr_nt_taric']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.constrains('l10n_gr_taric_no')
def _check_acme_taric_valid(self):
Taric = self.env['l10n_gr.taric.code']
deadline = fields.Date.to_date('2028-01-01')
for tmpl in self.filtered('l10n_gr_taric_no'):
if not Taric._l10n_gr_find(tmpl.l10n_gr_taric_no, on_date=deadline):
raise ValidationError(_(
'%(item)s: TARIC code %(code)s is not a declarable code valid on 1/1/2028.',
item=tmpl.display_name, code=tmpl.l10n_gr_taric_no))product.template.l10n_gr_taric_code_id
fields.Many2one('l10n_gr.taric.code'), computed, not stored, depends on the company in contextTARIC description of the item. The library record that matches the item's TARIC code today, or empty when the item has no code or the code is not valid. It is computed from the code stored on the item, so read it to show the official description or to reach the hierarchy; to change it, change the item's code. It is not stored, so it cannot be used in search domains. Precondition: l10n_gr_nt_taric installed.
Example
An exporter prints each item's TARIC code and official description under the product on the delivery note.
<!-- Need: an exporter's customers ask for the TARIC code and its official description
under each item on the validated delivery note. -->
<!-- depends: ['l10n_gr_nt_taric', 'stock'] -->
<odoo>
<!-- lines grouped by product (lots not printed) -->
<template id="acme_delivery_aggregated_taric" inherit_id="stock.stock_report_delivery_aggregated_move_lines">
<xpath expr="//tr/td[1]" position="inside">
<t t-set="taric" t-value="aggregated_lines[line]['product'].product_tmpl_id.with_company(o.company_id).l10n_gr_taric_code_id"/>
<div t-if="taric" class="text-muted small">
TARIC <span t-out="taric.code"/>: <span t-out="taric.complete_name"/>
</div>
</xpath>
</template>
<!-- one line per lot or serial number -->
<template id="acme_delivery_serial_taric" inherit_id="stock.stock_report_delivery_has_serial_move_line">
<xpath expr="//td[1]" position="inside">
<t t-set="taric" t-value="move_line.product_id.product_tmpl_id.with_company(o.company_id).l10n_gr_taric_code_id"/>
<div t-if="taric" class="text-muted small">
TARIC <span t-out="taric.code"/>: <span t-out="taric.complete_name"/>
</div>
</xpath>
</template>
</odoo>product.template.l10n_gr_taric_status
fields.Selection: missing, valid, ending, unknown; computed, not stored, depends on the company in contextTARIC check of the item. The state of the item's code: missing (no code), valid, ending (valid today but ends before the company's "TARIC code required from" date, by default 1/1/2028) or unknown (not in the library or not valid). Use it to find items that need attention before the obligation date, in your own reports, imports or checks. It is not stored, so filter records in Python rather than in a search domain. Precondition: l10n_gr_nt_taric installed; the ending state depends on the company setting "TARIC code required from".
Example
A weekly scheduled action gives the product manager a task for every item whose TARIC code is missing, unknown or ending before the obligation date.
# Need: every Monday until the obligation date, the product manager gets one task per
# item that still has a missing, unknown or ending TARIC code.
# depends: ['l10n_gr_nt_taric']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.l10n_gr_*.
from odoo import api, models, _
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _cron_acme_taric_todo(self):
manager = self.env.ref('stock.group_stock_manager').user_ids[:1] or self.env.user
summary = _('Fix the TARIC code')
items = self.search([('type', '=', 'consu'), ('sale_ok', '=', True)])
# the status is computed, not stored: filter in Python, not in the domain
todo = items.filtered(lambda t: t.l10n_gr_taric_status in ('missing', 'unknown', 'ending'))
open_ids = self.env['mail.activity'].search([
('res_model', '=', self._name), ('res_id', 'in', todo.ids), ('summary', '=', summary),
]).mapped('res_id')
for tmpl in todo - self.browse(open_ids):
tmpl.activity_schedule('mail.mail_activity_data_todo', summary=summary, user_id=manager.id)l10n.gr — Availability per Branch
depends: ['nt_multi_branch']
Products, pricelists, partners and POS payment methods available in any chosen set of branches, a company setting for what other branches see, a POS for each new branch, and automatic receipts for transfers between branches.
nt.multi.branch.mixin
models.AbstractModel, _name='nt.multi.branch.mixin'. Adds nt_branch_ids and _nt_available_in(company)Branch availability mixin. The mixin behind the «Available branches» field. Odoo gives a record one company_id (empty, one company, or a parent with its branches); the mixin adds, next to it, a free set of branches, without touching company_id or Odoo's company checks. Inherit it on your own model to get the same field and helper. Precondition: nt_multi_branch installed (add it to depends). It gives the field and the helper only: the record rules that hide records from other branches exist for the suite's own uses (products, variants, pricelists, partners, POS payment methods), so on your model add your own ir.rule or filter where it matters. Give the Many2many its own relation name, as the suite does for each model.
Example
A gym chain defines membership plans that are offered only in the branches chosen on each plan.
# Need: a gym chain sells membership plans that exist only in some of its branches.
# depends: ['nt_multi_branch']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.*.
from odoo import fields, models
class AcmeMembershipPlan(models.Model):
_name = 'acme.membership.plan'
_description = 'Membership plan'
_inherit = ['nt.multi.branch.mixin']
name = fields.Char(required=True, translate=True)
monthly_fee = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency', default=lambda self: self.env.company.currency_id)
# own relation name, as the suite does for each model
nt_branch_ids = fields.Many2many(relation='acme_membership_plan_branch_rel')
# The mixin adds no record rule on your model. To hide plans from other branches, add one:
# domain_force: ['|', ('nt_branch_ids', '=', False), ('nt_branch_ids', 'in', company_ids)]nt.multi.branch.mixin._nt_available_in
_nt_available_in(self, company) -> boolIs the record available in a branch. For one record (ensure_one): True when nt_branch_ids is empty or contains the given company, else False. Use it in Python checks, for example to block a sale, an import or an API call for a product the branch does not sell; the suite does the same on sale order lines.
Example
An EDI order import for a branch keeps only the lines that branch sells and returns the other item codes to the customer as rejected.
# Need: B2B orders arrive by EDI for a given branch; lines for products that branch does
# not sell are returned to the customer as rejected instead of failing the whole order.
# depends: ['nt_multi_branch', 'sale']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.*.
from odoo import Command, api, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.model
def _acme_create_from_edi(self, branch, partner, edi_lines):
"""edi_lines: [{'sku': ..., 'qty': ...}]. Returns (order, rejected skus)."""
accepted, rejected = [], []
for row in edi_lines:
product = self.env['product.product'].search([('default_code', '=', row['sku'])], limit=1)
if product and product.product_tmpl_id._nt_available_in(branch):
accepted.append(Command.create({'product_id': product.id, 'product_uom_qty': row['qty']}))
else:
rejected.append(row['sku'])
order = self.with_company(branch).create({
'company_id': branch.id, 'partner_id': partner.id, 'order_line': accepted,
}) if accepted else self.browse()
return order, rejectednt.multi.branch.mixin.nt_branch_ids
fields.Many2many('res.company', string='Available branches')Available branches. The branches where the record is available. Empty means all branches, the same way an empty company_id means all companies in Odoo, so a branch opened later sees the record without anyone adding it. Search with the pattern «empty or contains the branch» to get what a branch may use.
Example
On a quotation, the pricelist field offers only the pricelists available in the branch of that order.
# Need: a salesperson who works for several branches must see on a quotation only the
# pricelists available in the branch of that order, not in the branches selected in the switcher.
# depends: ['nt_multi_branch', 'sale']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.*.
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
# «empty or contains the branch»: empty means available in every branch
pricelist_id = fields.Many2one(
domain="['|', ('company_id', '=', False), ('company_id', 'parent_of', company_id),"
" '|', ('nt_branch_ids', '=', False), ('nt_branch_ids', 'in', [company_id])]",
)product.template.nt_branch_ids
fields.Many2many('res.company', relation='nt_branch_product_template_rel'), default from the companies selected in the company switcher (empty when all the user's companies are selected); product.product exposes it as a related fieldBranches where a product is available. The mixin field on products. A new product defaults to the branches selected in the company switcher, or stays empty (all branches) when all of the user's companies are selected. The POS of a branch loads only products available in it, and a sale order line in a branch refuses a product not available there. What other branches see depends on the setting «Products outside a branch» of the head (root) company: either the product is not sold but its stock is visible, or it is not visible at all. Write it from an import or sync module to set availability; leave it empty for products every branch sells.
Example
A partner migrating from a legacy ERP sets, item by item, the branches that sell each product, leaving it open to every branch when the old system had no restriction.
# Need: migration from a legacy ERP that kept, per item, the stores that sell it
# (sku;stores, stores as 'ATH|PAT'). Empty in the file means every branch.
# Run in odoo-bin shell (env given). depends: ['nt_multi_branch']
# The l10n.gr API is reached through the ORM (env[...]), never by importing from odoo.addons.*.
import csv
from odoo import Command
# legacy store code -> branch company; adjust the names to the customer's branches
branches = {
'ATH': env['res.company'].search([('name', '=', 'Acme Αθήνα')], limit=1),
'PAT': env['res.company'].search([('name', '=', 'Acme Πάτρα')], limit=1),
}
with open('/tmp/item_stores.csv', newline='', encoding='utf-8') as f:
for row in csv.DictReader(f, delimiter=';'):
tmpl = env['product.template'].search([('default_code', '=', row['sku'])], limit=1)
if not tmpl:
continue
codes = [c for c in row['stores'].split('|') if c]
ids = [branches[c].id for c in codes if branches.get(c)]
tmpl.nt_branch_ids = [Command.set(ids)] if ids else [Command.clear()]
env.cr.commit()res.company._nt_branch_pos_vals
_nt_branch_pos_vals(self, source, vals) -> dictValues of a new branch's POS. When a new branch company is created, it has no POS yet and the head (root) company has one, the suite copies the head company's first pos.config into the branch at commit time, with the branch's own warehouse and copies of the payment methods. This hook receives the source pos.config and the values prepared for the copy, and returns the values to use; self is the new branch. Override it to adjust what must belong to the branch and should not be copied as is (your own Many2one fields on pos.config pointing to company records, for example). Always call super() first and return its result with your changes: other suite modules use the same hook for their own fields.
Example
A bakery chain's new branch gets a POS whose receipts carry the branch's own name and address instead of the head store's.
# Need: a bakery chain opens new branches often; each new branch's POS must print its
# own name and address on the receipt, not those of the head store it was copied from.
# depends: ['nt_multi_branch', 'point_of_sale']
# The l10n.gr API is reached through the ORM (_inherit), never by importing from odoo.addons.*.
from odoo import models, _
class ResCompany(models.Model):
_inherit = 'res.company'
def _nt_branch_pos_vals(self, source, vals):
vals = super()._nt_branch_pos_vals(source, vals) # always first: other modules use this hook
address = ', '.join(filter(None, [self.street, self.zip, self.city]))
vals.update({
'name': _('%s POS', self.name),
'is_header_or_footer': True,
'receipt_header': '%s\n%s' % (self.name, address),
})
return valsstock.picking.nt_transit_source_id
fields.Many2one('stock.picking', string='Sent from', readonly=True, copy=False, index='btree_not_null')Sending delivery of a branch receipt. Odoo sends a delivery to another company of the same entity into the inter-company transit location but does not create the receipt on the other side. With nt_multi_branch, validating a delivery into that transit location whose partner is another company under the same head (root) company creates the receipt in the destination company's warehouse, ready to validate there; this field on the receipt points back to the sending delivery. Only quantities are carried over, not lots or serial numbers. Use it to go from a delivery to its receipt (search on the field) or from a receipt to its origin, for example to track goods still in transit. Precondition: the destination company has a warehouse, otherwise validating the delivery raises an error. Set by the suite only.
Example
A scheduled action flags every inter-branch receipt still open three days after the sending delivery was validated.
# Need: goods sent between branches must be received within 3 days; the receiving
# branch's warehouse manager gets a task for every receipt still open after that.
# depends: ['nt_multi_branch', 'stock']
# The l10n.gr API is reached through the ORM, never by importing from odoo.addons.*.
from datetime import timedelta
from odoo import api, fields, models, _
class StockPicking(models.Model):
_inherit = 'stock.picking'
@api.model
def _cron_acme_late_branch_receipts(self):
late = self.search([
('nt_transit_source_id', '!=', False),
('nt_transit_source_id.date_done', '<', fields.Datetime.now() - timedelta(days=3)),
('state', 'not in', ('done', 'cancel')),
('activity_ids', '=', False),
])
managers = self.env.ref('stock.group_stock_manager').user_ids
for receipt in late:
manager = receipt.user_id or managers.filtered(lambda u: receipt.company_id in u.company_ids)[:1]
receipt.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Goods sent with %s not received yet', receipt.nt_transit_source_id.name),
user_id=(manager or self.env.user).id,
)