forked from Mapan/odoo17e
445 lines
20 KiB
Python
445 lines
20 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import logging
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.addons.l10n_ke_edi_oscu.models.account_move import format_etims_datetime
|
|
from odoo.tools import SQL
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
PRODUCT_TYPE_CODE_SELECTION = [('1', "Raw Material"), ('2', "Finished Product"), ('3', "Service")]
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_inherit = 'product.template'
|
|
l10n_ke_packaging_unit_id = fields.Many2one(
|
|
comodel_name='l10n_ke_edi_oscu.code',
|
|
string="Packaging Unit",
|
|
compute='_compute_l10n_ke_packaging_unit_id',
|
|
inverse='_set_l10n_ke_packaging_unit_id',
|
|
domain=[('code_type', '=', '17')],
|
|
help="KRA code that describes the type of packaging used.",
|
|
)
|
|
l10n_ke_packaging_quantity = fields.Float(
|
|
string="Package Quantity",
|
|
compute='_compute_l10n_ke_packaging_quantity',
|
|
inverse='_set_l10n_ke_packaging_quantity',
|
|
help="Number of products in a package.",
|
|
)
|
|
l10n_ke_origin_country_id = fields.Many2one(
|
|
comodel_name='res.country',
|
|
string="Origin Country",
|
|
compute='_compute_l10n_ke_origin_country_id',
|
|
inverse='_set_l10n_ke_origin_country_id',
|
|
help="The origin country of the product.",
|
|
)
|
|
l10n_ke_product_type_code = fields.Selection(
|
|
string="eTIMS Product Type",
|
|
selection=PRODUCT_TYPE_CODE_SELECTION,
|
|
compute='_compute_l10n_ke_product_type_code',
|
|
inverse='_set_l10n_ke_product_type_code',
|
|
help="Used by eTIMS to determine the type of the product",
|
|
)
|
|
l10n_ke_is_insurance_applicable = fields.Boolean(
|
|
string="Insurance Applicable",
|
|
help="Check this box if the product is covered by insurance.",
|
|
compute='_compute_l10n_ke_is_insurance_applicable',
|
|
inverse='_set_l10n_ke_is_insurance_applicable',
|
|
)
|
|
l10n_ke_item_code = fields.Char(
|
|
string="KRA Item Code",
|
|
help="The code assigned to this product on eTIMS",
|
|
compute='_compute_l10n_ke_item_code',
|
|
)
|
|
|
|
# === Computes === #
|
|
@api.depends('product_variant_ids.l10n_ke_packaging_unit_id')
|
|
def _compute_l10n_ke_packaging_unit_id(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_packaging_unit_id')
|
|
|
|
def _set_l10n_ke_packaging_unit_id(self):
|
|
self._set_product_variant_field('l10n_ke_packaging_unit_id')
|
|
|
|
@api.depends('product_variant_ids.l10n_ke_packaging_quantity')
|
|
def _compute_l10n_ke_packaging_quantity(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_packaging_quantity')
|
|
|
|
def _set_l10n_ke_packaging_quantity(self):
|
|
self._set_product_variant_field('l10n_ke_packaging_quantity')
|
|
|
|
@api.depends('product_variant_ids.l10n_ke_origin_country_id')
|
|
def _compute_l10n_ke_origin_country_id(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_origin_country_id')
|
|
|
|
def _set_l10n_ke_origin_country_id(self):
|
|
self._set_product_variant_field('l10n_ke_origin_country_id')
|
|
|
|
@api.depends('product_variant_ids.l10n_ke_product_type_code')
|
|
def _compute_l10n_ke_product_type_code(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_product_type_code')
|
|
|
|
def _set_l10n_ke_product_type_code(self):
|
|
self._set_product_variant_field('l10n_ke_product_type_code')
|
|
|
|
@api.depends('product_variant_ids.l10n_ke_is_insurance_applicable')
|
|
def _compute_l10n_ke_is_insurance_applicable(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_is_insurance_applicable')
|
|
|
|
def _set_l10n_ke_is_insurance_applicable(self):
|
|
self._set_product_variant_field('l10n_ke_is_insurance_applicable')
|
|
|
|
@api.depends('product_variant_ids.l10n_ke_item_code')
|
|
def _compute_l10n_ke_item_code(self):
|
|
self._compute_template_field_from_variant_field('l10n_ke_item_code')
|
|
|
|
# === Actions === #
|
|
|
|
def action_l10n_ke_oscu_save_item(self):
|
|
if self.product_variant_count != 1:
|
|
raise UserError(_("There should only be one product variant per product template!"))
|
|
return self.product_variant_ids.action_l10n_ke_oscu_save_item()
|
|
|
|
def action_l10n_ke_oscu_save_stock_master(self):
|
|
if self.product_variant_count != 1:
|
|
raise UserError(_("There should only be one product variant per product template!"))
|
|
return self.product_variant_ids.action_l10n_ke_oscu_save_stock_master()
|
|
|
|
def _get_related_fields_variant_template(self):
|
|
# EXTENDS 'product'
|
|
return [
|
|
*super()._get_related_fields_variant_template(),
|
|
'l10n_ke_packaging_unit_id',
|
|
'l10n_ke_packaging_quantity',
|
|
'l10n_ke_origin_country_id',
|
|
'l10n_ke_product_type_code',
|
|
'l10n_ke_is_insurance_applicable',
|
|
]
|
|
|
|
|
|
class ProductProduct(models.Model):
|
|
_inherit = 'product.product'
|
|
|
|
l10n_ke_packaging_unit_id = fields.Many2one(
|
|
comodel_name='l10n_ke_edi_oscu.code',
|
|
string="Packaging Unit",
|
|
domain=[('code_type', '=', '17')],
|
|
compute='_compute_l10n_ke_packaging_unit_id',
|
|
store=True,
|
|
readonly=False,
|
|
help="KRA code that describes the type of packaging used.",
|
|
)
|
|
l10n_ke_packaging_quantity = fields.Float(
|
|
string="Package Quantity",
|
|
help="Number of products in a package.",
|
|
default=1,
|
|
)
|
|
l10n_ke_origin_country_id = fields.Many2one(
|
|
comodel_name='res.country',
|
|
string="Origin Country",
|
|
help="The origin country of the product.",
|
|
)
|
|
l10n_ke_product_type_code = fields.Selection(
|
|
string="eTIMS Product Type",
|
|
selection=PRODUCT_TYPE_CODE_SELECTION,
|
|
compute='_compute_l10n_ke_product_type_code',
|
|
store=True,
|
|
readonly=False,
|
|
help="Used by eTIMS to determine the type of the product",
|
|
)
|
|
l10n_ke_is_insurance_applicable = fields.Boolean(
|
|
string="Insurance Applicable",
|
|
help="Check this box if the product is covered by insurance.",
|
|
)
|
|
l10n_ke_item_code = fields.Char(
|
|
string="Item Code",
|
|
help="The code assigned to this product on eTIMS",
|
|
readonly=True,
|
|
)
|
|
|
|
# === Computes / Getters === #
|
|
|
|
@api.depends('detailed_type')
|
|
def _compute_l10n_ke_packaging_unit_id(self):
|
|
service_packaging = self.env.ref('l10n_ke_edi_oscu.packaging_type_ou', raise_if_not_found=False)
|
|
for product in self.filtered(lambda p: not p.l10n_ke_packaging_unit_id):
|
|
product.l10n_ke_packaging_unit_id = service_packaging if product.detailed_type == 'service' else None
|
|
|
|
@api.depends('type')
|
|
def _compute_l10n_ke_product_type_code(self):
|
|
for product in self:
|
|
if product.type == 'service':
|
|
product.l10n_ke_product_type_code = '3'
|
|
|
|
def _l10n_ke_get_validation_messages(self, for_invoice=False):
|
|
""" Validate the product configuration and generate warning messages.
|
|
|
|
:param bool for_invoice: whether the validations should be done for the purpose of sending
|
|
the product information in an invoice, or for the purpose of saving the product.
|
|
:returns: a dictionary, containing the message, an associated action and a name
|
|
for the action.
|
|
"""
|
|
messages = {}
|
|
|
|
products_missing_fields = self.filtered(
|
|
lambda p: (
|
|
not p.unspsc_code_id or not p.l10n_ke_packaging_unit_id
|
|
or not p.l10n_ke_packaging_quantity
|
|
or (
|
|
not for_invoice
|
|
and (
|
|
not p.standard_price
|
|
or not p.l10n_ke_origin_country_id
|
|
or not p.l10n_ke_product_type_code
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
if products_missing_fields:
|
|
if for_invoice:
|
|
message = _(
|
|
"When sending to eTIMS, the products used must have a defined Packaging Unit, "
|
|
"Packaging Quantity and UNSPSC Code."
|
|
)
|
|
else:
|
|
message = _(
|
|
"When sending to eTIMS, the products used must have a defined Cost, Product Type, "
|
|
"Origin Country, Packaging Unit, Packaging Quantity and UNSPSC Code."
|
|
)
|
|
|
|
messages['product_fields_missing'] = {
|
|
'message': message,
|
|
'action_text': _("View Product(s)"),
|
|
'action': products_missing_fields._l10n_ke_action_open_products(),
|
|
'blocking': True,
|
|
}
|
|
|
|
products_incorrect_taxes = self.filtered(
|
|
lambda p: len(p.taxes_id.filtered(lambda t: t.l10n_ke_tax_type_id and t.company_id in self.env.company.parent_ids)) > 1
|
|
)
|
|
if products_incorrect_taxes:
|
|
messages['product_incorrect_taxes'] = {
|
|
'message': _("Only one tax with a KRA tax code should be set on the product!"),
|
|
'action_text': _("View Product(s)"),
|
|
'action': products_incorrect_taxes._get_records_action(name=_("View Product(s)"), context={}),
|
|
}
|
|
|
|
products_mismatched_taxes = self.filtered(
|
|
lambda p: p.taxes_id.filtered(
|
|
lambda t: (
|
|
t.l10n_ke_tax_type_id
|
|
and t.company_id in self.env.company.parent_ids
|
|
and p.unspsc_code_id.l10n_ke_tax_type_id
|
|
and t.l10n_ke_tax_type_id != p.unspsc_code_id.l10n_ke_tax_type_id
|
|
)
|
|
)
|
|
)
|
|
if products_mismatched_taxes:
|
|
messages['product_mismatched_taxes'] = {
|
|
'message': _(
|
|
"The tax set on the product has tax code %(product_tax_code)s, which differs from "
|
|
"the tax code %(unspsc_tax_code)s specified by the KRA for the UNSPSC code.",
|
|
product_tax_code=products_mismatched_taxes[0].taxes_id.filtered(lambda t: t.company_id in self.env.company.parent_ids).l10n_ke_tax_type_id.code,
|
|
unspsc_tax_code=products_mismatched_taxes[0].unspsc_code_id.l10n_ke_tax_type_id.code,
|
|
),
|
|
'action_text': _("View Product(s)"),
|
|
'action': products_incorrect_taxes._get_records_action(name=_("View Product(s)"), context={}),
|
|
}
|
|
|
|
return messages
|
|
|
|
def _l10n_ke_get_tax_type(self):
|
|
""" Get the tax type associated with the product, given the current company. """
|
|
self.ensure_one()
|
|
return self.taxes_id.filtered(lambda t: t.company_id in self.env.company.parent_ids).l10n_ke_tax_type_id
|
|
|
|
def _l10n_ke_action_open_products(self, title=None):
|
|
""" Open a view with the required fields for saving a product on eTIMS """
|
|
res = {
|
|
'name': title or _("Products"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'product.product',
|
|
'domain': [('id', 'in', self.ids)],
|
|
'view_mode': 'tree',
|
|
'views': [(self.env.ref('l10n_ke_edi_oscu.l10n_ke_kra_product_tree').id, 'tree'), (False, 'form')],
|
|
'context': {'create': False, 'delete': False},
|
|
}
|
|
return res
|
|
|
|
# === Saving to KRA === #
|
|
|
|
def _calculate_l10n_ke_item_code(self):
|
|
""" Computes the item code of a given product
|
|
|
|
For instance KE1NTXU is an item code, where
|
|
KE: first two digits are the origin country of the product
|
|
1: the product type (raw material)
|
|
NT: the packaging type
|
|
XU: the quantity type
|
|
0000006: a unique value (id in our case)
|
|
"""
|
|
code_fields = [
|
|
self.l10n_ke_origin_country_id.code,
|
|
self.l10n_ke_product_type_code,
|
|
self.l10n_ke_packaging_unit_id.code,
|
|
self.uom_id.l10n_ke_quantity_unit_id.code,
|
|
]
|
|
if not all(code_fields):
|
|
return None
|
|
|
|
item_code_prefix = ''.join(code_fields)
|
|
return item_code_prefix.ljust(20 - len(str(self.id)), '0') + str(self.id)
|
|
|
|
def _l10n_ke_oscu_save_item_content(self):
|
|
""" Get a dict of values to be sent to the KRA for saving a product's information. """
|
|
self.ensure_one()
|
|
code = self.l10n_ke_item_code or self._calculate_l10n_ke_item_code()
|
|
content = {
|
|
'itemCd': code, # Item Code
|
|
'itemClsCd': self.unspsc_code_id.code or '', # HS Code (unspsc format)
|
|
'itemTyCd': self.l10n_ke_product_type_code, # Generally raw material, finished product, service
|
|
'itemNm': self.name, # Product name
|
|
'orgnNatCd': self.l10n_ke_origin_country_id.code, # Origin nation code
|
|
'pkgUnitCd': self.l10n_ke_packaging_unit_id.code, # Packaging unit code
|
|
'qtyUnitCd': self.uom_id.l10n_ke_quantity_unit_id.code, # Quantity unit code
|
|
'taxTyCd': self._l10n_ke_get_tax_type().code, # Tax type code
|
|
'bcd': self.barcode or None, # Self barcode
|
|
'dftPrc': self.standard_price, # Standard price
|
|
'isrcAplcbYn': 'Y' if self.l10n_ke_is_insurance_applicable else 'N', # Is insurance applicable
|
|
'useYn': 'Y',
|
|
**self.env.company._l10n_ke_get_user_dict(self.create_uid, self.write_uid),
|
|
}
|
|
return content
|
|
|
|
def _l10n_ke_oscu_save_item(self):
|
|
""" Register a product with eTIMS. """
|
|
content = self._l10n_ke_oscu_save_item_content()
|
|
error, _data, _date = self.env.company._l10n_ke_call_etims('saveItem', content)
|
|
if not error:
|
|
self.l10n_ke_item_code = content['itemCd']
|
|
return error, content
|
|
|
|
def action_l10n_ke_oscu_save_item(self):
|
|
""" Register a product with eTIMS (user action).
|
|
|
|
Regstration allows the product to be used via its itemCd in other requests such as invoice
|
|
and stock move reporting.
|
|
"""
|
|
validation_messages = self._l10n_ke_get_validation_messages(for_invoice=False)
|
|
for message in validation_messages.values():
|
|
if message.get('blocking'):
|
|
raise UserError(_("Cannot register '%s' on eTIMS:\n%s", self.name, message['message']))
|
|
error, _content = self._l10n_ke_oscu_save_item()
|
|
if error:
|
|
raise UserError(f"[{error['code']}] {error['message']}")
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'type': 'success',
|
|
'sticky': False,
|
|
'message': _("Product successfully registered"),
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
}
|
|
}
|
|
|
|
# === Helpers for invoice import === #
|
|
|
|
@api.model
|
|
def _l10n_ke_oscu_find_product_from_json(self, product_dict):
|
|
""" Find a product matching that of a given product represented json format provided by the API
|
|
|
|
:param dict product_dict: dictionary representing the fields of the product as obtained from
|
|
the API.
|
|
:returns: a tuple, containing a product (or None type) that is strongest
|
|
match to an item with the given details, and a message
|
|
describing the method by which the matching that was accomplished.
|
|
"""
|
|
if product_dict.get('bcd'):
|
|
search_domain = [('barcode', '=', product_dict['bcd']), ('unspsc_code_id.code', '=', product_dict['itemClsCd'])]
|
|
if (product := self.search(search_domain, limit=1)):
|
|
return product, _('"%s" matched using an exact matching of barcode and UNSPSC code.', product.name)
|
|
else:
|
|
return None, _(
|
|
'"%(item_name)s" could not be matched to any product, since it has a barcode (%(barcode)s) and UNSPSC'
|
|
'code (%(unspsc_code)s) that do not match any existing product.',
|
|
item_name=product_dict['itemNm'],
|
|
barcode=product_dict['bcd'],
|
|
unspsc_code=product_dict['itemClsCd'],
|
|
)
|
|
|
|
if (product := self.search([
|
|
('unspsc_code_id.code', '=', product_dict['itemClsCd']),
|
|
('name', 'ilike', product_dict['itemNm'])
|
|
], limit=1)):
|
|
return product, _('"%s" matched using an exact matching of name and UNSPSC code.', product.name)
|
|
|
|
fuzzy_name = ('name', 'ilike', f"%{'%'.join(product_dict['itemNm'].split())}%")
|
|
search_domain = [('unspsc_code_id.code', '=', product_dict['itemClsCd']), fuzzy_name]
|
|
if (product := product_dict.get('itemClsCd') and self.search(search_domain, limit=1)):
|
|
return product, _(
|
|
'"%s" matched using an inexact matching of name and an exact matching of UNSPSC code.',
|
|
product.name
|
|
)
|
|
|
|
return None, _(
|
|
'The product "%(product_name)s" with UNSPSC code: "%(unspsc_code)s" could not be matched to any existing product.',
|
|
product_name=product_dict['itemNm'],
|
|
unspsc_code=product_dict['itemClsCd'],
|
|
)
|
|
|
|
|
|
class ProductCode(models.Model):
|
|
_inherit = 'product.unspsc.code'
|
|
|
|
l10n_ke_tax_type_id = fields.Many2one('l10n_ke_edi_oscu.code')
|
|
|
|
def _cron_l10n_ke_oscu_get_codes_from_device(self):
|
|
""" Automatically fetch and create UNSPSC codes from the OSCU if they don't already exist """
|
|
company = self.env['res.company']._l10n_ke_find_for_cron(failed_action='No KRA Codes fetched.')
|
|
if not company:
|
|
return
|
|
|
|
tax_codes = {
|
|
tax_code['code']: tax_code['id']
|
|
for tax_code in self.env['l10n_ke_edi_oscu.code'].search_read([('code_type', '=', '04')], ['code'])
|
|
}
|
|
last_request_date = self.env['ir.config_parameter'].get_param('l10n_ke_oscu.last_unspsc_code_request_date', '20180101000000')
|
|
error, data, _date = company._l10n_ke_call_etims('selectItemClsList', {'lastReqDt': last_request_date})
|
|
if error:
|
|
if error.get('code') == '001':
|
|
_logger.info("No new UNSPSC codes fetched from the OSCU.")
|
|
return
|
|
raise UserError(f"[{error['code']}] {error['message']}")
|
|
|
|
cls_list = {item['itemClsCd']: item for item in data['itemClsList']}
|
|
existing_codes = self.with_context(active_test=False).search([
|
|
('code', 'in', list(cls_list.keys()))
|
|
])
|
|
for code in existing_codes:
|
|
if (new_tax_code := not code.l10n_ke_tax_type_id and cls_list[code.code]['taxTyCd']):
|
|
code.write({'l10n_ke_tax_type_id': tax_codes.get(new_tax_code)})
|
|
|
|
new_codes = self.env['product.unspsc.code'].create([
|
|
{
|
|
'name': code_dict['itemClsNm'],
|
|
'code': code,
|
|
'applies_to': 'product',
|
|
'l10n_ke_tax_type_id': tax_codes.get(code_dict['taxTyCd']),
|
|
'active': True,
|
|
}
|
|
for code, code_dict in cls_list.items() if code not in existing_codes.mapped('code')
|
|
])
|
|
if new_codes:
|
|
self._cr.execute(SQL("""
|
|
INSERT INTO ir_model_data
|
|
(name, res_id, module, model, noupdate)
|
|
SELECT concat('unspsc_code_', code), id, 'product_unspsc', 'product.unspsc.code', 't'
|
|
FROM product_unspsc_code
|
|
WHERE product_unspsc_code.id IN %s""", tuple(new_codes.ids)))
|
|
|
|
_logger.info("%i UNSPSC codes fetched from the OSCU, %i UNSPSC codes created", len(cls_list), len(new_codes))
|
|
self.env['ir.config_parameter'].sudo().set_param('l10n_ke_oscu.last_unspsc_code_request_date', format_etims_datetime(fields.Datetime.now()))
|