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

463 lines
20 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from requests.exceptions import RequestException, Timeout
import json
from json.decoder import JSONDecodeError
from markupsafe import Markup
from urllib.parse import urljoin
import contextlib
from datetime import datetime
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError, RedirectWarning
from odoo.http import request
from odoo.tools import file_open
_logger = logging.getLogger(__name__)
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_ke_branch_code = fields.Char(
related='partner_id.l10n_ke_branch_code',
readonly=False,
store=True,
)
l10n_ke_oscu_serial_number = fields.Char(
string="Serial Number",
help="Unique Serial Number you will need to specify in the commitment form of "
"your eTIMS taxpayer portal during the integration process. ",
tracking=True,
compute='_compute_l10n_ke_oscu_serial_number',
readonly=False,
store=True,
)
l10n_ke_control_unit = fields.Char(
string="Control Unit ID",
help="This is retrieved from the device during initialization.",
)
l10n_ke_oscu_cmc_key = fields.Char(
string="Device Communication Key",
help="If you have an already initialized device, you can put your key here.",
groups='base.group_system'
)
l10n_ke_insurance_code = fields.Char(
string="Insurance Code",
help="If this branch has a mandatory insurance policy (e.g. a pharmacy), put its code here.",
)
l10n_ke_insurance_name = fields.Char(
string="Insurance Name",
help="The name of the branch's insurance policy",
)
l10n_ke_insurance_rate = fields.Float(
string="Insurance Rate",
help="The premium rate of the branch's insurance policy",
)
l10n_ke_server_mode = fields.Selection(
selection=[
('prod', "Production"),
('test', "Test"),
('demo', "Demo"),
],
string="eTIMS Server Mode",
help="""
- Production: Connection to eTIMS in production mode.
- Test: Connection to eTIMS in test mode.
- Demo: Mocked data, does not require an initialized OSCU.
""",
)
l10n_ke_oscu_user_help = fields.Boolean(
string="User should go with the number to KRA first. "
)
l10n_ke_oscu_user_agreement = fields.Boolean(
string="Odoo OSCU user agreement",
help="Agreement is required to use Odoo as an OSCU service provider.",
tracking=True,
)
l10n_ke_oscu_is_active = fields.Boolean(
search='_search_l10n_ke_oscu_is_active',
compute_sudo=True,
)
l10n_ke_oscu_last_fetch_purchase_date = fields.Datetime(default=datetime(2018, 1, 1))
# === Computes === #
@api.depends('l10n_ke_oscu_cmc_key', 'l10n_ke_branch_code', 'l10n_ke_server_mode')
def _compute_l10n_ke_oscu_is_active(self):
for company in self:
company.l10n_ke_oscu_is_active = (
company.l10n_ke_server_mode == 'demo'
or (
company.l10n_ke_server_mode in ['test', 'prod']
and company.l10n_ke_oscu_cmc_key
and company.l10n_ke_branch_code
and company.l10n_ke_oscu_user_agreement
)
)
def _search_l10n_ke_oscu_is_active(self, operator, value):
domain_true = [
'|',
('l10n_ke_server_mode', '=', 'demo'),
'&', '&', '&',
('l10n_ke_server_mode', 'in', ['test', 'prod']),
('l10n_ke_oscu_cmc_key', '!=', False),
('l10n_ke_branch_code', '!=', False),
('l10n_ke_oscu_user_agreement', '=', True),
]
if (operator == '=' and value) or (operator == '!=' and not value):
return domain_true
elif (operator == '=' and not value) or (operator == '!=' and value):
return ['!'] + domain_true
@api.depends('country_code', 'vat')
def _compute_l10n_ke_oscu_serial_number(self):
for company in self.filtered(lambda c: c.country_code == 'KE'):
company.l10n_ke_oscu_serial_number = f'ODOO/{company.vat}/0'
# === Overrides === #
def write(self, vals):
# If the user has checked the user agreement box, we make it readonly,
# create an ir.logging entry recording this, and send a confirmation e-mail to the user.
email_is_not_sent = self.filtered(lambda c: not c.l10n_ke_oscu_user_agreement or not c.l10n_ke_oscu_cmc_key)
res = super().write(vals)
if email_is_not_sent:
for company in email_is_not_sent.filtered(lambda c: c.l10n_ke_oscu_user_agreement and c.l10n_ke_oscu_cmc_key):
logging_message = f"""
Checkbox `Odoo OSCU user agreement` was set to True
on Company `{company.name}` (id: {company.id})
by user {self.env.user.name} (id: {self.env.user.id}
on {fields.Datetime.now()}
"""
with contextlib.suppress(RuntimeError): # a RuntimeError would be raised in cases where there is no request
logging_message += f"""
remote_user: {request.httprequest.remote_user}
IP: {request.httprequest.remote_addr}
User-Agent: {request.httprequest.user_agent}
"""
self.env['ir.logging'].create({
'name': 'l10n_ke_edi_oscu',
'type': 'server',
'level': 'INFO',
'dbname': self.env.cr.dbname,
'message': logging_message,
'func': '',
'path': '',
'line': '',
})
mail_body = Markup("""
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px;">
Dear %s,
<br/>
This is a notification that you have agreed to Odoo's OSCU user agreement. %s can now
use OSCU flows in Odoo to declare your activities with the KRA.
<br/>
This is an automated e-mail and no further action is needed on your part.
<br/>
Thank you for choosing Odoo for your Kenyan ERP needs.
</p>
</div>
""") % (self.env.user.name, company.name)
mail_subject = f" {company.name} - Odoo OSCU User Agreement confirmation"
mail_values = {
'email_from': self.env.user.email_formatted,
'author_id': self.env.user.partner_id.id,
'model': None,
'res_id': None,
'subject': mail_subject,
'body_html': mail_body,
'email_to': self.env.user.email_formatted,
}
self.env['mail.mail'].sudo().create(mail_values)
return res
# === Actions === #
def _l10n_ke_preinitialization_checks(self):
if not self.l10n_ke_oscu_user_help:
raise UserError(_('Please confirm that you did the necessary steps in the eTIMS portal first. '))
if not self.l10n_ke_oscu_user_agreement:
raise UserError(_("Please agree to the terms of use of Odoo as an OSCU service provider first. "))
error_fields = []
on_company = False
if not self.vat:
error_fields.append(_('PIN Number (VAT)'))
on_company = True
if not self.l10n_ke_branch_code:
error_fields.append(_('Branch Code'))
on_company = True
if not self.l10n_ke_oscu_serial_number:
error_fields.append(_('OSCU Serial Number'))
if error_fields:
error_fields = ["- " + e for e in error_fields]
msg = _('To initialize the device, please fill in the following elements: \n%s', '\n'.join(error_fields))
if not on_company:
raise UserError(msg)
else:
raise RedirectWarning(
msg,
self._get_records_action(),
_("Go to the company"),
)
if not self.l10n_ke_oscu_serial_number.upper().startswith('ODOO/' + self.vat.upper() + '/'):
raise UserError(_('Your serial number should contain the PIN number and start: ODOO/%s/', self.vat))
def action_l10n_ke_oscu_initialize(self):
""" Initializing the device is necessary in order to receive the cmc key
The cmc key is a token, necessary for all subsequent communication with the device.
"""
self.ensure_one()
self._l10n_ke_preinitialization_checks()
content = {
'tin': self.vat, # VAT No
'bhfId': self.l10n_ke_branch_code, # Branch ID
'dvcSrlNo': self.l10n_ke_oscu_serial_number, # Device serial number
}
session = requests.Session()
url = urljoin(self._l10n_ke_oscu_get_base_url(), 'selectInitOsdcInfo')
_logger.debug("Calling OSCU initialization")
try:
response = session.post(url, json=content, timeout=30)
except (ValueError, RequestException):
raise UserError(_('Error connecting with the KRA.'))
try:
response_content = response.json()
except JSONDecodeError:
raise UserError(_('Error decoding response from KRA.'))
_logger.debug("Response: %s", response_content)
if response_content['resultCd'] != '000':
if response_content['resultCd'] == '901':
raise ValidationError(_('The registration failed. Maybe you did not do the necessary steps with '
'the KRA or the device has been registered before elsewhere and you can copy the CMC key and '
'control unit id manually. '))
raise ValidationError(
_('Request Error Code: %s, Message: %s',
response_content['resultCd'],
response_content['resultMsg'])
)
if response_content['resultCd'] == '000':
info = response_content['data']['info']
self.l10n_ke_oscu_cmc_key = info['cmcKey']
self.l10n_ke_control_unit = info['sdcId']
def action_l10n_ke_get_items(self):
""" Fetch all the products we've saved on eTIMS.
We don't use this method, but must have it to demonstrate we are able to query their API.
"""
last_request_date = self.env['ir.config_parameter'].get_param('l10n_ke_edi_oscu.last_fetch_items_request_date', '20180101000000')
content = {'lastReqDt': last_request_date}
error, data, _dummy = self._l10n_ke_call_etims('selectItemList', content)
if error:
raise UserError(error['message'])
raise UserError(json.dumps(data, indent=4))
def action_l10n_ke_get_stock_moves(self):
""" Fetch all the stock moves we've saved on eTIMS.
We don't use this method, but must have it to demonstrate we are able to query their API.
"""
content = {
'lastReqDt': '20180301000000',
}
error, data, _dummy = self._l10n_ke_call_etims('selectStockMoveList', content)
if error:
raise UserError(error['message'])
raise UserError(json.dumps(data, indent=4))
def action_l10n_ke_send_insurance(self):
""" Send the company's insurance status to eTIMS.
We don't use this method, but must have it to demonstrate we are able to query their API.
It may be useful for companies that must mandatorily take an insurance policy (e.g. pharmacies)
"""
content = {
'isrccCd': self.l10n_ke_insurance_code,
'isrccNm': self.l10n_ke_insurance_name,
'isrcRt': self.l10n_ke_insurance_rate,
'useYn': 'Y',
**self._l10n_ke_get_user_dict(self.env.user, self.env.user),
}
error, _data, _dummy = self._l10n_ke_call_etims('saveBhfInsurance', content)
if error:
raise UserError(error['message'])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'sticky': False,
'message': _("Insurance status successfully registered"),
'next': {'type': 'ir.actions.act_window_close'},
}
}
def action_l10n_ke_create_branches(self):
""" Query eTIMS for saved company branches, then create those branches in Odoo. """
content = {'lastReqDt': '20180101000000'}
error, data, _dummy = self._l10n_ke_call_etims('selectBhfList', content)
if error:
raise UserError(error['message'])
for bhf in data['bhfList']:
if bhf['bhfId'] != self.l10n_ke_branch_code:
company = self.search([('id', 'child_of', self.id), ('l10n_ke_branch_code', '=', bhf['bhfId'])], limit=1)
if not company:
self.create({
'parent_id': self.id,
'name': bhf['bhfNm'],
'vat': bhf['tin'],
'l10n_ke_server_mode': self.l10n_ke_server_mode,
'l10n_ke_branch_code': bhf['bhfId'],
'state_id': self.env['res.country.state'].search([('country_id.code', '=', 'KE'), ('name', '=', bhf['prvncNm'])], limit=1).id,
'street': bhf['dstrtNm'],
'street2': bhf['sctrNm'],
'email': bhf['mgrEmail'],
'country_id': self.env.ref('base.ke').id,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'sticky': False,
'message': _("Branches successfully created"),
'next': {'type': 'ir.actions.act_window_close'},
}
}
# === Helpers: calling eTIMS endpoints ===
def _l10n_ke_oscu_get_base_url(self):
""" Returns the base url for the OSCU API depending on whether the company is in test mode """
return f"https://etims-api{'-sbx' if self.l10n_ke_server_mode == 'test' else ''}.kra.go.ke/etims-api/"
def _l10n_ke_call_etims(self, urlext, content):
""" Make a request to the OSCU
:param string urlext: the extension of the url, represents the API endpoint to call.
:param dict content: represents the json content to be used in the request
:returns: a tuple (dict errors, dict data, string result_date)
"""
session = requests.Session()
session.headers.update({
'tin': self.vat,
'bhfid': self.l10n_ke_branch_code,
'cmcKey': self.sudo().l10n_ke_oscu_cmc_key,
})
url = urljoin(self._l10n_ke_oscu_get_base_url(), urlext)
_logger.debug("Calling endpoint: %s", urlext)
_logger.debug(content)
try:
if self.l10n_ke_server_mode != 'demo':
response = session.post(url, json=content, timeout=45) # Long timeout because eTIMS can often have congestion
else:
response = self._l10n_ke_get_demo_response(urlext, content)
_logger.debug(response.text)
except Timeout:
msg = _("Timeout Error: KRA is currently unable to process your document. Please try again later. Thank you for your patience.")
return {'code': 'TIM', 'message': msg}, {}, 'timeout_error'
except (ValueError, RequestException) as e:
return {'code': 'CON', 'message': _("Connection Error: %s\n", e)}, {}, 'connection_error'
try:
response_dict = response.json()
except JSONDecodeError:
return {'code': 'JSON', 'message': response.content}, {}, None
if response_dict['resultCd'] == '000':
return {}, response_dict['data'], response_dict['resultDt']
else:
return {'code': response_dict['resultCd'], 'message': response_dict['resultMsg']}, {}, response_dict['resultDt']
@api.model
def _l10n_ke_get_user_dict(self, create_user, write_user):
return {
'regrId': create_user.id,
'regrNm': create_user.name,
'modrId': write_user.id,
'modrNm': write_user.name,
}
def _l10n_ke_get_demo_response(self, urlext, content):
""" Get a mocked response in demo mode. """
class Response:
def __init__(self, content):
self.content = content
self.text = content.decode()
def json(self):
return json.loads(self.content)
stock_services = (
'insertStockIO',
'saveStockMaster',
'selectImportItemList',
'selectItemList',
'updateImportItem',
)
module = 'l10n_ke_edi_oscu_stock' if urlext in stock_services else 'l10n_ke_edi_oscu'
response_files = {
'insertTrnsPurchase': 'success',
'insertStockIO': 'success',
'saveBhfCustomer': 'success',
'saveBhfInsurance': 'success',
'saveBhfUser': 'success',
'saveItem': 'success',
'saveItemComposition': 'success',
'saveStockMaster': 'success',
'saveTrnsSalesOsdc': 'save_sale_success',
'selectBhfList': 'get_branches',
'selectCodeList': 'get_codes',
'selectCustomer': 'get_customer',
'selectImportItemList': 'get_imports_1',
'selectItemClsList': 'get_unspsc_codes',
'selectItemList': 'get_items',
'selectNoticeList': 'get_notices',
'selectStockMoveList': 'get_stock_moves',
'selectTrnsPurchaseSalesList': 'get_purchases_1' if 'l10n_ke_oscu_last_fetch_customs_import_date' in self else 'get_purchases_2',
'updateImportItem': 'success',
}
with file_open(f'{module}/tests/mocked_responses/{response_files[urlext]}.json', 'rb') as response_file:
content = response_file.read()
return Response(content)
def _l10n_ke_find_for_cron(self, failed_action=''):
company = self.env['res.company'].search(
[
('l10n_ke_oscu_is_active', '=', True),
('l10n_ke_server_mode', '!=', 'demo'),
],
limit=1,
)
if not company:
_logger.warning("No OSCU initialized company could be found. %s", failed_action)
return company
class BaseDocumentLayout(models.TransientModel):
_inherit = 'base.document.layout'
@api.model
def _default_company_details(self):
"""The KRA requires that the VAT number appears in the header of the document."""
company_details = super()._default_company_details()
company = self.env.company
if company.vat and company.l10n_ke_oscu_is_active:
return company_details + Markup('<br/> %s') % _('KRA PIN: %s', company.vat)
return company_details
@api.model
def _default_report_footer(self):
if (company := self.env.company) and company.vat and company.l10n_ke_oscu_is_active:
footer_fields = filter(None, [company.phone, company.email, company.website])
return Markup(' ').join(footer_fields)
return super()._default_report_footer()