1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_ke_edi_oscu/models/account_move.py
2024-12-10 09:04:09 +07:00

680 lines
35 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import contextlib
import logging
import json
from datetime import datetime
from collections import defaultdict
from zoneinfo import ZoneInfo
from psycopg2.errors import LockNotAvailable
from odoo import _, api, Command, fields, models, modules, tools
from odoo.exceptions import UserError
from odoo.addons.base.models.ir_qweb_fields import Markup
from odoo.tools.float_utils import json_float_round, float_compare
_logger = logging.getLogger(__name__)
TAX_CODE_LETTERS = ['A', 'B', 'C', 'D', 'E']
def format_etims_datetime(dt):
""" Format a UTC datetime as expected by eTIMS (only digits, Kenyan timezone). """
return dt.replace(tzinfo=ZoneInfo('UTC')).astimezone(ZoneInfo('Africa/Nairobi')).strftime('%Y%m%d%H%M%S')
def parse_etims_datetime(dt_str):
""" Parse a datetime string received from eTIMS into a UTC datetime. """
return datetime.strptime(dt_str, '%Y%m%d%H%M%S').replace(tzinfo=ZoneInfo('Africa/Nairobi')).astimezone(ZoneInfo('UTC')).replace(tzinfo=None)
class AccountMove(models.Model):
_inherit = 'account.move'
# === Business fields === #
l10n_ke_payment_method_id = fields.Many2one(
string="eTIMS Payment Method",
comodel_name='l10n_ke_edi_oscu.code',
domain=[('code_type', '=', '07')],
help="Method of payment communicated to the KRA via eTIMS. This is required when confirming purchases.",
)
l10n_ke_reason_code_id = fields.Many2one(
string="eTIMS Credit Note Reason",
comodel_name='l10n_ke_edi_oscu.code',
domain=[('code_type', '=', '32')],
copy=False,
help="Kenyan code for Credit Notes",
)
# === eTIMS Technical fields === #
l10n_ke_oscu_confirmation_datetime = fields.Datetime(copy=False)
l10n_ke_oscu_receipt_number = fields.Integer(string="Receipt Number", copy=False)
l10n_ke_oscu_invoice_number = fields.Integer(string="Invoice Number", copy=False)
l10n_ke_oscu_signature = fields.Char(string="Signature", copy=False)
l10n_ke_oscu_datetime = fields.Datetime(string="eTIMS Signing Time", copy=False)
l10n_ke_oscu_internal_data = fields.Char(string="Internal Data", copy=False)
l10n_ke_control_unit = fields.Char(string="Control Unit ID")
l10n_ke_oscu_attachment_file = fields.Binary(copy=False, attachment=True)
l10n_ke_oscu_attachment_id = fields.Many2one(
comodel_name='ir.attachment',
string="eTIMS Attachment",
compute=lambda self: self._compute_linked_attachment_id('l10n_ke_oscu_attachment_id', 'l10n_ke_oscu_attachment_file'),
depends=['l10n_ke_oscu_attachment_file'],
)
l10n_ke_validation_message = fields.Json(compute='_compute_l10n_ke_validation_message')
# === Computes === #
@api.depends('l10n_ke_oscu_attachment_id')
def _compute_show_reset_to_draft_button(self):
super()._compute_show_reset_to_draft_button()
self.filtered(lambda m: m.l10n_ke_oscu_invoice_number).show_reset_to_draft_button = False
@api.depends('invoice_line_ids.product_id',
'invoice_line_ids.product_uom_id')
def _compute_l10n_ke_validation_message(self):
""" Compute the series of messages to be displayed in the banner at the header of the invoice. """
for move in self:
if not move.company_id.l10n_ke_oscu_is_active or not move.is_invoice(include_receipts=True):
move.l10n_ke_validation_message = False
continue
product_lines = move.invoice_line_ids.filtered(lambda line: line.display_type == 'product')
messages = {
**product_lines.product_id._l10n_ke_get_validation_messages(for_invoice=True),
**product_lines.product_uom_id._l10n_ke_get_validation_messages(),
}
if move.is_purchase_document(include_receipts=True) and not move.l10n_ke_payment_method_id:
messages['no_payment_method_warning'] = {
'message': _("An eTIMS payment method is required when confirming a purchase. "),
'blocking': True,
}
if move.move_type == 'out_refund' and not move.l10n_ke_reason_code_id:
messages['no_reason_code_warning'] = {
'message': _("A KRA reason code is required when creating credit notes. "),
'blocking': True,
}
if product_lines.filtered(lambda line: not line.product_id and line.name):
messages['no_product_warning'] = {
'message': _("Some lines are missing a product where one must be set. "),
'blocking': True,
}
lines_not_single_tax = self.env['account.move.line']
unspsc_tax_mismatch_products = self.env['product.product']
for line in product_lines:
vat_taxes = line.tax_ids.filtered(lambda t: t.l10n_ke_tax_type_id)
if len(vat_taxes) != 1 and line.product_id:
lines_not_single_tax |= line
if (product_tax_type := line.product_id.unspsc_code_id.l10n_ke_tax_type_id) and product_tax_type not in vat_taxes.l10n_ke_tax_type_id:
unspsc_tax_mismatch_products |= line.product_id
if lines_not_single_tax:
messages['lines_not_single_vat_tax'] = {
'message': _("All invoice lines must have exactly one VAT tax (on which the KRA Tax Code is set)!"),
'blocking': True,
}
if unspsc_tax_mismatch_products:
messages['unspsc_tax_mismatch_warning'] = {
'message': _(
"There are products in use with UNSPSC codes for which the KRA has specified a "
"different tax rate to that in use on the line."
),
'action_text': _("View Product(s)"),
'action': unspsc_tax_mismatch_products._get_records_action(name=_("View Product(s)"), context={}),
'blocking': False,
}
move.l10n_ke_validation_message = messages
# === Sending to eTIMS: common helpers === #
def _l10n_ke_oscu_json_from_move(self):
""" Get the json content of the TrnsSalesSaveWr/TrnsPurchaseSave request from a move. """
self.ensure_one()
confirmation_datetime = format_etims_datetime(self.l10n_ke_oscu_confirmation_datetime)
invoice_date = (self.invoice_date and self.invoice_date.strftime('%Y%m%d')) or ''
original_invoice_number = (self.reversed_entry_id and self.reversed_entry_id.l10n_ke_oscu_invoice_number) or 0
tax_details = self._prepare_invoice_aggregated_taxes()
line_items = self._l10n_ke_oscu_get_json_from_lines(tax_details)
tax_codes = {item['code']: item['tax_rate'] for item in self.env['l10n_ke_edi_oscu.code'].search([('code_type', '=', '04')])}
tax_rates = {f'taxRt{letter}': tax_codes.get(letter, 0) for letter in TAX_CODE_LETTERS}
taxable_amounts = {
f'taxblAmt{letter}': json_float_round(sum(
item['taxblAmt'] for item in line_items if item['taxTyCd'] == letter
), 2) for letter in TAX_CODE_LETTERS
}
tax_amounts = {
f'taxAmt{letter}': json_float_round(sum(
item['taxAmt'] for item in line_items if item['taxTyCd'] == letter
), 2) for letter in TAX_CODE_LETTERS
}
content = {
'invcNo': '', # KRA Invoice Number (set at the point of sending)
'trdInvcNo': (self.name or '')[:50], # Trader system invoice number
'orgInvcNo': original_invoice_number, # Original invoice number
'cfmDt': confirmation_datetime, # Validated date
'pmtTyCd': self.l10n_ke_payment_method_id.code or '', # Payment type code
'rcptTyCd': { # Receipt code
'out_invoice': 'S', # - Sale
'out_refund': 'R', # - Credit note after sale
'in_invoice': 'P', # - Purchase
'in_refund': 'R', # - Credit note after purchase
}[self.move_type],
**taxable_amounts,
**tax_amounts,
**tax_rates,
'totTaxblAmt': json_float_round(tax_details['base_amount'], 2),
'totTaxAmt': json_float_round(tax_details['tax_amount'], 2),
'totAmt': json_float_round(self.amount_total, 2),
'totItemCnt': len(line_items), # Total Item count
'itemList': line_items,
**self.company_id._l10n_ke_get_user_dict(self.create_uid, self.write_uid),
}
if self.is_purchase_document(include_receipts=True):
content.update({
'spplrTin': (self.partner_id.vat or '')[:11], # Supplier VAT
'spplrNm': (self.partner_id.name or '')[:60], # Supplier name
'regTyCd': 'M', # Registration type code (Manual / Automatic)
'pchsTyCd': 'N', # Purchase type code (Copy / Normal / Proforma)
'pchsSttsCd': '02', # Transaction status code (02 approved / 05 credit note generated)
'pchsDt': invoice_date, # Purchase date
# "spplrInvcNo": None,
})
else:
receipt_part = {
'custTin': (self.partner_id.vat or '')[:11], # Partner VAT
'rcptPbctDt': confirmation_datetime, # Receipt published date
'prchrAcptcYn': 'N', # Purchase accepted Yes/No
}
if self.partner_id.mobile:
receipt_part.update({
'custMblNo': (self.partner_id.mobile or '')[:20] # Mobile number, not required
})
if self.partner_id.contact_address_inline:
receipt_part.update({
'adrs': (self.partner_id.contact_address_inline or '')[:200], # Address, not required
})
content.update({
'custTin': (self.partner_id.vat or '')[:11], # Partner VAT
'custNm': (self.partner_id.name or '')[:60], # Partner name
'salesSttsCd': '02', # Transaction status code (same as pchsSttsCd)
'salesDt': invoice_date, # Sales date
'prchrAcptcYn': 'Y',
'receipt': receipt_part,
})
if self.move_type in ('out_refund', 'in_refund'):
content.update({'rfdRsnCd': self.l10n_ke_reason_code_id.code})
return content
def _l10n_ke_oscu_get_json_from_lines(self, tax_details):
""" Return the values that should be sent to eTIMS for the lines in self. """
self.ensure_one()
lines_values = []
for index, line in enumerate(self.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_section', 'line_note'))):
product = line.product_id # for ease of reference
product_uom_qty = line.product_uom_id._compute_quantity(line.quantity, product.uom_id)
line_tax_details = next(
line_tax_details
for tax_grouping_key, line_tax_details in tax_details['tax_details_per_record'][line]['tax_details'].items()
if tax_grouping_key['tax'].l10n_ke_tax_type_id # We only want to report VAT taxes
)
if line.quantity and line.discount != 100:
# By computing the price_unit this way, we ensure that we get the price before the VAT tax, regardless of what
# other price_include / price_exclude taxes are defined on the product.
price_subtotal_before_discount = line_tax_details['base_amount'] / (1 - (line.discount / 100))
price_unit = price_subtotal_before_discount / line.quantity
else:
price_unit = line.price_unit
price_subtotal_before_discount = price_unit * line.quantity
discount_amount = price_subtotal_before_discount - line_tax_details['base_amount']
line_values = {
'itemSeq': index + 1, # Line number
'itemCd': product.l10n_ke_item_code, # Item code as defined by us, of the form KE2BFTNE0000000000000039
'itemClsCd': product.unspsc_code_id.code, # Item classification code, in this case the UNSPSC code
'itemNm': line.name, # Item name
'pkgUnitCd': product.l10n_ke_packaging_unit_id.code, # Packaging code, describes the type of package used
'pkg': product_uom_qty / product.l10n_ke_packaging_quantity, # Number of packages used
'qtyUnitCd': line.product_uom_id.l10n_ke_quantity_unit_id.code, # The UOMs as defined by the KRA, defined seperately from the UOMs on the line
'qty': line.quantity,
'prc': price_unit,
'splyAmt': price_subtotal_before_discount,
'dcRt': line.discount,
'dcAmt': discount_amount,
'taxTyCd': line_tax_details['tax'].l10n_ke_tax_type_id.code,
'taxblAmt': line_tax_details['base_amount'],
'taxAmt': line_tax_details['tax_amount'],
'totAmt': line_tax_details['base_amount'] + line_tax_details['tax_amount'],
}
fields_to_round = ('pkg', 'qty', 'prc', 'splyAmt', 'dcRt', 'dcAmt', 'taxblAmt', 'taxAmt', 'totAmt')
for field in fields_to_round:
line_values[field] = json_float_round(line_values[field], 2)
if product.barcode:
line_values.update({'bcd': product.barcode})
lines_values.append(line_values)
return lines_values
def _l10n_ke_oscu_json_from_attachment(self):
"""Get the json content of the TrnsPurchaseSave request given an attachment on the move."""
self.ensure_one()
if not self.l10n_ke_oscu_attachment_id:
return {}
if not self._is_vendor_bill_json(self.l10n_ke_oscu_attachment_id.raw):
return {}
file_content = json.loads(self.l10n_ke_oscu_attachment_id.raw)
# Firstly, those fields that map directly from the file to the purchase confirmation request
content = {field: file_content[field] for field in (
'spplrTin', 'spplrNm', 'spplrBhfId',
'spplrInvcNo', 'pmtTyCd', 'totItemCnt',
'taxblAmtA', 'taxRtA', 'taxAmtA',
'taxblAmtB', 'taxRtB', 'taxAmtB',
'taxblAmtC', 'taxRtC', 'taxAmtC',
'taxblAmtD', 'taxRtD', 'taxAmtD',
'taxblAmtE', 'taxRtE', 'taxAmtE',
'totTaxblAmt', 'totTaxAmt', 'totAmt',
)}
confirmation_datetime = format_etims_datetime(self.l10n_ke_oscu_confirmation_datetime)
content.update({
'invcNo': '',
'orgInvcNo': 0, # No original invoice
'regTyCd': 'M', # Registration type: manual
'pchsTyCd': 'N', # Purchase type: normal
'pchsSttsCd': '02', # Transaction progress: Accepted
'pchsDt': file_content['salesDt'],
'cfmDt': confirmation_datetime, # Validated date
**self.company_id._l10n_ke_get_user_dict(self.create_uid, self.write_uid),
})
item_list = []
for file_item in file_content['itemList']:
item = {field: file_item[field] for field in (
'itemSeq', 'itemClsCd', 'itemNm', 'pkgUnitCd', 'bcd', 'pkg', 'qtyUnitCd', 'qty',
'prc', 'splyAmt', 'dcRt', 'dcAmt', 'taxblAmt', 'taxTyCd', 'taxAmt', 'totAmt',
)}
item.update({
'itemCd': '',
'spplrItemCd': file_item['itemCd'],
'supplrItemNm': file_item['itemNm'],
'spplrItemClsCd': file_item['itemClsCd'],
})
item_list.append(item)
content['itemList'] = item_list
return content
def _l10n_ke_get_invoice_sequence(self):
""" Returns the KRA invoice sequence for this invoice (company and move_type dependent), creating it if needed. """
self.ensure_one()
sequence_code = 'l10n.ke.oscu.sale.sequence' if self.is_sale_document(include_receipts=True) else 'l10n.ke.oscu.purchase.sequence'
if not (sequence := self.env['ir.sequence'].search([
('code', '=', sequence_code),
('company_id', '=', self.company_id.id),
])):
sequence_name = 'eTIMS Customer Invoice Number' if self.is_sale_document(include_receipts=True) else 'eTIMS Vendor Bill Number'
return self.env['ir.sequence'].create({
'name': sequence_name,
'implementation': 'no_gap',
'company_id': self.company_id.id,
'code': sequence_code,
})
return sequence
# === Sending to eTIMS: invoices and credit notes === #
def _post(self, soft=True):
""" Perform checks related to credit notes and set the confirmation datetime
Unfortunately the KRA requires that this is performed here, as there is no validation of this
kind in their system. The purpose of these credit note checks is to confirm that neither the
quantities nor the monetary amounts exceed their values on the source customer invoice.
"""
# EXTENDS 'account'
for move in self.filtered(lambda move: move.country_code == 'KE' and move.reversed_entry_id):
original_move = move.reversed_entry_id
reversals = original_move.reversal_move_id
# Unless all the invoices / credit notes are made in the same currency, we can't conveniently
# check that the credit notes don't exceed the invoices (due to exchange rate differences),
# so we skip this check.
if len(set(original_move.mapped('currency_id') + reversals.mapped('currency_id'))) != 1:
continue
original_quantities = defaultdict(lambda: 0)
reverse_quantities = defaultdict(lambda: 0)
for line in original_move.invoice_line_ids.filtered(lambda l: l.display_type == 'product'):
original_quantities[line.product_id] += line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id)
for line in reversals.invoice_line_ids.filtered(lambda l: l.display_type == 'product'):
reverse_quantities[line.product_id] += line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id)
exceeding_quantities = []
for product, quantity in reverse_quantities.items():
if product not in original_quantities:
exceeding_quantities.append(_("'%s' is not present on the original invoice.", product.name))
elif (excess := quantity - original_quantities[product]) > 0:
exceeding_quantities.append(
_(
"'%(product_name)s' exceeds quantity on original invoice by %(excess)f %(uom_name)s",
product_name=product.name,
excess=excess,
uom_name=product.uom_id.name
)
)
if exceeding_quantities:
if len(reversals) > 1:
raise UserError(_(
"This credit note in conjunction with %(other_credit_notes)s has items of a quantity exceeding "
"that of the original customer invoice %(original_invoice)s. Please correct the quantity of "
"these lines before confirming:\n%(lines_to_correct)s",
other_credit_notes=', '.join((reversals - move).mapped("name")),
original_invoice=original_move.name,
lines_to_correct='\n'.join(exceeding_quantities),
))
raise UserError(_(
"This credit note has items of a quantity exceeding that of the original "
"customer invoice %(original_invoice)s. Please correct the quantity of these lines before "
"confirming:\n%(lines_to_correct)s",
original_invoice=original_move.name,
lines_to_correct='\n'.join(exceeding_quantities),
))
credit_note_total = abs(sum(move.amount_total_in_currency_signed for move in reversals))
excess = abs(credit_note_total) - abs(original_move.amount_total_in_currency_signed)
if float_compare(excess, 0, precision_digits=2) > 0:
if len(reversals) > 1:
raise UserError(_(
"This credit note in conjunction with %(other_credit_notes)s exceeds the amount on the "
"original customer invoice %(original_invoice)s. "
"Please adjust this credit note to a total value equal to or less than %(total_value)d before confirming.",
other_credit_notes=', '.join((reversals - move).mapped("name")),
original_invoice=original_move.name,
total_value=abs(move.amount_total_in_currency_signed) - excess,
))
raise UserError(_(
"This credit note exceeds the amount of the original customer invoice %(original_invoice)s. "
"Please adjust this credit note to a total value equal to or less than %(total_value)d before confirming.",
original_invoice=original_move.name,
total_value=abs(move.amount_total_in_currency_signed) - excess,
))
self.filtered(lambda m: not m.l10n_ke_oscu_confirmation_datetime).l10n_ke_oscu_confirmation_datetime = fields.Datetime.now()
return super()._post(soft)
def _l10n_ke_oscu_send_customer_invoice(self):
company = self.company_id
content = self._l10n_ke_oscu_json_from_move()
try:
content['invcNo'] = self._l10n_ke_get_invoice_sequence().next_by_id()
except LockNotAvailable:
raise UserError(_("Another user is already sending this invoice.")) from None
error, data, _date = company._l10n_ke_call_etims('saveTrnsSalesOsdc', content)
if not error:
self.write({
'l10n_ke_oscu_receipt_number': data['curRcptNo'],
'l10n_ke_oscu_invoice_number': content['invcNo'],
'l10n_ke_oscu_signature': data['rcptSign'],
'l10n_ke_oscu_datetime': parse_etims_datetime(data['sdcDateTime']),
'l10n_ke_oscu_internal_data': data['intrlData'],
'l10n_ke_control_unit': company.l10n_ke_control_unit,
})
else:
# In order not to rollback, but just to avoid consuming the invoice number
self._l10n_ke_get_invoice_sequence().number_next -= 1
return content, error
# === Sending to eTIMS: vendor bills === #
def action_l10n_ke_oscu_confirm_vendor_bill(self):
"""Send vendor bill information to the KRA in order to confirm that it has been accepted
Vendor bills can be received from the OSCU or created locally. When confirming vendor bills
received from the KRA, we can use the information from the attachment used to generate the
invoice in the first place to create the request. If the invoice is created locally,
generate the request using just the fields on the vendor bill.
"""
for move in self:
if (blocking := [msg for msg in (move.l10n_ke_validation_message or {}).values() if msg.get('blocking')]):
raise UserError(_("Please resolve these issues first.\n %s",
'\n'.join([f"- {msg['message']}" for msg in blocking])))
company = move.company_id
if move.l10n_ke_oscu_attachment_id:
content = {
**move._l10n_ke_oscu_json_from_attachment(),
'rcptTyCd': {'in_invoice': 'P', 'in_refund': 'R'}.get(move.move_type),
'regTyCd': 'A',
}
if not content['pmtTyCd']:
content['pmtTyCd'] = move.l10n_ke_payment_method_id.code
else:
content = move._l10n_ke_oscu_json_from_move()
try:
content['invcNo'] = move._l10n_ke_get_invoice_sequence().next_by_id()
except LockNotAvailable:
raise UserError(_("Another user is already sending this vendor bill.")) from None
error, _data, _date = company._l10n_ke_call_etims('insertTrnsPurchase', content)
if error:
raise UserError(error['message'])
move.l10n_ke_oscu_invoice_number = content['invcNo']
move.message_post(body=_("Purchase confirmed on eTIMS."))
# === Fetching from eTIMS: vendor bills === #
def _l10n_ke_oscu_fetch_purchases(self, companies):
""" Retrieve vendor bills from the KRA
:param recordset companies: recordset containing comanies for which purchases should be
fetched from the KRA.
:returns: recordset of the fetched invoices
"""
moves = self
for company in companies:
error, data, _date = company._l10n_ke_call_etims(
'selectTrnsPurchaseSalesList',
{'lastReqDt': format_etims_datetime(company.l10n_ke_oscu_last_fetch_purchase_date or datetime.datetime(2018, 1, 1))}
)
if error:
if error['code'] == '001':
_logger.warning("There are no new vendor bills on the OSCU for %s.", company.name)
else:
_logger.error("Error retrieving purchases from the OSCU: %s: %s", error['code'], error['message'])
continue
for purchase in data['saleList']:
filename = f"{purchase['spplrSdcId']}_{purchase['spplrInvcNo']}.json"
if self.env['ir.attachment'].search_count([
('name', '=', filename),
('res_model', '=', 'account.move'),
('res_field', '=', 'l10n_ke_oscu_attachment_file'),
], limit=1):
_logger.warning("Vendor bill already exists: %s", filename)
continue
move_type = {
'S': 'in_invoice',
'R': 'in_refund',
}.get(purchase['rcptTyCd'], 'in_invoice')
move = self.sudo().with_company(company).with_context(default_move_type=move_type).create({})
attachment = self.sudo().env['ir.attachment'].create({
'name': filename,
'raw': json.dumps(purchase, indent=4),
'type': 'binary',
'res_model': 'account.move',
'res_id': move.id,
'res_field': 'l10n_ke_oscu_attachment_file',
})
move.invalidate_recordset(fnames=['l10n_ke_oscu_attachment_id', 'l10n_ke_oscu_attachment_file'])
move.with_context(
account_predictive_bills_disable_prediction=True,
no_new_invoice=True,
).message_post(attachment_ids=attachment.ids)
moves |= move
company.l10n_ke_oscu_last_fetch_purchase_date = fields.Datetime.now()
for move in moves:
move._extend_with_attachments(move.l10n_ke_oscu_attachment_id, new=True)
# Avoid losing all our progress if the cron times-out
if not tools.config['test_enable'] and not modules.module.current_test:
self.env.cr.commit()
return moves
def _cron_l10n_ke_oscu_fetch_purchases(self):
""" Fetch purchases for all the relevant companies """
companies = self.env['res.company'].search([('l10n_ke_oscu_is_active', '=', True)])
moves = self._l10n_ke_oscu_fetch_purchases(companies)
_logger.info(
"KE EDI cron retrieved purchases for %s companies, and created %s vendor bills.",
len(moves.company_id),
len(moves),
)
@api.model
def _is_vendor_bill_json(self, file_content):
""" Determine whether the given file content is a vendor bill JSON retrieved from eTIMS. """
with contextlib.suppress(json.JSONDecodeError, UnicodeDecodeError):
content = json.loads(file_content)
return all(key in content for key in ('spplrTin', 'spplrNm', 'spplrBhfId', 'spplrInvcNo'))
def _get_edi_decoder(self, file_data, new=False):
# EXTENDS 'account'
if file_data['type'] == 'binary' and self._is_vendor_bill_json(file_data['content']):
return self._l10n_ke_oscu_import_invoice
return super()._get_edi_decoder(file_data, new=new)
def _l10n_ke_oscu_import_invoice(self, invoice, data, is_new):
""" Decodes the json content from eTIMS into an Odoo move.
This method is passed as the EDI decoder in the case where the file is recognised as an OSCU
JSON representation of a vendor bill.
:param dictionary data: the dictionary with the content to be imported
:param boolean is_new: whether the vendor bill is newly created or to be updated
:returns: the imported vendor bill
"""
with self._get_edi_creation() as self:
content = json.loads(data['content'])
message_to_log = []
self.move_type = {
'S': 'in_invoice',
'R': 'in_refund',
}.get(content['rcptTyCd'], 'in_invoice')
branch = self.env['res.partner'].search([('vat', '=ilike', content['spplrTin']),
('l10n_ke_branch_code', '=', content['spplrBhfId'])], limit=1)
if branch:
self.partner_id = branch
else:
self.partner_id = self.env['res.partner'].create({
'name': content['spplrNm'],
'vat': content['spplrTin'],
'l10n_ke_branch_code': content['spplrBhfId'],
})
message_to_log.extend((
_(
"A vendor with a matching Tax ID and Branch ID was not found. "
"One with the corresponding details was created."
),
"",
))
self.invoice_date = datetime.strptime(content['salesDt'], '%Y%m%d').date()
self.l10n_ke_control_unit = content['spplrSdcId']
uom_codes = [line['qtyUnitCd'] for line in content['itemList']]
uom_map = {
uom.l10n_ke_quantity_unit_id.code: uom
for uom in self.env['uom.uom'].search([('l10n_ke_quantity_unit_id.code', 'in', uom_codes)])
}
tax_rate_map = {code: content[f'taxRt{code}'] for code in TAX_CODE_LETTERS}
invoice_lines = []
for item in content['itemList']:
line = {}
product, msg = self.env['product.product']._l10n_ke_oscu_find_product_from_json(
{k: item[k] for k in ('itemNm', 'bcd', 'itemClsCd')}
)
if msg:
message_to_log.append(msg)
line['product_id'] = product and product.id or None
line['tax_ids'] = self.env['account.tax'].search([
('type_tax_use', '=', 'purchase'),
*self.env['account.tax']._check_company_domain(self.company_id),
('l10n_ke_tax_type_id.code', '=', item['taxTyCd']),
('amount', '=', tax_rate_map[item['taxTyCd']]), # in handy if tax rates would change
('amount_type', '=', 'percent'),
], limit=1).ids
# If we don't already have a matching UoM from the product
line['product_uom_id'] = uom_map.get(item['qtyUnitCd'], self.env['uom.uom']).id or (product and product.uom_id.id) or self.env.ref('uom.product_uom_unit').id
line['name'] = item['itemNm']
line['quantity'] = item['qty']
line['price_unit'] = item['prc']
line['discount'] = item['dcRt']
invoice_lines.append(Command.create(line))
self.invoice_line_ids = invoice_lines
message = Markup("<br/>").join(message_to_log)
# for message in message_to_log:
self.sudo().message_post(body=message)
return True
# === Report generation === #
def _get_name_invoice_report(self):
# EXTENDS account
self.ensure_one()
if self.l10n_ke_oscu_invoice_number:
return 'l10n_ke_edi_oscu.report_invoice_document'
return super()._get_name_invoice_report()
@api.model
def _l10n_ke_hyphenate_invoice_field(self, to_hyphenate, hyphenate_by=4):
"""Hyphenates a string by a regular interval
:param str to_hyphenate: string to be hyphenated (e.g. 'abcdefghijklmnop' becomes 'abcd-efgh-ijkl-mnop')
:param int hyphenate_by: the regular interval at which to add a hyphen
"""
return '-'.join(to_hyphenate[idx: idx + hyphenate_by] for idx in range(0, len(to_hyphenate), hyphenate_by))
def _l10n_ke_oscu_get_receipt_url(self):
self.ensure_one()
domain = 'etims-sbx' if self.company_id.l10n_ke_server_mode == 'test' else 'etims'
data = f'{self.company_id.vat}{self.company_id.l10n_ke_branch_code}{self.l10n_ke_oscu_signature}'
return f'https://{domain}.kra.go.ke/common/link/etims/receipt/indexEtimsReceiptData?Data={data}'
def _refunds_origin_required(self):
if self.country_code == 'KE':
return True
return super()._refunds_origin_required()