[ADD] l10n_es_pos: Spanish localization for POS

In Spain it's mandatory that POS systems generate a so called
"Factura simplificada". This is similar to a regular ticket
but has to comply with some special requirements.
Without this, POS isn't usable in Spain.

In this PR we introduce a spanish localisation for the pos
that handles the specific regulatory needs.

closes odoo/odoo#136008

Task: 3131769
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
vlst
2023-10-11 17:01:58 +00:00
parent aa118d7dc0
commit fb27d2b97e
24 changed files with 423 additions and 11 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import models
from . import tests
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Spain - Point of Sale',
'countries': ['es'],
'category': 'Accounting/Localizations/Point of Sale',
'summary': """Spanish localization for Point of Sale""",
'depends': ['point_of_sale', 'l10n_es'],
'auto_install': True,
'license': 'LGPL-3',
'data': [
'views/res_config_settings_views.xml',
'views/pos_order_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'l10n_es_pos/static/src/**/*',
],
'web.assets_tests': [
'l10n_es_pos/static/tests/**/*',
],
},
}
+5
View File
@@ -0,0 +1,5 @@
from . import pos_config
from . import pos_order
from . import pos_session
from . import res_config_settings
from . import account_move
+12
View File
@@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def _compute_l10n_es_is_simplified(self):
super()._compute_l10n_es_is_simplified()
for move in self:
if move.pos_order_ids:
move.l10n_es_is_simplified = move.pos_order_ids[0].is_l10n_es_simplified_invoice
+34
View File
@@ -0,0 +1,34 @@
from odoo import api, fields, models
class PosConfig(models.Model):
_inherit = "pos.config"
is_spanish = fields.Boolean(string="Company located in Spain", compute="_is_company_spanish")
def _is_company_spanish(self):
for pos in self:
pos.is_spanish = pos.company_id.country_id.code == "ES"
l10n_es_simplified_invoice_limit = fields.Float(
string="Simplified Invoice limit amount",
help="Over this amount is not legally possible to create a simplified invoice",
default=400,
)
l10n_es_simplified_invoice_journal_id = fields.Many2one('account.journal')
simplified_partner_id = fields.Many2one(
"res.partner", string="Simplified invoice partner", compute="_get_simplified_partner"
)
@api.depends("company_id")
def _get_simplified_partner(self):
for config in self:
config.simplified_partner_id = self.env.ref("l10n_es.partner_simplified").id
def get_limited_partners_loading(self):
# this function normally returns 100 partners, but we have to make sure that
# the simplified partner is also loaded
res = super().get_limited_partners_loading()
if (self.simplified_partner_id.id,) not in res:
res.append((self.simplified_partner_id.id,))
return res
+28
View File
@@ -0,0 +1,28 @@
from odoo import api, fields, models
class PosOrder(models.Model):
_inherit = "pos.order"
is_l10n_es_simplified_invoice = fields.Boolean("Simplified invoice")
l10n_es_simplified_invoice_number = fields.Char("Simplified invoice number", compute="_compute_l10n_es_simplified_invoice_number")
@api.depends("account_move")
def _compute_l10n_es_simplified_invoice_number(self):
for order in self:
if order.is_l10n_es_simplified_invoice:
order.l10n_es_simplified_invoice_number = order.account_move.name
else:
order.l10n_es_simplified_invoice_number = False
@api.model
def _order_fields(self, ui_order):
res = super(PosOrder, self)._order_fields(ui_order)
if ui_order.get("is_l10n_es_simplified_invoice"):
res.update({"is_l10n_es_simplified_invoice": ui_order["is_l10n_es_simplified_invoice"]})
return res
def _prepare_invoice_vals(self):
res = super()._prepare_invoice_vals()
if self.config_id.is_spanish and self.is_l10n_es_simplified_invoice:
res["journal_id"] = self.config_id.l10n_es_simplified_invoice_journal_id.id
return res
+12
View File
@@ -0,0 +1,12 @@
from odoo import models
class PosSession(models.Model):
_inherit = "pos.session"
def _loader_params_res_company(self):
res = super()._loader_params_res_company()
if not self.config_id.is_spanish:
return res
res["search_params"]["fields"] += ["street", "city", "zip"]
return res
@@ -0,0 +1,18 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
pos_is_spanish = fields.Boolean(
string="Consider the specific spanish legislation, such as the use of simplified invoices",
related="pos_config_id.is_spanish",
)
pos_l10n_es_simplified_invoice_limit = fields.Float(
related="pos_config_id.l10n_es_simplified_invoice_limit",
readonly=False,
)
pos_l10n_es_simplified_invoice_journal_id = fields.Many2one(
related="pos_config_id.l10n_es_simplified_invoice_journal_id", readonly=False
)
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-contact')]//div" position="before">
<t t-set="partner" t-value="receiptData.order.get_partner()"/>
<t t-set="isSimplifiedInvoice" t-value="receiptData.order.is_l10n_es_simplified_invoice"/>
<t t-set="receipt" t-value="receiptData.receipt"/>
<t t-if="pos.config.is_spanish">
<t t-if="isSimplifiedInvoice">
<div>Simplified invoice</div>
<div class="simplified-invoice-number" t-esc="receiptData.order.invoice_name" />
</t>
<div t-if="receipt.company.street" t-esc="receipt.company.street" />
<div t-if="receipt.company.zip" t-esc="receipt.company.zip" />
<div t-if="receipt.company.city" t-esc="receipt.company.city" />
<div t-if="receipt.company.state_id">(<t t-esc="receipt.company.state_id[1]"/>)</div>
</t>
</xpath>
<xpath expr="//div[hasclass('pos-receipt-contact')]" position="inside">
<t t-if="pos.config.is_spanish and partner?.id !== pos.config.simplified_partner_id[0]">
<div>Customer: <t t-esc="partner.name" /></div>
<div t-if="partner.vat">
<t t-esc="receipt.company.vat_label" />:
<t t-esc="partner.vat" />
</div>
<div t-if="partner.address" t-esc="partner.address"/>
</t>
</xpath>
</t>
</templates>
@@ -0,0 +1,51 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
patch(PaymentScreen.prototype, {
async validateOrder(isForceValidate) {
if (!this.pos.config.is_spanish) {
return await super.validateOrder(...arguments);
}
const order = this.currentOrder;
this.currentOrder.to_invoice = true;
const simplified_partner =
this.pos.db.partner_by_id[this.pos.config.simplified_partner_id[0]];
if (order.canBeSimplifiedInvoiced()) {
order.is_l10n_es_simplified_invoice ??= true;
} else {
order.is_l10n_es_simplified_invoice = false;
}
order.partner ||= simplified_partner;
if (!order.is_l10n_es_simplified_invoice && order.partner.id === simplified_partner.id) {
order.partner = null;
}
return await super.validateOrder(...arguments);
},
toggleIsToInvoice() {
super.toggleIsToInvoice(...arguments);
if (
this.currentOrder.to_invoice &&
this.pos.config.is_spanish &&
this.currentOrder.canBeSimplifiedInvoiced()
) {
this.currentOrder.is_l10n_es_simplified_invoice = false;
}
},
shouldDownloadInvoice() {
return this.pos.config.is_spanish
? !this.pos.selectedOrder.is_l10n_es_simplified_invoice
: super.shouldDownloadInvoice();
},
async _postPushOrderResolve(order, order_server_ids) {
if (this.pos.config.is_spanish) {
const savedOrder = await this.orm.searchRead(
"pos.order",
[["id", "in", order_server_ids]],
["account_move"]
);
order.invoice_name = savedOrder[0].account_move[1];
}
return super._postPushOrderResolve(...arguments);
},
});
@@ -0,0 +1,23 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Order } from "@point_of_sale/app/store/models";
patch(Order.prototype, {
canBeSimplifiedInvoiced() {
return (
this.pos.config.is_spanish &&
this.env.utils.roundCurrency(this.get_total_with_tax()) <
this.pos.config.l10n_es_simplified_invoice_limit
);
},
wait_for_push_order() {
return this.pos.config.is_spanish ? true : super.wait_for_push_order(...arguments);
},
export_as_JSON() {
const json = super.export_as_JSON(...arguments);
if (this.pos.config.is_spanish) {
json.is_l10n_es_simplified_invoice = this.is_l10n_es_simplified_invoice;
}
return json;
},
});
@@ -0,0 +1,22 @@
/** @odoo-module */
import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
export function checkSimplifiedInvoiceNumber(number) {
return [
{
content: "verify that the simplified invoice number appears correctly on the receipt",
trigger: `.receipt-screen .simplified-invoice-number:contains('${number}')`,
isCheck: true,
},
];
}
export function pay() {
return [
...ProductScreen.do.clickPayButton(),
...PaymentScreen.do.clickPaymentMethod("Bank"),
...PaymentScreen.do.clickValidate(),
];
}
@@ -0,0 +1,51 @@
/** @odoo-module */
import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods";
import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods";
import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods";
import { PartnerListScreen } from "@point_of_sale/../tests/tours/helpers/PartnerListScreenTourMethods";
import { registry } from "@web/core/registry";
import { checkSimplifiedInvoiceNumber, pay } from "./helpers/receipt_helpers";
const SIMPLIFIED_INVOICE_LIMIT = 1000;
registry.category("web_tour.tours").add("spanish_pos_tour", {
test: true,
steps: () => [
...ProductScreen.do.confirmOpeningPopup(),
...ProductScreen.exec.addOrderline("Desk Pad", "1"),
...pay(),
...checkSimplifiedInvoiceNumber("0001"),
...ReceiptScreen.do.clickNextOrder(),
...ProductScreen.exec.addOrderline("Desk Pad", "1", SIMPLIFIED_INVOICE_LIMIT - 1),
...pay(),
...checkSimplifiedInvoiceNumber("0002"),
...ReceiptScreen.do.clickNextOrder(),
...ProductScreen.exec.addOrderline("Desk Pad", "1", SIMPLIFIED_INVOICE_LIMIT + 1),
...pay(),
{
content: "verify that the pos requires the selection of a partner",
trigger: `div.popup.popup-confirm .modal-header:contains('Please select the Customer') ~ footer div.button.confirm`,
},
...PartnerListScreen.do.clickPartner(""),
...PaymentScreen.check.isInvoiceOptionSelected(),
...PaymentScreen.do.clickValidate(),
{
content:
"verify that the simplified invoice number does not appear on the receipt, because this order is invoiced, so it does not have a simplified invoice number",
trigger: ".receipt-screen:not(:has(.simplified-invoice-number))",
isCheck: true,
},
...ReceiptScreen.do.clickNextOrder(),
...ProductScreen.exec.addOrderline("Desk Pad", "1"),
...pay(),
...checkSimplifiedInvoiceNumber("0003"),
],
});
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_frontend
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon
@odoo.tests.tagged('post_install_l10n', 'post_install', '-at_install')
class TestUi(TestPointOfSaleHttpCommon):
@classmethod
def _get_main_company(cls):
cls.company_data["company"].country_id = cls.env.ref("base.es").id
return cls.company_data["company"]
def test_spanish_pos(self):
simp = self.env['account.journal'].create({
'name': 'Simplified Invoice Journal',
'type': 'sale',
'company_id': self._get_main_company().id,
'code': 'SIMP',
})
def get_number_of_regular_invoices():
return self.env['account.move'].search_count([('journal_id', '=', self.main_pos_config.invoice_journal_id.id), ('l10n_es_is_simplified', '=', False), ('pos_order_ids', '!=', False)])
initial_number_of_regular_invoices = get_number_of_regular_invoices()
self.main_pos_config.l10n_es_simplified_invoice_journal_id = simp
# this `limit` value is linked to the `SIMPLIFIED_INVOICE_LIMIT` const in the tour
self.main_pos_config.l10n_es_simplified_invoice_limit = 1000
self.main_pos_config.with_user(self.pos_user).open_ui()
self.start_tour(f"/pos/ui?config_id={self.main_pos_config.id}", "spanish_pos_tour", login="pos_user")
num_of_simp_invoices = self.env['account.move'].search_count([('journal_id', '=', simp.id), ('l10n_es_is_simplified', '=', True)])
num_of_regular_invoices = get_number_of_regular_invoices() - initial_number_of_regular_invoices
self.assertEqual(num_of_simp_invoices, 3)
self.assertEqual(num_of_regular_invoices, 1)
@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<odoo>
<!-- POS order form -->
<record id="view_pos_pos_form_simplified_invoice" model="ir.ui.view">
<field name="name">pos.order.form</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form" />
<field name="arch" type="xml">
<field name="name" position="after">
<field name="l10n_es_simplified_invoice_number" readonly="1" invisible="not l10n_es_simplified_invoice_number" />
</field>
</field>
</record>
<!-- POS order tree -->
<record id="view_pos_order_tree" model="ir.ui.view">
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_order_tree" />
<field name="arch" type="xml">
<field name="pos_reference" position="before">
<field name="l10n_es_simplified_invoice_number"/>
</field>
</field>
</record>
</odoo>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//block[@id='pos_accounting_section']" position="inside">
<field name="pos_is_spanish" invisible="1" />
<setting string="Simplified Invoice Limit" title="" invisible="not pos_is_spanish">
<div class="text-muted">
Above this limit the simplified invoice won't be made
</div>
<field name="pos_l10n_es_simplified_invoice_limit" />
</setting>
</xpath>
<xpath expr="//setting[@id='pos_default_journals']" position="inside">
<div class="row">
<label string="Facturas Simplficadas" for="pos_l10n_es_simplified_invoice_journal_id" class="col-lg-3 o_light_label"/>
<field name="pos_l10n_es_simplified_invoice_journal_id"
domain="[('company_id', '=', company_id), ('type', '=', 'sale')]"
required="pos_is_spanish"
invisible="not pos_is_spanish"
context="{'default_company_id': company_id, 'default_type': 'sale'}"/>
</div>
</xpath>
</field>
</record>
</odoo>
+1 -1
View File
@@ -53,7 +53,7 @@ class PosController(PortalAccount):
pos_session = request.env['pos.session'].sudo().search(domain, limit=1)
if not pos_session or config_id and not pos_config.active:
return request.redirect('/web#action=point_of_sale.action_client_pos_menu')
# The POS only work in one company, so we enforce the one of the session in the context
# The POS only works in one company, so we enforce the one of the session in the context
company = pos_session.company_id
session_info = request.env['ir.http'].session_info()
session_info['user_context']['allowed_company_ids'] = company.ids
@@ -14,11 +14,9 @@
<br />
</t>
<div class="pos-receipt-contact">
<t t-if="_receipt.company.contact_address">
<div>
<t t-esc="_receipt.company.contact_address" />
</div>
</t>
<div t-if="_receipt.company.contact_address">
<t t-esc="_receipt.company.contact_address" />
</div>
<t t-if="_receipt.company.phone">
<div>Tel:
<t t-esc="_receipt.company.phone" />
@@ -1486,7 +1486,7 @@ export class Order extends PosModel {
this.partner = partner;
this.temporary = false; // FIXME
this.to_invoice = false; // FIXME
this.to_invoice = json.to_invoice || false;
this.shippingDate = json.shipping_date;
var orderlines = json.lines;
@@ -1,5 +1,4 @@
/** @odoo-module */
import { formatMonetary } from "@web/views/fields/formatters";
import {
formatFloat,
@@ -160,6 +160,15 @@ class Check {
},
];
}
isInvoiceOptionSelected() {
return [
{
content: "Invoice option is selected",
trigger: ".payment-buttons .js_invoice.highlight",
isCheck: true,
},
];
}
/**
* Check if the remaining is the provided amount.
+7 -2
View File
@@ -13,6 +13,10 @@ import odoo.tests
class TestPointOfSaleHttpCommon(AccountTestInvoicingHttpCommon):
@classmethod
def _get_main_company(cls):
return cls.company_data['company']
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
@@ -21,7 +25,7 @@ class TestPointOfSaleHttpCommon(AccountTestInvoicingHttpCommon):
cls.env.user.groups_id += env.ref('point_of_sale.group_pos_manager')
journal_obj = env['account.journal']
account_obj = env['account.account']
main_company = cls.company_data['company']
main_company = cls._get_main_company()
account_receivable = account_obj.create({'code': 'X1012',
'name': 'Account Receivable - Test',
@@ -40,10 +44,11 @@ class TestPointOfSaleHttpCommon(AccountTestInvoicingHttpCommon):
'groups_id': [
(4, cls.env.ref('base.group_user').id),
(4, cls.env.ref('point_of_sale.group_pos_user').id),
(4, cls.env.ref('account.group_account_invoice').id),
],
})
cls.pos_admin = cls.env['res.users'].create({
'name': 'A powerfull PoS man!',
'name': 'A powerful PoS man!',
'login': 'pos_admin',
'password': 'pos_admin',
'groups_id': [
@@ -180,7 +180,7 @@
</div>
</div>
</setting>
<setting string="Default Journals" help="Default journals for orders and invoices">
<setting string="Default Journals" id="pos_default_journals" help="Default journals for orders and invoices">
<div class="content-group mt16">
<div class="row" title="Whenever you close a session, one entry is generated in the following accounting journal for all the orders not invoiced. Invoices are recorded in accounting separately.">
<label string="Orders" for="pos_journal_id" class="col-lg-3 o_light_label" options="{'no_open': True, 'no_create': True}"/>