odoo-addon-l10n-es-aeat-sii-oca 16.0.1.3.2.1__py3-none-any.whl → 16.0.1.5.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. odoo/addons/l10n_es_aeat_sii_oca/README.rst +1 -1
  2. odoo/addons/l10n_es_aeat_sii_oca/__manifest__.py +1 -1
  3. odoo/addons/l10n_es_aeat_sii_oca/data/aeat_sii_queue_job.xml +3 -3
  4. odoo/addons/l10n_es_aeat_sii_oca/i18n/bg.po +61 -18
  5. odoo/addons/l10n_es_aeat_sii_oca/i18n/ca.po +99 -28
  6. odoo/addons/l10n_es_aeat_sii_oca/i18n/cs.po +61 -15
  7. odoo/addons/l10n_es_aeat_sii_oca/i18n/de.po +61 -15
  8. odoo/addons/l10n_es_aeat_sii_oca/i18n/es.po +122 -36
  9. odoo/addons/l10n_es_aeat_sii_oca/i18n/es_CO.po +61 -15
  10. odoo/addons/l10n_es_aeat_sii_oca/i18n/es_CR.po +61 -15
  11. odoo/addons/l10n_es_aeat_sii_oca/i18n/eu.po +61 -18
  12. odoo/addons/l10n_es_aeat_sii_oca/i18n/fr.po +61 -18
  13. odoo/addons/l10n_es_aeat_sii_oca/i18n/gl.po +61 -18
  14. odoo/addons/l10n_es_aeat_sii_oca/i18n/hr.po +61 -18
  15. odoo/addons/l10n_es_aeat_sii_oca/i18n/l10n_es_aeat_sii_oca.pot +62 -16
  16. odoo/addons/l10n_es_aeat_sii_oca/i18n/nl.po +61 -15
  17. odoo/addons/l10n_es_aeat_sii_oca/i18n/pl.po +61 -18
  18. odoo/addons/l10n_es_aeat_sii_oca/i18n/pt.po +61 -18
  19. odoo/addons/l10n_es_aeat_sii_oca/i18n/pt_BR.po +61 -18
  20. odoo/addons/l10n_es_aeat_sii_oca/i18n/ru.po +61 -15
  21. odoo/addons/l10n_es_aeat_sii_oca/i18n/sl.po +61 -18
  22. odoo/addons/l10n_es_aeat_sii_oca/i18n/sv.po +61 -15
  23. odoo/addons/l10n_es_aeat_sii_oca/i18n/tr.po +61 -15
  24. odoo/addons/l10n_es_aeat_sii_oca/i18n/vi.po +61 -15
  25. odoo/addons/l10n_es_aeat_sii_oca/migrations/16.0.1.4.0/post-migration.py +18 -0
  26. odoo/addons/l10n_es_aeat_sii_oca/models/__init__.py +1 -0
  27. odoo/addons/l10n_es_aeat_sii_oca/models/account_move.py +126 -693
  28. odoo/addons/l10n_es_aeat_sii_oca/models/sii_mixin.py +915 -0
  29. odoo/addons/l10n_es_aeat_sii_oca/static/description/index.html +1 -1
  30. odoo/addons/l10n_es_aeat_sii_oca/tests/test_l10n_es_aeat_sii.py +38 -43
  31. {odoo_addon_l10n_es_aeat_sii_oca-16.0.1.3.2.1.dist-info → odoo_addon_l10n_es_aeat_sii_oca-16.0.1.5.0.1.dist-info}/METADATA +2 -2
  32. {odoo_addon_l10n_es_aeat_sii_oca-16.0.1.3.2.1.dist-info → odoo_addon_l10n_es_aeat_sii_oca-16.0.1.5.0.1.dist-info}/RECORD +34 -32
  33. {odoo_addon_l10n_es_aeat_sii_oca-16.0.1.3.2.1.dist-info → odoo_addon_l10n_es_aeat_sii_oca-16.0.1.5.0.1.dist-info}/WHEEL +0 -0
  34. {odoo_addon_l10n_es_aeat_sii_oca-16.0.1.3.2.1.dist-info → odoo_addon_l10n_es_aeat_sii_oca-16.0.1.5.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,915 @@
1
+ # Copyright 2021 Tecnativa - João Marques
2
+ # Copyright 2022 ForgeFlow - Lois Rilo
3
+ # Copyright 2011-2023 Tecnativa - Pedro M. Baeza
4
+ # Copyright 2023 Aures Tic - Almudena de la Puente <almudena@aurestic.es>
5
+ # Copyright 2023 Aures Tic - Jose Zambudio <jose@aurestic.es>
6
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
7
+ import json
8
+ import logging
9
+
10
+ from requests import Session
11
+
12
+ from odoo import _, api, exceptions, fields, models
13
+ from odoo.exceptions import UserError, ValidationError
14
+ from odoo.modules.registry import Registry
15
+ from odoo.tools.float_utils import float_compare
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+ try:
20
+ from zeep import Client
21
+ from zeep.plugins import HistoryPlugin
22
+ from zeep.transports import Transport
23
+ except (ImportError, IOError) as err:
24
+ _logger.debug(err)
25
+
26
+ SII_STATES = [
27
+ ("not_sent", "Not sent"),
28
+ ("sent", "Sent"),
29
+ ("sent_w_errors", "Accepted with errors"),
30
+ ("sent_modified", "Registered in SII but last modifications not sent"),
31
+ ("cancelled", "Cancelled"),
32
+ ("cancelled_modified", "Cancelled in SII but last modifications not sent"),
33
+ ]
34
+ SII_VERSION = "1.1"
35
+ SII_MACRODATA_LIMIT = 100000000.0
36
+ SII_DATE_FORMAT = "%d-%m-%Y"
37
+
38
+
39
+ def round_by_keys(elem, search_keys, prec=2):
40
+ """This uses ``round`` method directly as if has been tested that Odoo's
41
+ ``float_round`` still returns incorrect amounts for certain values. Try
42
+ 3 units x 3,77 €/unit with 10% tax and you will be hit by the error
43
+ (on regular x86 architectures)."""
44
+ if isinstance(elem, dict):
45
+ for key, value in elem.items():
46
+ if key in search_keys:
47
+ elem[key] = round(elem[key], prec)
48
+ else:
49
+ round_by_keys(value, search_keys)
50
+ elif isinstance(elem, list):
51
+ for value in elem:
52
+ round_by_keys(value, search_keys)
53
+
54
+
55
+ class SiiMixin(models.AbstractModel):
56
+ _name = "sii.mixin"
57
+ _description = "SII Mixin"
58
+
59
+ company_id = fields.Many2one(
60
+ comodel_name="res.company",
61
+ string="Company",
62
+ )
63
+ sii_description = fields.Text(
64
+ string="SII computed description",
65
+ compute="_compute_sii_description",
66
+ default="/",
67
+ store=True,
68
+ readonly=False,
69
+ copy=False,
70
+ )
71
+ sii_state = fields.Selection(
72
+ selection=SII_STATES,
73
+ string="SII send state",
74
+ default="not_sent",
75
+ readonly=True,
76
+ copy=False,
77
+ help="Indicates the state of this document in relation with the "
78
+ "presentation at the SII",
79
+ )
80
+ sii_csv = fields.Char(string="SII CSV", copy=False, readonly=True)
81
+ sii_return = fields.Text(string="SII Return", copy=False, readonly=True)
82
+ sii_header_sent = fields.Text(
83
+ string="SII last header sent",
84
+ copy=False,
85
+ readonly=True,
86
+ )
87
+ sii_content_sent = fields.Text(
88
+ string="SII last content sent",
89
+ copy=False,
90
+ readonly=True,
91
+ )
92
+ sii_send_error = fields.Text(
93
+ string="SII Send Error",
94
+ readonly=True,
95
+ copy=False,
96
+ )
97
+ sii_send_failed = fields.Boolean(
98
+ string="SII send failed",
99
+ copy=False,
100
+ help="Indicates that the last attempt to communicate this document to "
101
+ "the SII has failed. See SII return for details",
102
+ )
103
+ sii_refund_type = fields.Selection(
104
+ selection=[
105
+ # ('S', 'By substitution'), - Removed as not fully supported
106
+ ("I", "By differences"),
107
+ ],
108
+ string="SII Refund Type",
109
+ compute="_compute_sii_refund_type",
110
+ store=True,
111
+ readonly=False,
112
+ )
113
+ sii_account_registration_date = fields.Date(
114
+ string="SII account registration date",
115
+ readonly=True,
116
+ copy=False,
117
+ help="Indicates the account registration date set at the SII, which "
118
+ "must be the date when the document is recorded in the system and "
119
+ "is independent of the date of the accounting entry of the "
120
+ "document",
121
+ )
122
+ sii_registration_key_domain = fields.Char(
123
+ compute="_compute_sii_registration_key_domain",
124
+ string="SII registration key domain",
125
+ )
126
+ sii_registration_key = fields.Many2one(
127
+ comodel_name="aeat.sii.mapping.registration.keys",
128
+ string="SII registration key",
129
+ compute="_compute_sii_registration_key",
130
+ store=True,
131
+ readonly=False,
132
+ # required=True, This is not set as required here to avoid the
133
+ # set not null constraint warning
134
+ )
135
+ sii_registration_key_code = fields.Char(
136
+ compute="_compute_sii_registration_key_code",
137
+ readonly=True,
138
+ string="SII Code",
139
+ )
140
+ sii_enabled = fields.Boolean(
141
+ string="Enable SII",
142
+ compute="_compute_sii_enabled",
143
+ )
144
+ sii_macrodata = fields.Boolean(
145
+ string="MacroData",
146
+ help="Check to confirm that the document has an absolute amount "
147
+ "greater o equal to 100 000 000,00 euros.",
148
+ compute="_compute_macrodata",
149
+ )
150
+
151
+ def _compute_sii_refund_type(self):
152
+ self.sii_refund_type = False
153
+
154
+ def _compute_sii_description(self):
155
+ self.sii_description = "/"
156
+
157
+ def _compute_sii_registration_key_domain(self):
158
+ for document in self:
159
+ mapping_key = document._get_mapping_key()
160
+ if mapping_key in {"out_invoice", "out_refund"}:
161
+ document.sii_registration_key_domain = "sale"
162
+ elif mapping_key in {"in_invoice", "in_refund"}:
163
+ document.sii_registration_key_domain = "purchase"
164
+ else:
165
+ document.sii_registration_key_domain = False
166
+
167
+ @api.depends("fiscal_position_id")
168
+ def _compute_sii_registration_key(self):
169
+ for document in self:
170
+ mapping_key = document._get_mapping_key()
171
+ if document.fiscal_position_id:
172
+ if "out" in mapping_key:
173
+ key = document.fiscal_position_id.sii_registration_key_sale
174
+ else:
175
+ key = document.fiscal_position_id.sii_registration_key_purchase
176
+ # Only assign sii_registration_key if it's set in the fiscal position
177
+ if key:
178
+ document.sii_registration_key = key
179
+ else:
180
+ domain = [
181
+ ("code", "=", "01"),
182
+ (
183
+ "type",
184
+ "=",
185
+ "sale" if mapping_key.startswith("out_") else "purchase",
186
+ ),
187
+ ]
188
+ sii_key_obj = self.env["aeat.sii.mapping.registration.keys"]
189
+ document.sii_registration_key = sii_key_obj.search(domain, limit=1)
190
+
191
+ @api.depends("sii_registration_key")
192
+ def _compute_sii_registration_key_code(self):
193
+ """
194
+ Para evitar tiempos de instalación largos en BBDD grandes, es necesario que
195
+ sólo dependa de sii_registration_key, ya que en caso de añadirlo odoo buscará
196
+ todos los movimientos y cuando escribamos el key, aunque sea un campo no almacenado
197
+ A partir de v16.0 este cambio ya no es necesario, ya que el sistema ya revisa que el
198
+ campo sea almacenado o que este visualizandose (en caché)
199
+ """
200
+ for record in self:
201
+ record.sii_registration_key_code = record.sii_registration_key.code
202
+
203
+ def _compute_sii_enabled(self):
204
+ raise NotImplementedError
205
+
206
+ def _compute_macrodata(self):
207
+ for document in self:
208
+ document.sii_macrodata = (
209
+ float_compare(
210
+ abs(document._get_document_amount_total()),
211
+ SII_MACRODATA_LIMIT,
212
+ precision_digits=2,
213
+ )
214
+ >= 0
215
+ )
216
+
217
+ def _sii_get_partner(self):
218
+ raise NotImplementedError
219
+
220
+ def _get_sii_country_code(self):
221
+ self.ensure_one()
222
+ return self._sii_get_partner()._parse_aeat_vat_info()[0]
223
+
224
+ def _filter_sii_unlink_not_possible(self):
225
+ """Filter records that we do not allow to be deleted, all those
226
+ that are not in not_sent sii status."""
227
+ return self.filtered(lambda rec: rec.sii_state != "not_sent")
228
+
229
+ @api.ondelete(at_uninstall=False)
230
+ def _unlink_except_sii(self):
231
+ """Do not allow the deletion of records already sent to the SII."""
232
+ if self._filter_sii_unlink_not_possible():
233
+ raise exceptions.UserError(
234
+ _("You cannot delete an invoice already registered at the SII.")
235
+ )
236
+
237
+ @api.model
238
+ def _get_sii_taxes_map(self, codes, date):
239
+ """Return the codes that correspond to that sii map line codes.
240
+
241
+ :param codes: List of code strings to get the mapping.
242
+ :param date: Date to map
243
+ :return: Recordset with the corresponding codes
244
+ """
245
+ map_obj = self.env["aeat.sii.map"].sudo()
246
+ sii_map = map_obj.search(
247
+ [
248
+ "|",
249
+ ("date_from", "<=", date),
250
+ ("date_from", "=", False),
251
+ "|",
252
+ ("date_to", ">=", date),
253
+ ("date_to", "=", False),
254
+ ],
255
+ limit=1,
256
+ )
257
+ tax_templates = sii_map.map_lines.filtered(lambda x: x.code in codes).taxes
258
+ return self.company_id.get_taxes_from_templates(tax_templates)
259
+
260
+ def _change_date_format(self, date):
261
+ datetimeobject = fields.Date.to_date(date)
262
+ new_date = datetimeobject.strftime(SII_DATE_FORMAT)
263
+ return new_date
264
+
265
+ def _get_sii_header(self, tipo_comunicacion=False, cancellation=False):
266
+ """Builds SII send header
267
+
268
+ :param tipo_comunicacion String 'A0': new reg, 'A1': modification
269
+ :param cancellation Bool True when the communitacion es for document
270
+ cancellation
271
+ :return Dict with header data depending on cancellation
272
+ """
273
+ self.ensure_one()
274
+ if not self.company_id.vat:
275
+ raise UserError(
276
+ _("No VAT configured for the company '{}'").format(self.company_id.name)
277
+ )
278
+ header = {
279
+ "IDVersionSii": SII_VERSION,
280
+ "Titular": {
281
+ "NombreRazon": self.company_id.name[0:120],
282
+ "NIF": self.company_id.partner_id._parse_aeat_vat_info()[2],
283
+ },
284
+ }
285
+ if not cancellation:
286
+ header.update({"TipoComunicacion": tipo_comunicacion})
287
+ return header
288
+
289
+ def _get_sii_jobs_field_name(self):
290
+ raise NotImplementedError()
291
+
292
+ def _cancel_sii_jobs(self):
293
+ for queue in self.sudo().mapped(self._get_sii_jobs_field_name()):
294
+ if queue.state == "started":
295
+ return False
296
+ elif queue.state in ("pending", "enqueued", "failed"):
297
+ queue.unlink()
298
+ return True
299
+
300
+ def _get_valid_document_states(self):
301
+ raise NotImplementedError()
302
+
303
+ def send_sii(self):
304
+ documents = self.filtered(
305
+ lambda document: (
306
+ document.sii_enabled
307
+ and document.state in self._get_valid_document_states()
308
+ and document.sii_state not in ["sent", "cancelled"]
309
+ )
310
+ )
311
+ if not documents._cancel_sii_jobs():
312
+ raise UserError(
313
+ _(
314
+ "You can not communicate this document at this moment "
315
+ "because there is a job running!"
316
+ )
317
+ )
318
+ documents._process_sii_send()
319
+
320
+ def _process_sii_send(self):
321
+ """Process document sending to the SII. Adds general checks from
322
+ configuration parameters and document availability for SII. If the
323
+ document is to be sent the decides the send method: direct send or
324
+ via connector depending on 'Use connector' configuration"""
325
+ queue_obj = self.env["queue.job"].sudo()
326
+ for record in self:
327
+ company = record.company_id
328
+ if not company.use_connector:
329
+ record.confirm_one_document()
330
+ else:
331
+ eta = company._get_sii_eta()
332
+ new_delay = (
333
+ record.sudo()
334
+ .with_context(company_id=company.id)
335
+ .with_delay(eta=eta if not record.sii_send_failed else False)
336
+ .confirm_one_document()
337
+ )
338
+ job = queue_obj.search([("uuid", "=", new_delay.uuid)], limit=1)
339
+ setattr(record.sudo(), self._get_sii_jobs_field_name(), [(4, job.id)])
340
+
341
+ def _bind_sii(self, client, port_name, address=None):
342
+ self.ensure_one()
343
+ service = client._get_service("siiService")
344
+ port = client._get_port(service, port_name)
345
+ address = address or port.binding_options["address"]
346
+ return client.create_service(port.binding.name, address)
347
+
348
+ def _connect_params_sii(self, mapping_key):
349
+ self.ensure_one()
350
+ agency = self.company_id.tax_agency_id
351
+ if not agency:
352
+ # We use spanish agency by default to keep old behavior with
353
+ # ir.config parameters. In the future it might be good to reinforce
354
+ # to explicitly set a tax agency in the company by raising an error
355
+ # here.
356
+ agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain")
357
+ return agency._connect_params_sii(mapping_key, self.company_id)
358
+
359
+ def _connect_sii(self, mapping_key):
360
+ self.ensure_one()
361
+ public_crt, private_key = self.env["l10n.es.aeat.certificate"].get_certificates(
362
+ company=self.company_id
363
+ )
364
+ params = self._connect_params_sii(mapping_key)
365
+ session = Session()
366
+ session.cert = (public_crt, private_key)
367
+ transport = Transport(session=session)
368
+ history = HistoryPlugin()
369
+ client = Client(wsdl=params["wsdl"], transport=transport, plugins=[history])
370
+ return self._bind_sii(client, params["port_name"], params["address"])
371
+
372
+ def _get_sii_gen_type(self):
373
+ """Make a choice for general invoice type
374
+
375
+ Returns:
376
+ int: 1 (National), 2 (Intracom), 3 (Export)
377
+ """
378
+ self.ensure_one()
379
+ partner_ident = self.fiscal_position_id.sii_partner_identification_type
380
+ if partner_ident:
381
+ res = int(partner_ident)
382
+ elif self.fiscal_position_id.name == "Régimen Intracomunitario":
383
+ res = 2
384
+ elif self.fiscal_position_id.name == "Régimen Extracomunitario":
385
+ res = 3
386
+ else:
387
+ res = 1
388
+ return res
389
+
390
+ def _is_sii_simplified_invoice(self):
391
+ """Inheritable method to allow control when an
392
+ invoice are simplified or normal"""
393
+ partner = self._sii_get_partner()
394
+ return partner.sii_simplified_invoice
395
+
396
+ def _sii_check_exceptions(self):
397
+ """Inheritable method for exceptions control when sending SII invoices."""
398
+ self.ensure_one()
399
+ gen_type = self._get_sii_gen_type()
400
+ partner = self._sii_get_partner()
401
+ country_code = self._get_sii_country_code()
402
+ is_simplified_invoice = self._is_sii_simplified_invoice()
403
+ if (
404
+ (gen_type != 3 or country_code == "ES")
405
+ and not partner.vat
406
+ and not is_simplified_invoice
407
+ ):
408
+ raise UserError(_("The partner has not a VAT configured."))
409
+ if not self.company_id.chart_template_id:
410
+ raise UserError(
411
+ _("You have to select what account chart template use this" " company.")
412
+ )
413
+ if not self.company_id.sii_enabled:
414
+ raise UserError(_("This company doesn't have SII enabled."))
415
+ if not self.sii_enabled:
416
+ raise UserError(_("This invoice is not SII enabled."))
417
+
418
+ def _get_mapping_key(self):
419
+ raise NotImplementedError()
420
+
421
+ def _get_document_date(self):
422
+ raise NotImplementedError()
423
+
424
+ def _get_document_fiscal_date(self):
425
+ raise NotImplementedError()
426
+
427
+ def _get_document_fiscal_year(self):
428
+ return fields.Date.to_date(self._get_document_fiscal_date()).year
429
+
430
+ def _get_document_period(self):
431
+ return "%02d" % fields.Date.to_date(self._get_document_fiscal_date()).month
432
+
433
+ def _get_document_serial_number(self):
434
+ raise NotImplementedError()
435
+
436
+ def _get_document_product_exempt(self, applied_taxes):
437
+ raise NotImplementedError()
438
+
439
+ def _get_sii_exempt_cause(self, applied_taxes):
440
+ """Código de la causa de exención según 3.6 y 3.7 de la FAQ del SII.
441
+
442
+ :param applied_taxes: Taxes that are exempt for filtering the lines.
443
+ """
444
+ self.ensure_one()
445
+ gen_type = self._get_sii_gen_type()
446
+ if gen_type == 2:
447
+ return "E5"
448
+ else:
449
+ exempt_cause = False
450
+ product_exempt_causes = self._get_document_product_exempt(applied_taxes)
451
+ if len(product_exempt_causes) > 1:
452
+ raise UserError(
453
+ _("Currently there's no support for multiple exempt causes.")
454
+ )
455
+ if product_exempt_causes:
456
+ exempt_cause = product_exempt_causes.pop()
457
+ elif (
458
+ self.fiscal_position_id.sii_exempt_cause
459
+ and self.fiscal_position_id.sii_exempt_cause != "none"
460
+ ):
461
+ exempt_cause = self.fiscal_position_id.sii_exempt_cause
462
+ if gen_type == 3 and exempt_cause not in ["E2", "E3"]:
463
+ exempt_cause = "E2"
464
+ return exempt_cause
465
+
466
+ def _get_tax_info(self):
467
+ raise NotImplementedError()
468
+
469
+ def _get_sii_tax_req(self, tax):
470
+ """Get the associated req tax for the specified tax.
471
+
472
+ :param self: Single invoice record.
473
+ :param tax: Initial tax for searching for the RE linked tax.
474
+ :return: REQ tax (or empty recordset) linked to the provided tax.
475
+ """
476
+ raise NotImplementedError()
477
+
478
+ @api.model
479
+ def _get_sii_tax_dict(self, tax_line, tax_lines):
480
+ """Get the SII tax dictionary for the passed tax line.
481
+
482
+ :param self: Single invoice record.
483
+ :param tax_line: Tax line that is being analyzed.
484
+ :param tax_lines: Dictionary of processed invoice taxes for further operations
485
+ (like REQ).
486
+ :return: A dictionary with the corresponding SII tax values.
487
+ """
488
+ tax = tax_line["tax"]
489
+ tax_base_amount = tax_line["base"]
490
+ if tax.amount_type == "group":
491
+ tax_type = abs(tax.children_tax_ids.filtered("amount")[:1].amount)
492
+ else:
493
+ tax_type = abs(tax.amount)
494
+ tax_dict = {"TipoImpositivo": str(tax_type), "BaseImponible": tax_base_amount}
495
+ if self._get_mapping_key() in ["out_invoice", "out_refund"]:
496
+ key = "CuotaRepercutida"
497
+ else:
498
+ key = "CuotaSoportada"
499
+ tax_dict[key] = tax_line["amount"]
500
+ # Recargo de equivalencia
501
+ req_tax = self._get_sii_tax_req(tax)
502
+ if req_tax:
503
+ tax_dict["TipoRecargoEquivalencia"] = req_tax.amount
504
+ tax_dict["CuotaRecargoEquivalencia"] = tax_lines[req_tax]["amount"]
505
+ return tax_dict
506
+
507
+ def _get_no_taxable_cause(self):
508
+ self.ensure_one()
509
+ return (
510
+ self.fiscal_position_id.sii_no_taxable_cause
511
+ or "ImporteTAIReglasLocalizacion"
512
+ )
513
+
514
+ def _is_sii_type_breakdown_required(self, taxes_dict):
515
+ """Calculates if the block 'DesgloseTipoOperacion' is required for
516
+ the invoice communication."""
517
+ self.ensure_one()
518
+ if "DesgloseFactura" not in taxes_dict:
519
+ return False
520
+ country_code = self._get_sii_country_code()
521
+ sii_gen_type = self._get_sii_gen_type()
522
+ if "DesgloseTipoOperacion" in taxes_dict:
523
+ # DesgloseTipoOperacion and DesgloseFactura are Exclusive
524
+ return True
525
+ elif sii_gen_type in (2, 3):
526
+ # DesgloseTipoOperacion required for Intracommunity and
527
+ # Export operations
528
+ return True
529
+ elif sii_gen_type == 1 and country_code != "ES":
530
+ # DesgloseTipoOperacion required for national operations
531
+ # with 'IDOtro' in the SII identifier block
532
+ return True
533
+ elif sii_gen_type == 1 and (self._sii_get_partner().vat or "").startswith(
534
+ "ESN"
535
+ ):
536
+ # DesgloseTipoOperacion required if customer's country is Spain and
537
+ # has a NIF which starts with 'N'
538
+ return True
539
+ return False
540
+
541
+ def _get_sii_out_taxes(self): # noqa: C901
542
+ """Get the taxes for sales documents.
543
+
544
+ :param self: Single document record.
545
+ """
546
+ self.ensure_one()
547
+ taxes_dict = {}
548
+ date = self._get_document_fiscal_date()
549
+ taxes_sfesb = self._get_sii_taxes_map(["SFESB"], date)
550
+ taxes_sfesbe = self._get_sii_taxes_map(["SFESBE"], date)
551
+ taxes_sfesisp = self._get_sii_taxes_map(["SFESISP"], date)
552
+ # taxes_sfesisps = self._get_taxes_map(['SFESISPS'])
553
+ taxes_sfens = self._get_sii_taxes_map(["SFENS"], date)
554
+ taxes_sfess = self._get_sii_taxes_map(["SFESS"], date)
555
+ taxes_sfesse = self._get_sii_taxes_map(["SFESSE"], date)
556
+ taxes_sfesns = self._get_sii_taxes_map(["SFESNS"], date)
557
+ taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"], date)
558
+ taxes_not_in_total_neg = self._get_sii_taxes_map(
559
+ ["NotIncludedInTotalNegative"], date
560
+ )
561
+ base_not_in_total = self._get_sii_taxes_map(["BaseNotIncludedInTotal"], date)
562
+ not_in_amount_total = 0
563
+ exempt_cause = self._get_sii_exempt_cause(taxes_sfesbe + taxes_sfesse)
564
+ tax_lines = self._get_tax_info()
565
+ for tax_line in tax_lines.values():
566
+ tax = tax_line["tax"]
567
+ breakdown_taxes = taxes_sfesb + taxes_sfesisp + taxes_sfens + taxes_sfesbe
568
+ if tax in taxes_not_in_total:
569
+ not_in_amount_total += tax_line["amount"]
570
+ elif tax in taxes_not_in_total_neg:
571
+ not_in_amount_total -= tax_line["amount"]
572
+ elif tax in base_not_in_total:
573
+ not_in_amount_total += tax_line["base"]
574
+ if tax in breakdown_taxes:
575
+ tax_breakdown = taxes_dict.setdefault("DesgloseFactura", {})
576
+ if tax in (taxes_sfesb + taxes_sfesbe + taxes_sfesisp):
577
+ sub_dict = tax_breakdown.setdefault("Sujeta", {})
578
+ # TODO l10n_es no tiene impuesto exento de bienes
579
+ # corrientes nacionales
580
+ if tax in taxes_sfesbe:
581
+ exempt_dict = sub_dict.setdefault(
582
+ "Exenta",
583
+ {"DetalleExenta": [{"BaseImponible": 0}]},
584
+ )
585
+ det_dict = exempt_dict["DetalleExenta"][0]
586
+ if exempt_cause:
587
+ det_dict["CausaExencion"] = exempt_cause
588
+ det_dict["BaseImponible"] += tax_line["base"]
589
+ else:
590
+ sub_dict.setdefault(
591
+ "NoExenta",
592
+ {
593
+ "TipoNoExenta": ("S2" if tax in taxes_sfesisp else "S1"),
594
+ "DesgloseIVA": {"DetalleIVA": []},
595
+ },
596
+ )
597
+ not_ex_type = sub_dict["NoExenta"]["TipoNoExenta"]
598
+ if tax in taxes_sfesisp:
599
+ is_s3 = not_ex_type == "S1"
600
+ else:
601
+ is_s3 = not_ex_type == "S2"
602
+ if is_s3:
603
+ sub_dict["NoExenta"]["TipoNoExenta"] = "S3"
604
+ sub_dict["NoExenta"]["DesgloseIVA"]["DetalleIVA"].append(
605
+ self._get_sii_tax_dict(tax_line, tax_lines),
606
+ )
607
+ # No sujetas
608
+ if tax in taxes_sfens:
609
+ # ImporteTAIReglasLocalizacion or ImportePorArticulos7_14_Otros
610
+ default_no_taxable_cause = self._get_no_taxable_cause()
611
+ nsub_dict = tax_breakdown.setdefault(
612
+ "NoSujeta",
613
+ {default_no_taxable_cause: 0},
614
+ )
615
+ nsub_dict[default_no_taxable_cause] += tax_line["base"]
616
+ if tax in (taxes_sfess + taxes_sfesse + taxes_sfesns):
617
+ type_breakdown = taxes_dict.setdefault(
618
+ "DesgloseTipoOperacion",
619
+ {"PrestacionServicios": {}},
620
+ )
621
+ if tax in (taxes_sfesse + taxes_sfess):
622
+ type_breakdown["PrestacionServicios"].setdefault("Sujeta", {})
623
+ service_dict = type_breakdown["PrestacionServicios"]
624
+ if tax in taxes_sfesse:
625
+ exempt_dict = service_dict["Sujeta"].setdefault(
626
+ "Exenta",
627
+ {"DetalleExenta": [{"BaseImponible": 0}]},
628
+ )
629
+ det_dict = exempt_dict["DetalleExenta"][0]
630
+ if exempt_cause:
631
+ det_dict["CausaExencion"] = exempt_cause
632
+ det_dict["BaseImponible"] += tax_line["base"]
633
+ if tax in taxes_sfess:
634
+ # TODO l10n_es_ no tiene impuesto ISP de servicios
635
+ # if tax in taxes_sfesisps:
636
+ # TipoNoExenta = 'S2'
637
+ # else:
638
+ service_dict["Sujeta"].setdefault(
639
+ "NoExenta",
640
+ {"TipoNoExenta": "S1", "DesgloseIVA": {"DetalleIVA": []}},
641
+ )
642
+ sub = type_breakdown["PrestacionServicios"]["Sujeta"]["NoExenta"][
643
+ "DesgloseIVA"
644
+ ]["DetalleIVA"]
645
+ sub.append(self._get_sii_tax_dict(tax_line, tax_lines))
646
+ if tax in taxes_sfesns:
647
+ nsub_dict = service_dict.setdefault(
648
+ "NoSujeta",
649
+ {"ImporteTAIReglasLocalizacion": 0},
650
+ )
651
+ nsub_dict["ImporteTAIReglasLocalizacion"] += tax_line["base"]
652
+ # Ajustes finales breakdown
653
+ # - DesgloseFactura y DesgloseTipoOperacion son excluyentes
654
+ # - Ciertos condicionantes obligan DesgloseTipoOperacion
655
+ if self._is_sii_type_breakdown_required(taxes_dict):
656
+ taxes_dict.setdefault("DesgloseTipoOperacion", {})
657
+ taxes_dict["DesgloseTipoOperacion"]["Entrega"] = taxes_dict[
658
+ "DesgloseFactura"
659
+ ]
660
+ del taxes_dict["DesgloseFactura"]
661
+ return taxes_dict, not_in_amount_total
662
+
663
+ def _get_document_amount_total(self):
664
+ raise NotImplementedError()
665
+
666
+ def _get_sii_invoice_type(self):
667
+ raise NotImplementedError()
668
+
669
+ def _get_sii_identifier(self):
670
+ """Get the SII structure for a partner identifier depending on the
671
+ conditions of the invoice.
672
+ """
673
+ self.ensure_one()
674
+ gen_type = self._get_sii_gen_type()
675
+ (
676
+ country_code,
677
+ identifier_type,
678
+ identifier,
679
+ ) = self._sii_get_partner()._parse_aeat_vat_info()
680
+ # Limpiar alfanum
681
+ if identifier:
682
+ identifier = "".join(e for e in identifier if e.isalnum()).upper()
683
+ else:
684
+ identifier = "NO_DISPONIBLE"
685
+ identifier_type = "06"
686
+ if gen_type == 1:
687
+ if "1117" in (self.sii_send_error or ""):
688
+ return {
689
+ "IDOtro": {
690
+ "CodigoPais": country_code,
691
+ "IDType": "07",
692
+ "ID": identifier,
693
+ }
694
+ }
695
+ else:
696
+ if identifier_type == "":
697
+ return {"NIF": identifier}
698
+ return {
699
+ "IDOtro": {
700
+ "CodigoPais": country_code,
701
+ "IDType": identifier_type,
702
+ "ID": country_code + identifier
703
+ if self._sii_get_partner()._map_aeat_country_code(country_code)
704
+ in self._sii_get_partner()._get_aeat_europe_codes()
705
+ else identifier,
706
+ },
707
+ }
708
+ elif gen_type == 2:
709
+ return {"IDOtro": {"IDType": "02", "ID": country_code + identifier}}
710
+ elif gen_type == 3 and identifier_type:
711
+ # Si usamos identificador tipo 02 en exportaciones, el envío falla con:
712
+ # {'CodigoErrorRegistro': 1104,
713
+ # 'DescripcionErrorRegistro': 'Valor del campo ID incorrecto'}
714
+ if identifier_type == "02":
715
+ identifier_type = "06"
716
+ return {
717
+ "IDOtro": {
718
+ "CodigoPais": country_code,
719
+ "IDType": identifier_type,
720
+ "ID": identifier,
721
+ },
722
+ }
723
+ elif gen_type == 3:
724
+ return {"NIF": identifier}
725
+
726
+ def _get_sii_invoice_dict_out(self, cancel=False):
727
+ """Build dict with data to send to AEAT WS for document types:
728
+ out_invoice and out_refund.
729
+
730
+ :param cancel: It indicates if the dictionary is for sending a
731
+ cancellation of the document.
732
+ :return: documents (dict) : Dict XML with data for this document.
733
+ """
734
+ self.ensure_one()
735
+ document_date = self._change_date_format(self._get_document_date())
736
+ partner = self._sii_get_partner()
737
+ company = self.company_id
738
+ fiscal_year = self._get_document_fiscal_year()
739
+ period = self._get_document_period()
740
+ is_simplified_invoice = self._is_sii_simplified_invoice()
741
+ serial_number = self._get_document_serial_number()
742
+ inv_dict = {
743
+ "IDFactura": {
744
+ "IDEmisorFactura": {
745
+ "NIF": company.partner_id._parse_aeat_vat_info()[2]
746
+ },
747
+ # On cancelled invoices, number is not filled
748
+ "NumSerieFacturaEmisor": serial_number,
749
+ "FechaExpedicionFacturaEmisor": document_date,
750
+ },
751
+ "PeriodoLiquidacion": {
752
+ "Ejercicio": fiscal_year,
753
+ "Periodo": period,
754
+ },
755
+ }
756
+ if not cancel:
757
+ tipo_desglose, not_in_amount_total = self._get_sii_out_taxes()
758
+ amount_total = self._get_document_amount_total() - not_in_amount_total
759
+ inv_dict["FacturaExpedida"] = {
760
+ "TipoFactura": self._get_sii_invoice_type(),
761
+ "ClaveRegimenEspecialOTrascendencia": (self.sii_registration_key.code),
762
+ "DescripcionOperacion": self.sii_description,
763
+ "TipoDesglose": tipo_desglose,
764
+ "ImporteTotal": amount_total,
765
+ }
766
+ if self.sii_macrodata:
767
+ inv_dict["FacturaExpedida"].update(Macrodato="S")
768
+ exp_dict = inv_dict["FacturaExpedida"]
769
+ if not is_simplified_invoice:
770
+ # Simplified invoices don't have counterpart
771
+ exp_dict["Contraparte"] = {
772
+ "NombreRazon": partner.name[0:120],
773
+ }
774
+ # Uso condicional de IDOtro/NIF
775
+ exp_dict["Contraparte"].update(self._get_sii_identifier())
776
+ return inv_dict
777
+
778
+ def _get_sii_invoice_dict_in(self, cancel=False):
779
+ """Build dict with data to send to AEAT WS for invoice types:
780
+ in_invoice and in_refund.
781
+
782
+ :param cancel: It indicates if the dictionary if for sending a
783
+ cancellation of the invoice.
784
+ :return: invoices (dict) : Dict XML with data for this invoice.
785
+ """
786
+ raise NotImplementedError()
787
+
788
+ def _get_sii_invoice_dict(self):
789
+ self.ensure_one()
790
+ self._sii_check_exceptions()
791
+ inv_dict = {}
792
+ mapping_key = self._get_mapping_key()
793
+ if mapping_key in ["out_invoice", "out_refund"]:
794
+ inv_dict = self._get_sii_invoice_dict_out()
795
+ elif mapping_key in ["in_invoice", "in_refund"]:
796
+ inv_dict = self._get_sii_invoice_dict_in()
797
+ round_by_keys(
798
+ inv_dict,
799
+ [
800
+ "BaseImponible",
801
+ "CuotaRepercutida",
802
+ "CuotaSoportada",
803
+ "TipoRecargoEquivalencia",
804
+ "CuotaRecargoEquivalencia",
805
+ "ImportePorArticulos7_14_Otros",
806
+ "ImporteTAIReglasLocalizacion",
807
+ "ImporteTotal",
808
+ "BaseRectificada",
809
+ "CuotaRectificada",
810
+ "CuotaDeducible",
811
+ "ImporteCompensacionREAGYP",
812
+ ],
813
+ )
814
+ return inv_dict
815
+
816
+ def _get_account_registration_date(self):
817
+ """Hook method to allow the setting of the account registration date
818
+ of each supplier invoice. The SII recommends to set the send date as
819
+ the default value (point 9.3 of the document
820
+ SII_Descripcion_ServicioWeb_v0.7.pdf), so by default we return
821
+ the current date or, if exists, the stored
822
+ sii_account_registration_date
823
+ :return String date in the format %Y-%m-%d"""
824
+ self.ensure_one()
825
+ return self.sii_account_registration_date or fields.Date.today()
826
+
827
+ def _send_document_to_sii(self):
828
+ for document in self.filtered(
829
+ lambda i: i.state in self._get_valid_document_states()
830
+ ):
831
+ if document.sii_state == "not_sent":
832
+ tipo_comunicacion = "A0"
833
+ else:
834
+ tipo_comunicacion = "A1"
835
+ header = document._get_sii_header(tipo_comunicacion)
836
+ doc_vals = {
837
+ "sii_header_sent": json.dumps(header, indent=4),
838
+ }
839
+ # add this extra try except in case _get_sii_invoice_dict fails
840
+ # if not, get the value doc_dict for the next try and except below
841
+ try:
842
+ inv_dict = document._get_sii_invoice_dict()
843
+ except Exception as fault:
844
+ raise ValidationError(fault) from fault
845
+ try:
846
+ mapping_key = document._get_mapping_key()
847
+ serv = document._connect_sii(mapping_key)
848
+ doc_vals["sii_content_sent"] = json.dumps(inv_dict, indent=4)
849
+ if mapping_key in ["out_invoice", "out_refund"]:
850
+ res = serv.SuministroLRFacturasEmitidas(header, inv_dict)
851
+ elif mapping_key in ["in_invoice", "in_refund"]:
852
+ res = serv.SuministroLRFacturasRecibidas(header, inv_dict)
853
+ # TODO Facturas intracomunitarias 66 RIVA
854
+ # elif invoice.fiscal_position_id.id == self.env.ref(
855
+ # 'account.fp_intra').id:
856
+ # res = serv.SuministroLRDetOperacionIntracomunitaria(
857
+ # header, invoices)
858
+ res_line = res["RespuestaLinea"][0]
859
+ if res["EstadoEnvio"] == "Correcto":
860
+ doc_vals.update(
861
+ {
862
+ "sii_state": "sent",
863
+ "sii_csv": res["CSV"],
864
+ "sii_send_failed": False,
865
+ }
866
+ )
867
+ elif (
868
+ res["EstadoEnvio"] == "ParcialmenteCorrecto"
869
+ and res_line["EstadoRegistro"] == "AceptadoConErrores"
870
+ ):
871
+ doc_vals.update(
872
+ {
873
+ "sii_state": "sent_w_errors",
874
+ "sii_csv": res["CSV"],
875
+ "sii_send_failed": True,
876
+ }
877
+ )
878
+ else:
879
+ doc_vals["sii_send_failed"] = True
880
+ if (
881
+ "sii_state" in doc_vals
882
+ and not document.sii_account_registration_date
883
+ and mapping_key[:2] == "in"
884
+ ):
885
+ doc_vals[
886
+ "sii_account_registration_date"
887
+ ] = self._get_account_registration_date()
888
+ doc_vals["sii_return"] = res
889
+ send_error = False
890
+ if res_line["CodigoErrorRegistro"]:
891
+ send_error = "{} | {}".format(
892
+ str(res_line["CodigoErrorRegistro"]),
893
+ str(res_line["DescripcionErrorRegistro"])[:60],
894
+ )
895
+ doc_vals["sii_send_error"] = send_error
896
+ document.write(doc_vals)
897
+ except Exception as fault:
898
+ new_cr = Registry(self.env.cr.dbname).cursor()
899
+ env = api.Environment(new_cr, self.env.uid, self.env.context)
900
+ document = env[document._name].browse(document.id)
901
+ doc_vals.update(
902
+ {
903
+ "sii_send_failed": True,
904
+ "sii_send_error": repr(fault)[:60],
905
+ "sii_return": repr(fault),
906
+ "sii_content_sent": json.dumps(inv_dict, indent=4),
907
+ }
908
+ )
909
+ document.write(doc_vals)
910
+ new_cr.commit()
911
+ new_cr.close()
912
+ raise ValidationError(fault) from fault
913
+
914
+ def confirm_one_document(self):
915
+ self.sudo()._send_document_to_sii()