Files
enterprise-addons/l10n_ke_edi_oscu_pos/models/pos_order.py
T

288 lines
15 KiB
Python

from datetime import datetime
from zoneinfo import ZoneInfo
from odoo import _, api, models, modules, fields, tools
from odoo.exceptions import UserError
from odoo.tools.float_utils import json_float_round
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 PosOrder(models.Model):
_inherit = 'pos.order'
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_oscu_confirmation_datetime = fields.Datetime(string="Confirmation Date", copy=False)
l10n_ke_oscu_receipt_number = fields.Integer(string="ETims Receipt Number", copy=False)
l10n_ke_oscu_order_number = fields.Integer(string="ETims Number", copy=False)
l10n_ke_oscu_signature = fields.Char(string="Signature", copy=False)
l10n_ke_oscu_datetime = fields.Datetime(string="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_order_json = fields.Json(string="Order JSON", copy=False)
l10n_ke_order_send_status = fields.Selection(string="ETims status", selection=[('not_sent', "Not sent"), ('sent', "Sent")],
compute="_compute_send_status", store=True, readonly=True, copy=False)
###################################################
# Compute #
###################################################
@api.depends("l10n_ke_oscu_order_number")
def _compute_send_status(self):
for order in self:
order.l10n_ke_order_send_status = 'sent' if order.l10n_ke_oscu_order_number else 'not_sent'
###################################################
# eTims #
###################################################
def post_order_to_etims(self):
company = self.company_id
content = self._l10n_ke_oscu_json_from_pos_order()
if not (sequence := self.env['ir.sequence'].search([
('code', '=', 'l10n.ke.oscu.sale.sequence'),
('company_id', '=', company.id),
])):
sequence = self.env['ir.sequence'].create({
'name': 'eTIMS Customer Invoice Number',
'implementation': 'no_gap',
'company_id': company.id,
'code': 'l10n.ke.oscu.sale.sequence',
})
content['invcNo'] = sequence.next_by_id()
if not self.failed_pickings:
error, data, _date = company._l10n_ke_call_etims('saveTrnsSalesOsdc', content)
else:
error = {
'code': 'Out of stock',
'message': _('One or more product(s) are out of stock.')
}
if not error:
self.write({
'l10n_ke_oscu_receipt_number': data['curRcptNo'],
'l10n_ke_oscu_order_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,
'l10n_ke_order_json': content,
})
for pick in self.picking_ids:
pick.move_ids._l10n_ke_oscu_process_moves()
if self.is_invoiced and self.account_move:
# As the order has been sent, no need to send the invoice as well, just copy the fields
self._copy_etims_vals_from_pos_order_to_invoice(self.account_move)
template = self.env.ref('account.email_template_edi_invoice')
self.account_move.with_context(skip_invoice_sync=True)._generate_and_send(template)
else:
self.write({
'l10n_ke_order_json': content,
})
# Just in case of error, we don't want to create gaps in the sequence
sequence.number_next -= 1
# If there is an error, we want to be sure the sequence and the l10n_ke_order_json are commited in the db before throwing the error
if self._can_commit():
self.env.cr.commit()
return {
"content": content,
"error": error,
}
def _l10n_ke_oscu_get_json_from_order_lines(self):
""" 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.lines.filtered(lambda l: l.product_id.type != 'combo')):
product = line.product_id # for ease of reference
product_uom_qty = line.product_uom_id._compute_quantity(line.qty, product.uom_id)
if line.qty 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.price_subtotal / (1 - (line.discount / 100))
price_unit = price_subtotal_before_discount / line.qty
else:
price_unit = line.price_unit
price_subtotal_before_discount = price_unit * line.qty
discount_amount = price_subtotal_before_discount - line.price_subtotal
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.qty,
'prc': price_unit,
'splyAmt': price_subtotal_before_discount,
'dcRt': line.discount,
'dcAmt': discount_amount,
'taxTyCd': line.tax_ids.l10n_ke_tax_type_id.code,
'taxblAmt': line.price_subtotal,
'taxAmt': line.price_subtotal_incl - line.price_subtotal,
'totAmt': line.price_subtotal_incl,
}
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)
fields_to_abs = ('pkg', 'qty', 'splyAmt', 'taxblAmt', 'taxAmt', 'totAmt')
for field in fields_to_abs:
line_values[field] = abs(line_values[field])
if product.barcode:
line_values.update({'bcd': product.barcode})
lines_values.append(line_values)
return lines_values
def _l10n_ke_oscu_json_from_pos_order(self):
""" Get the json content of the TrnsSalesSaveWr request from a pos order. """
self.ensure_one()
self.l10n_ke_oscu_confirmation_datetime = fields.Datetime.now()
confirmation_datetime = format_etims_datetime(self.l10n_ke_oscu_confirmation_datetime)
order_date = (self.date_order and self.date_order.strftime('%Y%m%d')) or ''
# For a refund, eTIMS needs the KRA invoice number of the order being refunded, not this order's own number.
order_number = self.refunded_order_id.l10n_ke_oscu_order_number if self.refunded_order_id else self.sequence_number
line_items = self._l10n_ke_oscu_get_json_from_order_lines()
tax_codes, tax_rates, taxable_amounts, tax_amounts = self.env["account.move"]._get_taxes_data(line_items)
content = {
'invcNo': '', # KRA Invoice Number (set at the point of sending)
'trdInvcNo': (self.name or '')[:50], # Trader system pos order number
'orgInvcNo': order_number, # Original pos order number (or original invoice number if a refund)
'cfmDt': confirmation_datetime, # Validated date
'pmtTyCd': self.l10n_ke_payment_method_id.code or '', # Payment type code
'rcptTyCd': 'S' if not self.refunded_order_id else 'R', # Receipt code
**taxable_amounts,
**tax_amounts,
**tax_rates,
'totTaxblAmt': json_float_round(self.amount_total - self.amount_tax, 2), # amount without taxes
'totTaxAmt': json_float_round(self.amount_tax, 2), # only taxes
'totAmt': json_float_round(self.amount_total, 2), # total amount with taxes
'totItemCnt': len(line_items), # Total Item count
'itemList': line_items,
**self.company_id._l10n_ke_get_user_dict(self.create_uid, self.write_uid),
}
self.env["account.move"]._update_receipt_content(content, confirmation_datetime, order_date, self.partner_id)
fields_to_abs = ('totTaxblAmt', 'totTaxAmt', 'totAmt')
for field in fields_to_abs:
content[field] = abs(content[field])
if self.refunded_order_id:
content.update({'rfdRsnCd': '06'})
return content
###################################################
# OVERRIDES #
###################################################
def _generate_pos_order_invoice(self):
if self.config_id.is_kenyan:
return super(PosOrder, self.with_context(generate_pdf=self.l10n_ke_oscu_order_number))._generate_pos_order_invoice()
return super()._generate_pos_order_invoice()
def _create_invoice(self, move_vals):
invoice = super()._create_invoice(move_vals)
if self.config_id.is_kenyan and self.l10n_ke_order_send_status == 'sent':
self._copy_etims_vals_from_pos_order_to_invoice(invoice)
invoice.message_post(body="This invoice has already been sent to eTIMS from the linked POS order.")
return invoice
###################################################
# ACTION #
###################################################
def action_post_order(self):
etims_response = self.post_order_to_etims()
if etims_response.get('error'):
raise UserError(f"{etims_response['error']['code']} : {etims_response['error']['message']}") # pylint: disable=missing-gettext
def action_post_selected_orders(self, order_ids):
orders = self.env['pos.order'].browse(order_ids)
errors = []
for order in orders:
if order.l10n_ke_order_send_status == 'not_sent':
etims_response = order.post_order_to_etims()
if etims_response.get('error'):
errors.append(etims_response.get('error'))
if len(errors):
raise UserError(_("One or more order(s) have failed, you might want to check them individually."))
###################################################
# Helpers #
###################################################
def _get_l10n_ke_edi_oscu_pos_qrurl(self):
self.ensure_one()
res = self._l10n_ke_oscu_get_receipt_url() if self.l10n_ke_oscu_signature else None
return res
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 get_l10n_ke_edi_oscu_pos_data(self):
""" Return the needed eTims data to the JS to be included in the receipt """
self.ensure_one()
return {
"l10n_ke_edi_oscu_pos_date": self.l10n_ke_oscu_confirmation_datetime,
"l10n_ke_edi_oscu_pos_receipt_number": self.l10n_ke_oscu_receipt_number,
"l10n_ke_edi_oscu_pos_internal_data": self.l10n_ke_oscu_internal_data,
"l10n_ke_edi_oscu_pos_signature": self.l10n_ke_oscu_signature,
"l10n_ke_edi_oscu_pos_order_json": self.l10n_ke_order_json,
"l10n_ke_edi_oscu_pos_serial_number": self.env.company.l10n_ke_oscu_serial_number,
"l10n_ke_edi_oscu_pos_qrurl": self._get_l10n_ke_edi_oscu_pos_qrurl(),
}
def _copy_etims_vals_from_pos_order_to_invoice(self, invoice):
""" Copy the etims fields in self on the invoice """
invoice.write({
'l10n_ke_control_unit': self.l10n_ke_control_unit,
'l10n_ke_oscu_confirmation_datetime': self.l10n_ke_oscu_confirmation_datetime,
'l10n_ke_oscu_datetime': self.l10n_ke_oscu_datetime,
'l10n_ke_oscu_internal_data': self.l10n_ke_oscu_internal_data,
'l10n_ke_oscu_receipt_number': self.l10n_ke_oscu_receipt_number,
'l10n_ke_oscu_invoice_number': self.l10n_ke_oscu_order_number,
'l10n_ke_oscu_signature': self.l10n_ke_oscu_signature,
'l10n_ke_payment_method_id': self.l10n_ke_payment_method_id,
})
@staticmethod
def _can_commit():
return not tools.config['test_enable'] and not modules.module.current_test