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

364 lines
18 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from collections import defaultdict
import itertools
from psycopg2.errors import LockNotAvailable
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import groupby
from odoo.tools.float_utils import json_float_round
class StockMove(models.Model):
_inherit = 'stock.move'
country_code = fields.Char(related='company_id.account_fiscal_country_id.code')
l10n_ke_oscu_flow_type_code = fields.Selection(
selection=[
('01', "Import Incoming"),
('02', "Purchase Incoming"),
('03', "Return Incoming"),
('04', "Stock Movement Incoming"),
('05', "Processing Incoming"),
('06', "Adjustment Incoming"),
('11', "Sale Outgoing"),
('12', "Return Outgoing"),
('13', "Stock Movement Outgoing"),
('14', "Processing Outgoing"),
('15', "Discarding Outgoing"),
('16', "Adjustment Outgoing"),
],
compute='_compute_l10n_ke_oscu_flow_type_code',
string="eTIMS Category",
store=True, readonly=False, copy=False,
)
l10n_ke_oscu_sar_number = fields.Integer(
string="Store and Release Number",
copy=False,
help="Number used by the KRA to identify stock movements",
)
l10n_ke_oscu_attachment_id = fields.Many2one(
comodel_name='ir.attachment',
string="eTIMS Stock IO content",
copy=False,
help="JSON file sent to eTIMS for Stock IO",
ondelete='set null',
groups='base.group_system',
)
# === Computes === #
@api.depends('location_id.usage', 'location_dest_id.usage', 'partner_id')
def _compute_l10n_ke_oscu_flow_type_code(self):
flow_mappings = {
# Partner type, location_id.usage, location_dest_id.usage
# ruff: noqa: E241
('external', 'supplier', 'internal'): '02', # Purchase Incoming
(False, 'customer', 'internal'): '03', # Return Incoming
('branch', 'supplier', 'internal'): '04', # Stock Move Incoming
(False, 'production', 'internal'): '05', # Processing Incoming
(False, 'inventory', 'internal'): '06', # Adjustment Incoming
(False, 'supplier', 'internal'): '06',
('external', 'internal', 'customer'): '11', # Sale Outgoing
(False, 'internal', 'supplier'): '12', # Return Outgoing
('branch', 'internal', 'customer'): '13', # Stock Move Outgoing
(False, 'internal', 'production'): '14', # Processing Outgoing
(False, 'internal', 'customer'): '16', # Adjustment Outgoing
(False, 'internal', 'inventory'): '16', # Adjustment Outgoing
}
ke_moves = self.filtered(lambda m: m.company_id.country_id.code == "KE")
(self - ke_moves).l10n_ke_oscu_flow_type_code = False
for move in ke_moves:
if move.scrapped:
move.l10n_ke_oscu_flow_type_code = '15' # Discarding Outgoing
continue
partner_type = 'internal'
if partner := move.picking_id.partner_id:
company = self.env['res.company'].search([('partner_id', '=', partner.id)], limit=1)
partner_type = 'branch' if company and company.account_fiscal_country_id.code == 'KE' else 'external'
code = flow_mappings.get(
(partner_type, move.location_id.usage, move.location_dest_id.usage)
) or flow_mappings.get((False, move.location_id.usage, move.location_dest_id.usage))
if code == '02' and move.picking_id.partner_id.country_id.code not in ['KE', False]:
code = '01'
move.l10n_ke_oscu_flow_type_code = code
@api.ondelete(at_uninstall=False)
def _unlink_only_if_unsent(self):
if self.filtered(lambda m: m.sudo().l10n_ke_oscu_attachment_id):
raise UserError(_('You cannot delete a stock move once it has been sent to eTIMS!'))
# === Overrides === #
def _action_done(self, cancel_backorder=False):
# EXTENDS 'stock'
res = super()._action_done(cancel_backorder=cancel_backorder)
if self.filtered(lambda m: m.l10n_ke_oscu_flow_type_code):
self.env.ref('l10n_ke_edi_oscu_stock.ir_cron_send_stock_moves')._trigger()
return res
# === Sending to KRA: Stock IO === #
def _calculate_unit_cost(self):
""" For stockable products we can easily use the stock valuation layers to calculate the unit price"""
self.ensure_one()
unit_price = 0
quantity_product_uom = self.product_uom._compute_quantity(self.quantity, self.product_id.uom_id)
for layer in self.stock_valuation_layer_ids:
unit_price += layer.unit_cost * (quantity_product_uom / layer.quantity)
return unit_price
def _l10n_ke_oscu_save_stock_io_content(self):
""" Send a recordset of stock moves to eTIMS.
All records should have the same partner_id, flow type code and date.
"""
first_move = self[0]
customer_info = {
'custTin': first_move.partner_id.vat or None, # Customer TIN
'custNm': first_move.partner_id.name or None, # Customer Name
'custBhfId': first_move.partner_id.l10n_ke_branch_code or None, # Customer Branch ID
}
lines_vals = []
for index, move in enumerate(self):
product = move.product_id # for ease of use
taxes = product.taxes_id.filtered(lambda tax: tax.l10n_ke_tax_type_id)
tax_rate = (taxes[0].amount / 100) if taxes else 0
quantity_product_uom = move.product_uom._compute_quantity(move.quantity, move.product_id.uom_id)
# but get from product for now
price = abs(move._calculate_unit_cost()) * (move.quantity / quantity_product_uom)
price = price or move.product_id.standard_price # Suppose the user forgot to set it
base_amount = quantity_product_uom * price
lines_vals.append({
'itemSeq': index + 1,
'itemCd': product.l10n_ke_item_code, # Item code (if it's there)
'itemClsCd': product.unspsc_code_id.code, # UNSPSC Code
'itemNm': product.name, # Product name
'bcd': product.barcode or '', # Barcode
'pkgUnitCd': product.l10n_ke_packaging_unit_id.code, # Packaging unit code
'pkg': json_float_round(quantity_product_uom / product.l10n_ke_packaging_quantity, 2), # Packaging quantity
'qtyUnitCd': move.product_uom.l10n_ke_quantity_unit_id.code, # UoM (but as defined by Kenya)
'qty': json_float_round(move.quantity, 2), # Quantity
'prc': json_float_round(price, 2), # Unit price cost
'splyAmt': json_float_round(base_amount, 2), # Cost of items
'totDcAmt': 0, # Total discount amount
'taxblAmt': json_float_round(base_amount, 2), # Taxable amount
'taxTyCd': product._l10n_ke_get_tax_type().code, # Tax type code
'taxAmt': json_float_round(base_amount * tax_rate, 2), # Tax amount
'totAmt': json_float_round(base_amount * (1 + tax_rate), 2) # Total amount
})
content = {
**customer_info,
'regTyCd': 'M', # Registration type code (if this becomes automatic, then A)
'sarTyCd': first_move.l10n_ke_oscu_flow_type_code, # Stored and released type code
'ocrnDt': first_move.date.strftime('%Y%m%d'), # Occurred date
**self.env.company._l10n_ke_get_user_dict(first_move.create_uid, first_move.write_uid),
'totItemCnt': len(self),
'totTaxblAmt': json_float_round(sum(float(line['taxblAmt']) for line in lines_vals), 2),
'totTaxAmt': json_float_round(sum(float(line['taxAmt']) for line in lines_vals), 2),
'totAmt': json_float_round(sum(float(line['totAmt']) for line in lines_vals), 2),
'itemList': lines_vals,
}
return content
def _l10n_ke_oscu_save_stock_io(self):
content = {
**self._l10n_ke_oscu_save_stock_io_content(),
'orgSarNo': self[0].picking_id.backorder_id and self[0].picking_id.backorder_id.move_ids[0].l10n_ke_oscu_sar_number or 0,
}
try:
content['sarNo'] = self.company_id._l10n_ke_get_sar_sequence().next_by_id()
except LockNotAvailable:
raise UserError(_("Another user is already sending this picking.")) from None
self.l10n_ke_oscu_sar_number = content['sarNo']
error, _dummy, _dummy = self.company_id._l10n_ke_call_etims('insertStockIO', content)
if error:
# Instead of rolling back entirely, we just unassign the number and unincrement the sequence
self.l10n_ke_oscu_sar_number = False
self.company_id._l10n_ke_get_sar_sequence().number_next -= 1
return error, content
@api.model
def _l10n_ke_oscu_process_moves(self):
""" Send the stock moves in `self` to eTIMS:
- register the product if needed
- send the stock IOs for the moves batched by picking, date, and flow type code
- send the stock master for all products for which at least one stock IO was successfully sent.
"""
if not self:
return
# Step 1: Register products
products_to_register = self.product_id.filtered(lambda p: not p.l10n_ke_item_code)
products_data = defaultdict(dict)
for product in products_to_register:
error, content = product._l10n_ke_oscu_save_item()
products_data[product]['registration_content'] = content
products_data[product]['registration_error'] = error
if self.env['account.move.send']._can_commit():
self.env.cr.commit()
# Step 2: Send Stock IO for moves.
# Moves with a picking should grouped by picking ID. Moves without should be grouped by flow type code and date.
# Only send moves for products that were successfully registered.
moves_to_send = self.filtered(lambda m: m.product_id.l10n_ke_item_code)
move_send_batches = {
move_batch_key: self.env['stock.move'].union(*moves).with_prefetch(moves_to_send.ids)
for move_batch_key, moves in groupby(moves_to_send, lambda m: (m.picking_id, m.l10n_ke_oscu_flow_type_code, m.date))
}
move_batches_data = defaultdict(dict)
products_to_send_stock_master = self.env['product.product']
for move_batch_key, moves in move_send_batches.items():
error, content = moves._l10n_ke_oscu_save_stock_io()
move_batches_data[move_batch_key]['content'] = content
move_batches_data[move_batch_key]['error'] = error
if not error:
products_to_send_stock_master |= moves.product_id
if self.env['account.move.send']._can_commit():
self.env.cr.commit()
# Step 3: Send Stock Master for all products where at least one Stock IO succeeded.
for product in products_to_send_stock_master:
error, content = product._l10n_ke_oscu_save_stock_master()
products_data[product]['stock_master_content'] = content
products_data[product]['stock_master_error'] = error
if self.env['account.move.send']._can_commit():
self.env.cr.commit()
# Step 4: Update picking error message
is_error = False
for picking in self.picking_id:
move_batch_keys = {(m.picking_id, m.l10n_ke_oscu_flow_type_code, m.date) for m in picking.move_ids}
errors = list(itertools.chain(
(products_data[product].get('registration_error') for product in picking.move_ids.product_id),
(move_batches_data[move_batch_key].get('error') for move_batch_key in move_batch_keys),
(products_data[product].get('stock_master_error') for product in picking.move_ids.product_id),
))
unique_errors = list({f"{e['code']} {e['message']}" for e in errors if e}) # Don't show duplicate errors
if unique_errors:
is_error = True
picking.l10n_ke_error_msg = {
f'message_{i}': {
'message': error_msg,
}
for i, error_msg in enumerate(unique_errors)
}
# Step 5: Create attachments on stock.move, one for each batch that was *successfully* sent.
for move_batch_key, moves in move_send_batches.items():
if not move_batches_data[move_batch_key]['error']:
contents = list(itertools.chain(
(
products_data[product]['register_content']
for product in moves.product_id
if 'register_content' in products_data[product]
),
(
move_batches_data[move_batch_key]['content']
),
(
products_data[product]['stock_master_content']
for product in moves.product_id
if 'stock_master_content' in products_data[product]
)
))
picking, flow_type_code, date = move_batch_key
if picking:
filename_prefix = f"KRA_stock_{picking.name.replace('/', '_')}"
else:
filename_prefix = f"KRA_stock_{flow_type_code}_{date}"
filename = f'{filename_prefix}.json'
i = 1
while self.env['ir.attachment'].search_count([('name', '=', filename)], limit=1):
filename = f'{filename_prefix}_{i}.json'
i += 1
attachment = self.env['ir.attachment'].create({
'name': filename,
'raw': json.dumps(contents, indent=4),
'res_model': picking.id and 'stock.picking',
'res_id': picking.id,
})
moves.sudo().write({
'l10n_ke_oscu_attachment_id': attachment.id,
})
return is_error
@api.model
def _cron_l10n_ke_oscu_process_moves(self):
companies = self.env.companies.filtered(lambda c: c.l10n_ke_oscu_is_active)
for company in companies:
# Determine stock moves to send
moves_need_reporting_domain = [
('product_id.type', '=', 'product'),
('state', '=', 'done'),
('l10n_ke_oscu_flow_type_code', '!=', False),
('l10n_ke_oscu_sar_number', '=', False),
('company_id', '=', company.id),
]
# This param is set when l10n_ke_edi_oscu_stock is installed, and ensures that old moves are not sent.
if from_date := self.env['ir.config_parameter'].sudo().get_param('l10n_ke.start_stock_date'):
moves_need_reporting_domain += [('date', '>=', from_date)]
# We set the env company here because it is needed for the stock master to correctly compute product quantities.
moves_need_reporting = self.with_context(allowed_company_ids=company.ids).search(moves_need_reporting_domain, order='date,id')
# Don't send moves linked to a picking if the corresponding invoice wasn't yet sent (KRA requirement)
# or if there is missing information on the product / UoM.
moves_need_reporting = moves_need_reporting.filtered(
lambda m: (
not m.picking_id.l10n_ke_validation_msg
if m.picking_id
else (
not m.product_id._l10n_ke_get_validation_messages()
and not m.product_uom._l10n_ke_get_validation_messages()
)
)
)
moves_need_reporting._l10n_ke_oscu_process_moves()
def action_l10n_ke_oscu_process_moves(self):
if any(p.type != 'product' for p in self.product_id):
raise UserError(_("Only stockable products may be sent."))
if any(m.l10n_ke_oscu_sar_number for m in self):
raise UserError(_("This stock move has already been sent."))
if any(p._l10n_ke_get_validation_messages() for p in self.product_id):
raise UserError(_("Information is missing on the product."))
if any(uom._l10n_ke_get_validation_messages() for uom in self.product_uom):
raise UserError(_("Information is missing on the unit of measure."))
is_error = self._l10n_ke_oscu_process_moves()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success' if not is_error else 'danger',
'sticky': False,
'message': (
_("Stock IO and stock master successfully reported")
if not is_error
else _("Some errors occurred while reporting stock IO and stock master, see pickings for error messages.")
),
'next': {'type': 'ir.actions.act_window_close'},
}
}