358 lines
16 KiB
Python
358 lines
16 KiB
Python
import uuid
|
|
from markupsafe import Markup
|
|
from urllib.parse import quote, urlencode, urlparse
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.addons.l10n_tr_nilvera.lib.nilvera_client import _get_nilvera_client
|
|
|
|
MOVE_TYPE_CATEGORY_MAP = {
|
|
"out_invoice": {
|
|
"earchive": "invoices",
|
|
"einvoice": "sale",
|
|
},
|
|
"in_invoice": {
|
|
"einvoice": "purchase",
|
|
},
|
|
}
|
|
|
|
CATEGORY_MOVE_TYPE_MAP = {
|
|
"invoices": "out_invoice",
|
|
"sale": "out_invoice",
|
|
"purchase": "in_invoice",
|
|
}
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_name = 'account.move'
|
|
_inherit = ['account.move']
|
|
|
|
l10n_tr_nilvera_uuid = fields.Char(
|
|
string="Nilvera Document UUID",
|
|
copy=False,
|
|
readonly=True,
|
|
default=lambda self: str(uuid.uuid4()),
|
|
help="Universally unique identifier of the Invoice",
|
|
)
|
|
|
|
l10n_tr_nilvera_send_status = fields.Selection(
|
|
selection=[
|
|
('error', "Error (check chatter)"),
|
|
('not_sent', "Not sent"),
|
|
('sent', "Sent and waiting response"),
|
|
('succeed', "Successful"),
|
|
('waiting', "Waiting"),
|
|
('unknown', "Unknown"),
|
|
],
|
|
string="Nilvera Status",
|
|
readonly=True,
|
|
copy=False,
|
|
default='not_sent',
|
|
)
|
|
|
|
def _l10n_tr_types_to_update_status(self):
|
|
return list(MOVE_TYPE_CATEGORY_MAP)
|
|
|
|
def _l10n_tr_get_document_category(self, invoice_channel):
|
|
return MOVE_TYPE_CATEGORY_MAP.get(self.move_type, {}).get(invoice_channel)
|
|
|
|
def _l10n_tr_get_category_move_type(self, document_category):
|
|
return CATEGORY_MOVE_TYPE_MAP.get(document_category.lower())
|
|
|
|
@api.model
|
|
def _get_ubl_cii_builder_from_xml_tree(self, tree):
|
|
customization_id = tree.find('{*}CustomizationID')
|
|
if customization_id is not None and 'TR1.2' in customization_id.text:
|
|
return self.env['account.edi.xml.ubl.tr']
|
|
return super()._get_ubl_cii_builder_from_xml_tree(tree)
|
|
|
|
def button_draft(self):
|
|
# EXTENDS account
|
|
for move in self:
|
|
if move.l10n_tr_nilvera_uuid and move.l10n_tr_nilvera_send_status != 'not_sent':
|
|
raise UserError(_("You cannot reset to draft an entry that has been sent to Nilvera."))
|
|
super().button_draft()
|
|
|
|
def _l10n_tr_nilvera_submit_einvoice(self, xml_file, customer_alias):
|
|
self._l10n_tr_nilvera_submit_document(
|
|
xml_file=xml_file,
|
|
endpoint=f"/einvoice/Send/Xml?{urlencode({'Alias': customer_alias})}",
|
|
)
|
|
|
|
def _l10n_tr_nilvera_submit_earchive(self, xml_file):
|
|
self._l10n_tr_nilvera_submit_document(
|
|
xml_file=xml_file,
|
|
endpoint="/earchive/Send/Xml",
|
|
)
|
|
|
|
def _l10n_tr_nilvera_submit_document(self, xml_file, endpoint, post_series=True):
|
|
"""
|
|
Submits an e-invoice or e-archive document to Nilvera for processing.
|
|
|
|
:param xml_file: The XML file to be submitted.
|
|
:type xml_file: file-like object
|
|
:param endpoint: The Nilvera API endpoint for submission.
|
|
:type endpoint: str
|
|
:param post_series: Whether to attempt posting the series/sequence to Nilvera if it is missing.
|
|
Defaults to True. Useful for avoiding an infinite loop.
|
|
:type post_series: bool
|
|
:raises UserError: If the API key lacks necessary rights (401 or 403 responses), if the response
|
|
indicates a client error (4xx), or if a server error occurs (500).
|
|
:return: None
|
|
"""
|
|
with _get_nilvera_client(self.env.company) as client:
|
|
response = client.request(
|
|
"POST",
|
|
endpoint,
|
|
files={'file': (xml_file.name, xml_file, 'application/xml')},
|
|
handle_response=False,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
self.is_move_sent = True
|
|
self.l10n_tr_nilvera_send_status = 'sent'
|
|
elif response.status_code in {401, 403}:
|
|
raise UserError(_("Oops, seems like you're unauthorised to do this. Try another API key with more rights or contact Nilvera."))
|
|
elif 400 <= response.status_code < 500:
|
|
error_message, error_codes = self._l10n_tr_nilvera_einvoice_get_error_messages_from_response(response)
|
|
|
|
# If the sequence/series is not found on Nilvera, add it then retry.
|
|
if 3009 in error_codes and post_series:
|
|
self._l10n_tr_nilvera_post_series(endpoint, client)
|
|
return self._l10n_tr_nilvera_submit_document(xml_file, endpoint, post_series=False)
|
|
raise UserError(error_message)
|
|
elif response.status_code == 500:
|
|
raise UserError(_("Server error from Nilvera, please try again later."))
|
|
|
|
self.message_post(body=_("The invoice has been successfully sent to Nilvera."))
|
|
|
|
def _l10n_tr_nilvera_post_series(self, endpoint, client):
|
|
"""Post the series to Nilvera based on the endpoint."""
|
|
path = urlparse(endpoint).path # Remove query params from te endpoint.
|
|
if path == "/einvoice/Send/Xml":
|
|
series_endpoint = "/einvoice/Series"
|
|
elif path == "/earchive/Send/Xml":
|
|
series_endpoint = "/earchive/Series"
|
|
else:
|
|
# Return early if endpoint couldn't be matched.
|
|
return
|
|
|
|
if not self.sequence_prefix:
|
|
return
|
|
|
|
series = self.sequence_prefix.split('/', 1)[0]
|
|
client.request(
|
|
"POST",
|
|
series_endpoint,
|
|
json={
|
|
'Name': series,
|
|
'IsActive': True,
|
|
'IsDefault': False,
|
|
},
|
|
)
|
|
|
|
def _l10n_tr_nilvera_get_submitted_document_status(self):
|
|
with _get_nilvera_client(self.env.company) as client:
|
|
for invoice in self:
|
|
invoice_channel = invoice.partner_id.l10n_tr_nilvera_customer_status
|
|
document_category = invoice._l10n_tr_get_document_category(invoice_channel)
|
|
if not document_category or not invoice_channel:
|
|
continue
|
|
|
|
response = client.request(
|
|
"GET",
|
|
f"/{invoice_channel}/{quote(document_category)}/{invoice.l10n_tr_nilvera_uuid}/Status",
|
|
)
|
|
|
|
nilvera_status = response.get('InvoiceStatus', {}).get('Code') or response.get('StatusCode')
|
|
if nilvera_status in dict(invoice._fields['l10n_tr_nilvera_send_status'].selection):
|
|
invoice.l10n_tr_nilvera_send_status = nilvera_status
|
|
if nilvera_status == 'error':
|
|
invoice.message_post(
|
|
body=Markup(
|
|
"%s<br/>%s - %s<br/>"
|
|
) % (
|
|
_("The invoice couldn't be sent to the recipient."),
|
|
response.get('InvoiceStatus', {}).get('Description') or response.get('StatusDetail'),
|
|
response.get('InvoiceStatus', {}).get('DetailDescription') or response.get('ReportStatus'),
|
|
)
|
|
)
|
|
else:
|
|
invoice.message_post(body=_("The invoice status couldn't be retrieved from Nilvera."))
|
|
|
|
def _l10n_tr_nilvera_get_documents(self, invoice_channel="einvoice", document_category="Purchase", journal_type="in_invoice"):
|
|
with _get_nilvera_client(self.env.company) as client:
|
|
response = client.request("GET", f"/{invoice_channel}/{quote(document_category)}", params={"StatusCode": ["succeed"]})
|
|
if not response.get('Content'):
|
|
return
|
|
journal = self._l10n_tr_get_nilvera_invoice_journal(journal_type)
|
|
document_uuids, document_uuids_records, document_uuids_references = self._l10n_tr_build_document_uuids_list(response)
|
|
for document_uuid in document_uuids:
|
|
move = document_uuids_records.get(document_uuid)
|
|
# If an account.move doesn't exist, create it and attach the document
|
|
if not move:
|
|
move = self._l10n_tr_nilvera_get_invoice_from_uuid(client, journal, document_uuid, document_category, invoice_channel)
|
|
self._l10n_tr_nilvera_add_pdf_to_invoice(client, move, document_uuid, document_category, invoice_channel)
|
|
# If account.move exists, but doesn't have a reference and its reference is found in the nilvera document references, attach the document
|
|
elif not move.ref and (nilvera_reference := document_uuids_references.get(document_uuid)):
|
|
move.ref = nilvera_reference
|
|
self._l10n_tr_nilvera_add_pdf_to_invoice(client, move, document_uuid, document_category, invoice_channel)
|
|
self._cr.commit()
|
|
|
|
def _l10n_tr_get_nilvera_invoice_journal(self, journal_type):
|
|
journal = self._l10n_tr_get_document_category_default_journal(journal_type)
|
|
if not journal:
|
|
journal = self.env['account.journal'].search([
|
|
*self.env['account.journal']._check_company_domain(self.env.company),
|
|
('type', '=', f'{journal_type}'),
|
|
], limit=1)
|
|
return journal
|
|
|
|
def _l10n_tr_get_document_category_default_journal(self, journal_type):
|
|
if journal_type == "purchase":
|
|
return self.env.company.l10n_tr_nilvera_purchase_journal_id
|
|
return None
|
|
|
|
def _l10n_tr_build_document_uuids_list(self, response):
|
|
contents = response.get("Content", [])
|
|
document_uuids = [content.get("UUID") for content in contents if content.get("UUID")]
|
|
# Should be unique per invoice so we get the records with the invoice to use the records
|
|
document_uuids_records = dict(self.env["account.move"]._read_group([("l10n_tr_nilvera_uuid", "in", document_uuids)], groupby=["l10n_tr_nilvera_uuid", "id"]))
|
|
document_uuids_references = {
|
|
content["UUID"]: content["InvoiceNumber"]
|
|
for content in contents
|
|
if content.get("UUID") and content.get("InvoiceNumber")
|
|
}
|
|
|
|
return document_uuids, document_uuids_records, document_uuids_references
|
|
|
|
def _l10n_tr_nilvera_get_invoice_from_uuid(self, client, journal, document_uuid, document_category="Purchase", invoice_channel="einvoice"):
|
|
response = client.request(
|
|
"GET",
|
|
f"/{invoice_channel}/{quote(document_category)}/{quote(document_uuid)}/xml",
|
|
params={"StatusCode": ["succeed"]},
|
|
)
|
|
|
|
attachment_vals = {
|
|
'name': 'attachment.xml',
|
|
'raw': response,
|
|
'type': 'binary',
|
|
'mimetype': 'application/xml',
|
|
}
|
|
|
|
attachment = self.env['ir.attachment'].create(attachment_vals)
|
|
move_type = self._l10n_tr_get_category_move_type(document_category)
|
|
try:
|
|
move = journal.with_context(
|
|
default_move_type=move_type,
|
|
default_l10n_tr_nilvera_uuid=document_uuid,
|
|
default_message_main_attachment_id=attachment.id,
|
|
default_l10n_tr_nilvera_send_status='succeed',
|
|
)._create_document_from_attachment(attachment.id)
|
|
|
|
# If move creation was successful, update the attachment name with the bill reference.
|
|
if move.ref:
|
|
attachment.name = f'{move.ref}.xml'
|
|
|
|
move._message_log(body=_("Nilvera document has been received successfully"))
|
|
except Exception: # noqa: BLE001
|
|
# If the invoice creation fails, create an empty invoice with the attachment. The PDF will be
|
|
# added in a later step as well. Nilvera only returns uuid of the successful attachments.
|
|
move = self.env['account.move'].create({
|
|
'move_type': move_type,
|
|
'company_id': self.env.company.id,
|
|
'l10n_tr_nilvera_uuid': document_uuid,
|
|
'l10n_tr_nilvera_send_status': 'succeed',
|
|
'message_main_attachment_id': attachment.id,
|
|
})
|
|
attachment.write({
|
|
'res_model': 'account.move',
|
|
'res_id': move.id,
|
|
})
|
|
|
|
return move
|
|
|
|
def _l10n_tr_nilvera_add_pdf_to_invoice(self, client, invoice, document_uuid, document_category="Purchase", invoice_channel="einvoice"):
|
|
response = client.request(
|
|
"GET",
|
|
f"/{invoice_channel}/{quote(document_category)}/{quote(document_uuid)}/pdf",
|
|
)
|
|
|
|
filename = f'{invoice.ref}.pdf' if invoice.ref else 'Nilvera PDF.pdf'
|
|
|
|
attachment = self.env['ir.attachment'].create({
|
|
'name': filename,
|
|
'res_id': invoice.id,
|
|
'res_model': 'account.move',
|
|
'datas': response,
|
|
'type': 'binary',
|
|
'mimetype': 'application/pdf',
|
|
})
|
|
# The created attachement coming form Nilvera should be the main attachment
|
|
invoice.message_main_attachment_id = attachment
|
|
invoice.with_context(no_new_invoice=True).message_post(attachment_ids=attachment.ids)
|
|
|
|
def _l10n_tr_nilvera_einvoice_get_error_messages_from_response(self, response):
|
|
msg = ""
|
|
error_codes = []
|
|
|
|
response_json = response.json()
|
|
if errors := response_json.get('Errors'):
|
|
msg += _("The invoice couldn't be sent due to the following errors:\n")
|
|
for error in errors:
|
|
msg += "%s - %s: %s\n" % (error.get('Code'), error.get('Description'), error.get('Detail'))
|
|
error_codes.append(error.get('Code'))
|
|
|
|
return msg, error_codes
|
|
|
|
def _l10n_tr_nilvera_einvoice_check_invalid_subscription_dates(self):
|
|
if 'deferred_start_date' not in self.invoice_line_ids._fields:
|
|
return False
|
|
|
|
# Ensure that either no lines have the start and end dates or all lines have the same start and end dates.
|
|
lines_to_check = self.invoice_line_ids.filtered(lambda line: line.display_type == 'product')
|
|
if not (subscription_lines := lines_to_check.filtered('deferred_start_date')):
|
|
return False
|
|
|
|
return len(subscription_lines) != len(lines_to_check) or len(set(subscription_lines.mapped(
|
|
lambda aml: (aml.deferred_start_date, aml.deferred_end_date)),
|
|
)) > 1
|
|
|
|
def _l10n_tr_nilvera_einvoice_check_negative_lines(self):
|
|
return any(
|
|
line.display_type not in {'line_note', 'line_section'}
|
|
and (line.quantity < 0 or line.price_unit < 0)
|
|
for line in self.invoice_line_ids
|
|
)
|
|
|
|
def _get_partner_l10n_tr_nilvera_customer_alias_name(self):
|
|
# Allows overriding the default customer alias with a custom one.
|
|
self.ensure_one()
|
|
return self.partner_id.l10n_tr_nilvera_customer_alias_id.name
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CRONS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_tr_nilvera_company_get_documents(self, invoice_channel, category, journal_type):
|
|
for company in self.env.companies:
|
|
if company.country_code != "TR" or not company.l10n_tr_nilvera_api_key:
|
|
continue
|
|
self.with_company(company)._l10n_tr_nilvera_get_documents(invoice_channel, category, journal_type)
|
|
|
|
def _cron_nilvera_get_new_einvoice_purchase_documents(self):
|
|
self._l10n_tr_nilvera_company_get_documents("einvoice", "Purchase", "purchase")
|
|
|
|
def _cron_nilvera_get_new_einvoice_sale_documents(self):
|
|
self._l10n_tr_nilvera_company_get_documents("einvoice", "Sale", "sale")
|
|
|
|
def _cron_nilvera_get_new_earchive_sale_documents(self):
|
|
self._l10n_tr_nilvera_company_get_documents("earchive", "Invoices", "sale")
|
|
|
|
def _cron_nilvera_get_invoice_status(self):
|
|
invoices_to_update = self.env['account.move'].search([
|
|
('l10n_tr_nilvera_send_status', 'in', ['waiting', 'sent']),
|
|
('move_type', 'in', self._l10n_tr_types_to_update_status()),
|
|
])
|
|
invoices_to_update._l10n_tr_nilvera_get_submitted_document_status()
|