Odoo18-Base/addons/l10n_in_ewaybill_stock/models/l10n_in_ewaybill.py
2025-01-06 10:57:38 +07:00

641 lines
26 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import logging
import pytz
import re
from collections import defaultdict
from datetime import datetime
import psycopg2.errors
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.addons.l10n_in_ewaybill_stock.tools.ewaybill_api import EWayBillApi, EWayBillError
_logger = logging.getLogger(__name__)
class Ewaybill(models.Model):
_name = "l10n.in.ewaybill"
_description = "e-Waybill"
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
_check_company_auto = True
# Ewaybill details generated from the API
name = fields.Char("e-Waybill Number", copy=False, readonly=True, tracking=True)
ewaybill_date = fields.Date("e-Waybill Date", copy=False, readonly=True, tracking=True)
ewaybill_expiry_date = fields.Date("e-Waybill Valid Upto", copy=False, readonly=True, tracking=True)
state = fields.Selection(string='Status', selection=[
('pending', 'Pending'),
('challan', 'Challan'),
('generated', 'Generated'),
('cancel', 'Cancelled'),
], required=True, readonly=True, copy=False, tracking=True, default='pending')
# Stock picking details
picking_id = fields.Many2one("stock.picking", "Stock Transfer", copy=False)
move_ids = fields.One2many(related="picking_id.move_ids")
picking_type_code = fields.Selection(related='picking_id.picking_type_id.code')
# Document details
document_date = fields.Datetime("Document Date", related="picking_id.date_done")
document_number = fields.Char("Document", related="picking_id.name")
company_id = fields.Many2one("res.company", related="picking_id.company_id")
company_currency_id = fields.Many2one(related="company_id.currency_id")
supply_type = fields.Selection(string="Supply Type", selection=[
("O", "Outward"),
("I", "Inward")
], compute="_compute_supply_type")
partner_bill_from_id = fields.Many2one(
"res.partner",
string='Bill From',
compute="_compute_document_partners_details",
check_company=True,
store=True,
readonly=False
)
partner_bill_to_id = fields.Many2one(
"res.partner",
string='Bill To',
compute="_compute_document_partners_details",
check_company=True,
store=True,
readonly=False
)
partner_ship_from_id = fields.Many2one(
"res.partner",
string='Dispatch From',
compute="_compute_document_partners_details",
check_company=True,
store=True,
readonly=False
)
partner_ship_to_id = fields.Many2one(
'res.partner',
string='Ship To',
compute='_compute_document_partners_details',
check_company=True,
store=True,
readonly=False
)
# Fields to determine which partner details are editable
is_bill_to_editable = fields.Boolean(compute="_compute_is_editable")
is_bill_from_editable = fields.Boolean(compute="_compute_is_editable")
is_ship_to_editable = fields.Boolean(compute="_compute_is_editable")
is_ship_from_editable = fields.Boolean(compute="_compute_is_editable")
fiscal_position_id = fields.Many2one(
comodel_name="account.fiscal.position",
string="Fiscal Position",
compute="_compute_fiscal_position",
check_company=True,
store=True,
readonly=False
)
# E-waybill Document Type
type_id = fields.Many2one("l10n.in.ewaybill.type", "Document Type", tracking=True, required=True)
sub_type_code = fields.Char(related="type_id.sub_type_code")
type_description = fields.Char(string="Description")
# Transportation details
distance = fields.Integer("Distance", tracking=True)
mode = fields.Selection([
("1", "By Road"),
("2", "Rail"),
("3", "Air"),
("4", "Ship or Ship Cum Road/Rail")
], string="Transportation Mode", copy=False, tracking=True, default="1")
# Vehicle Number and Type required when transportation mode is By Road.
vehicle_no = fields.Char("Vehicle Number", copy=False, tracking=True)
vehicle_type = fields.Selection([
("R", "Regular"),
("O", "Over Dimensional Cargo")],
string="Vehicle Type",
compute="_compute_vehicle_type",
store=True,
copy=False,
tracking=True,
readonly=False
)
# Document number and date required in case of transportation mode is Rail, Air or Ship.
transportation_doc_no = fields.Char(
string="Transporter Doc No",
copy=False, tracking=True)
transportation_doc_date = fields.Date(
string="Transporter Doc Date",
copy=False,
tracking=True)
transporter_id = fields.Many2one("res.partner", "Transporter", copy=False, tracking=True)
error_message = fields.Html(readonly=True)
blocking_level = fields.Selection([
("warning", "Warning"),
("error", "Error")],
string="Blocking Level", readonly=True)
content = fields.Binary(compute='_compute_content', compute_sudo=True)
cancel_reason = fields.Selection(selection=[
("1", "Duplicate"),
("2", "Data Entry Mistake"),
("3", "Order Cancelled"),
("4", "Others"),
], string="Cancel reason", copy=False, tracking=True)
cancel_remarks = fields.Char("Cancel remarks", copy=False, tracking=True)
def _compute_supply_type(self):
for ewaybill in self:
ewaybill.supply_type = ewaybill.picking_type_code == 'incoming' and 'I' or 'O'
@api.depends('picking_id')
def _compute_document_partners_details(self):
for ewaybill in self.filtered(lambda ewb: ewb.state == 'pending'):
picking_id = ewaybill.picking_id
if ewaybill.picking_type_code == 'incoming':
ewaybill.partner_bill_to_id = picking_id.company_id.partner_id
ewaybill.partner_bill_from_id = picking_id.partner_id
ewaybill.partner_ship_to_id = picking_id.picking_type_id.warehouse_id.partner_id
ewaybill.partner_ship_from_id = picking_id.partner_id
else:
ewaybill.partner_bill_to_id = picking_id.partner_id
ewaybill.partner_bill_from_id = picking_id.company_id.partner_id
ewaybill.partner_ship_to_id = picking_id.partner_id
ewaybill.partner_ship_from_id = picking_id.picking_type_id.warehouse_id.partner_id
if partner_invoice_id := ewaybill.picking_id._l10n_in_get_invoice_partner():
ewaybill.partner_bill_to_id = partner_invoice_id
if (
ewaybill.picking_type_code == 'dropship' and
(dest_partner := ewaybill.picking_id._get_l10n_in_dropship_dest_partner())
):
ewaybill.partner_ship_to_id = dest_partner
ewaybill.partner_ship_from_id = ewaybill.picking_id.partner_id
@api.depends('partner_bill_from_id', 'partner_bill_to_id')
def _compute_fiscal_position(self):
for ewaybill in self.filtered(lambda ewb: ewb.state == 'pending'):
ewaybill.fiscal_position_id = (
self.env['account.fiscal.position']._get_fiscal_position(
ewaybill.picking_type_code == 'incoming'
and ewaybill.partner_bill_from_id
or ewaybill.partner_bill_to_id
)
or ewaybill.picking_id._l10n_in_get_fiscal_position()
)
@api.depends('partner_ship_from_id', 'partner_ship_to_id', 'partner_bill_from_id', 'partner_bill_to_id')
def _compute_is_editable(self):
for ewaybill in self:
is_incoming = ewaybill.picking_type_code == "incoming"
ewaybill.is_bill_to_editable = not is_incoming
ewaybill.is_bill_from_editable = is_incoming
ewaybill.is_ship_from_editable = is_incoming and ewaybill._is_overseas()
ewaybill.is_ship_to_editable = not is_incoming and not ewaybill._is_overseas()
def _compute_content(self):
for ewaybill in self:
ewaybill.content = base64.b64encode(json.dumps(ewaybill._ewaybill_generate_direct_json()).encode())
@api.depends('name', 'state')
def _compute_display_name(self):
for ewaybill in self:
ewaybill.display_name = (
(ewaybill.state == 'pending' and _('Pending'))
or (ewaybill.state == 'challan' and _('Challan'))
or ewaybill.name
)
@api.depends('mode')
def _compute_vehicle_type(self):
"""when transportation mode is ship then vehicle type should be Over Dimensional Cargo (ODC)"""
for ewaybill in self.filtered(lambda ewb: ewb.state == 'pending' and ewb.mode == "4"):
ewaybill.vehicle_type = 'O'
def action_export_json(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/web/content/l10n.in.ewaybill/%s/content' % self.id
}
def generate_ewaybill(self):
for ewaybill in self:
if errors := ewaybill._check_configuration():
raise UserError('\n'.join(errors))
ewaybill._generate_ewaybill_direct()
def cancel_ewaybill(self):
self.ensure_one()
return {
'name': _('Cancel Ewaybill'),
'res_model': 'l10n.in.ewaybill.cancel',
'view_mode': 'form',
'context': {
'default_l10n_in_ewaybill_id': self.id,
},
'target': 'new',
'type': 'ir.actions.act_window',
}
def reset_to_pending(self):
self.ensure_one()
if self.state not in ('cancel', 'challan'):
raise UserError(_("Only Delivery Challan and Cancelled E-waybill can be reset to pending."))
self.write({
'name': False,
'state': 'pending',
'cancel_reason': False,
'cancel_remarks': False,
})
def action_set_to_challan(self):
self.ensure_one()
if self.state != 'pending':
raise UserError(_("The challan can only be generated in the Pending state."))
self.write({
'state': 'challan',
})
def _is_overseas(self):
self.ensure_one()
return self._get_gst_treatment()[1] in ('overseas', 'special_economic_zone')
def _check_configuration(self):
error_message = []
methods_to_check = [
self._check_partners,
self._check_document_number,
self._check_lines,
self._check_gst_treatment,
self._check_transporter,
]
for get_error_message in methods_to_check:
error_message.extend(get_error_message())
return error_message
def _check_transporter(self):
error_message = []
if self.transporter_id and not self.transporter_id.vat:
error_message.append(_("- Transporter %s does not have a GST Number", self.transporter_id.name))
if self.mode == "4" and self.vehicle_no and self.vehicle_type == "R":
error_message.append(_("- Vehicle type can not be regular when the transportation mode is ship"))
return error_message
def _check_partners(self):
error_message = []
partners = {
self.partner_bill_to_id, self.partner_bill_from_id, self.partner_ship_to_id, self.partner_ship_from_id
}
for partner in partners:
error_message += self._l10n_in_validate_partner(partner)
return error_message
@api.model
def _l10n_in_validate_partner(self, partner):
"""
Validation method for Stock Ewaybill (different from the one in EDI Ewaybill)
"""
message = []
if partner.country_id.code == "IN":
if partner.state_id and not partner.state_id.l10n_in_tin:
message.append(_("- TIN number not set in state %s", partner.state_id.name))
if not partner.state_id:
message.append(_("- State is required"))
if not partner.zip or not re.match("^[0-9]{6}$", partner.zip):
message.append(_("- Zip code required and should be 6 digits"))
elif not partner.country_id:
message.append(_("- Country is required"))
if message:
message.insert(0, "%s" % partner.display_name)
return message
def _check_document_number(self):
if not re.match("^.{1,16}$", self.document_number):
return [_("Document number should be set and not more than 16 characters")]
return []
def _check_lines(self):
error_message = []
AccountEDI = self.env['account.edi.format']
for line in self.move_ids:
if not (hsn_code := AccountEDI._l10n_in_edi_extract_digits(line.product_id.l10n_in_hsn_code)):
error_message.append(_("HSN code is not set in product %s", line.product_id.name))
elif not re.match("^[0-9]+$", hsn_code):
error_message.append(_(
"Invalid HSN Code (%(hsn_code)s) in product %(product)s",
hsn_code=hsn_code,
product=line.product_id.name,
))
return error_message
def _check_gst_treatment(self):
partner, gst_treatment = self._get_gst_treatment()
if not gst_treatment:
return [_("Set GST Treatment for in %s", partner.display_name)]
return []
def _get_gst_treatment(self):
if self.picking_type_code == 'incoming':
partner = self.partner_bill_from_id
else:
partner = self.partner_bill_to_id
return partner, partner.l10n_in_gst_treatment
def _write_error(self, error_message, blocking_level='error'):
self.write({
'error_message': error_message,
'blocking_level': blocking_level,
})
def _write_successfully_response(self, response_vals):
response_vals.update({
'error_message': False,
'blocking_level': False,
})
self.write(response_vals)
def _lock_ewaybill(self):
try:
# Lock e-Waybill
with self.env.cr.savepoint(flush=False):
self._cr.execute('SELECT * FROM l10n_in_ewaybill WHERE id IN %s FOR UPDATE NOWAIT', [tuple(self.ids)])
except psycopg2.errors.LockNotAvailable:
raise UserError(_('This document is being sent by another process already.')) from None
def _handle_internal_warning_if_present(self, response):
if warnings := response.get('odoo_warning'):
for warning in warnings:
if warning.get('message_post'):
odoobot = self.env.ref("base.partner_root")
self.message_post(
author_id=odoobot.id,
body=warning.get('message')
)
else:
self._write_error(warning.get('message'))
def _handle_error(self, ewaybill_error):
self._handle_internal_warning_if_present(ewaybill_error.error_json)
error_message = ewaybill_error.get_all_error_message()
blocking_level = "error"
if "404" in ewaybill_error.error_codes:
blocking_level = "warning"
self._write_error(error_message, blocking_level)
def _ewaybill_cancel(self):
cancel_json = {
"ewbNo": int(self.name),
"cancelRsnCode": int(self.cancel_reason),
"CnlRem": self.cancel_remarks,
}
ewb_api = EWayBillApi(self.company_id)
self._lock_ewaybill()
try:
ewb_api._ewaybill_cancel(cancel_json)
except EWayBillError as error:
self._handle_error(error)
return False
self._write_successfully_response({'state': 'cancel'})
self._cr.commit()
def _l10n_in_ewaybill_stock_handle_zero_distance_alert_if_present(self, response):
if self.distance == 0 and (alert := response.get('data').get('alert')):
pattern = r", Distance between these two pincodes is \d+, "
if re.fullmatch(pattern, alert) and (dist := int(re.search(r'\d+', alert).group())) > 0:
self.distance = dist
def _generate_ewaybill_direct(self):
ewb_api = EWayBillApi(self.company_id)
generate_json = self._ewaybill_generate_direct_json()
self._lock_ewaybill()
try:
response = ewb_api._ewaybill_generate(generate_json)
except EWayBillError as error:
self._handle_error(error)
return False
self._handle_internal_warning_if_present(response) # In case of error 604
response_data = response.get("data")
response_values = {
'name': response_data.get("ewayBillNo"),
'state': 'generated',
'ewaybill_date': self._indian_timezone_to_odoo_utc(
response_data['ewayBillDate']
),
'ewaybill_expiry_date': self._indian_timezone_to_odoo_utc(
response_data.get('validUpto')
),
}
self._l10n_in_ewaybill_stock_handle_zero_distance_alert_if_present(response)
self._write_successfully_response(response_values)
self._cr.commit()
@api.model
def _indian_timezone_to_odoo_utc(self, str_date, time_format='%d/%m/%Y %I:%M:%S %p'):
"""
This method is used to convert date from Indian timezone to UTC
"""
if not str_date:
return False
try:
local_time = datetime.strptime(str_date, time_format)
except ValueError:
try:
# Misc. due to a bug in eWaybill sometimes there are chances of getting below format in response
local_time = datetime.strptime(str_date, "%d/%m/%Y %H:%M:%S ")
except ValueError:
# Worst senario no date format matched
_logger.warning("Something went wrong while L10nInEwaybill date conversion")
return fields.Datetime.to_string(fields.Datetime.now())
utc_time = local_time.astimezone(pytz.utc)
return fields.Datetime.to_string(utc_time)
@api.model
def _get_partner_state_code(self, partner):
return int(partner.state_id.l10n_in_tin) if partner.country_id.code == "IN" else 99
def _l10n_in_tax_details(self):
tax_details = {
'line_tax_details': defaultdict(dict),
'tax_details': defaultdict(float)
}
for move in self.move_ids:
line_tax_vals = self._l10n_in_tax_details_by_line(move)
tax_details['line_tax_details'][move.id] = line_tax_vals
for val_field in ['total_excluded', 'total_included', 'total_void']:
tax_details['tax_details'][val_field] += line_tax_vals[val_field]
for tax in ['igst', 'cgst', 'sgst', 'cess_non_advol', 'cess', 'other']:
for taxes in line_tax_vals['taxes']:
for field_key in ["rate", "amount"]:
if (key := f"{tax}_{field_key}") in taxes:
tax_details['tax_details'][key] += taxes[key]
return tax_details
def _l10n_in_tax_details_by_line(self, move):
taxes = move.ewaybill_tax_ids.compute_all(price_unit=move.ewaybill_price_unit, quantity=move.quantity)
for tax in taxes['taxes']:
tax_id = self.env['account.tax'].browse(tax['id'])
tax_name = "other"
for gst_tax_name in ['igst', 'sgst', 'cgst']:
if self.env.ref("l10n_in.tax_tag_%s" % (gst_tax_name)).id in tax['tag_ids']:
tax_name = gst_tax_name
if self.env.ref("l10n_in.tax_tag_cess").id in tax['tag_ids']:
tax_name = tax_id.amount_type != "percent" and "cess_non_advol" or "cess"
rate_key = "%s_rate" % tax_name
amount_key = "%s_amount" % tax_name
tax.setdefault(rate_key, 0)
tax.setdefault(amount_key, 0)
tax[rate_key] += tax_id.amount
tax[amount_key] += tax['amount']
return taxes
def _get_l10n_in_ewaybill_line_details(self, line, tax_details):
AccountEDI = self.env['account.edi.format']
product = line.product_id
line_details = {
"productName": product.name,
"hsnCode": AccountEDI._l10n_in_edi_extract_digits(product.l10n_in_hsn_code),
"productDesc": product.name,
"quantity": line.quantity,
"qtyUnit": product.uom_id.l10n_in_code and product.uom_id.l10n_in_code.split("-")[
0] or "OTH",
"taxableAmount": AccountEDI._l10n_in_round_value(tax_details['total_excluded']),
}
for tax in tax_details.get('taxes'):
gst_types = ['sgst', 'cgst', 'igst']
gst_tax_rates = {}
for gst_type in gst_types:
if tax_rate := tax.get(f'{gst_type}_rate'):
gst_tax_rates.update({
f"{gst_type}Rate": AccountEDI._l10n_in_round_value(tax_rate)
})
line_details.update(
gst_tax_rates
or dict.fromkeys(
[f"{gst_type}Rate" for gst_type in gst_types],
0
)
)
if cess_rate := tax.get("cess_rate"):
line_details.update({"cessRate": AccountEDI._l10n_in_round_value(cess_rate)})
if cess_non_advol := tax.get("cess_non_advol_amount"):
line_details.update({
"cessNonadvol": AccountEDI._l10n_in_round_value(cess_non_advol)
})
return line_details
def _prepare_ewaybill_base_json_payload(self):
def get_transaction_type(seller_details, dispatch_details, buyer_details, ship_to_details):
"""
1 - Regular
2 - Bill To - Ship To
3 - Bill From - Dispatch From
4 - Combination of 2 and 3
"""
if seller_details != dispatch_details and buyer_details != ship_to_details:
return 4
elif seller_details != dispatch_details:
return 3
elif buyer_details != ship_to_details:
return 2
else:
return 1
def prepare_details(key_paired_function, partner_detail):
return {
f"{place}{key}": fun(partner)
for key, fun in key_paired_function
for place, partner in partner_detail
}
ewaybill_json = {
# document details
"supplyType": self.supply_type,
"subSupplyType": self.type_id.sub_type_code,
"docType": self.type_id.code,
"transactionType": get_transaction_type(
self.partner_bill_from_id,
self.partner_ship_from_id,
self.partner_bill_to_id,
self.partner_ship_to_id
),
"transDistance": str(self.distance),
"docNo": self.document_number,
"docDate": self.document_date.strftime("%d/%m/%Y"),
# bill details
**prepare_details(
key_paired_function={
'Gstin': lambda p: p.commercial_partner_id.vat or "URP",
'TrdName': lambda p: p.commercial_partner_id.name,
'StateCode': self._get_partner_state_code,
}.items(),
partner_detail={'from': self.partner_bill_from_id, 'to': self.partner_bill_to_id}.items()
),
# shipping details
**prepare_details(
key_paired_function={
"Addr1": lambda p: p.street and p.street[:120] or "",
"Addr2": lambda p: p.street2 and p.street2[:120] or "",
"Place": lambda p: p.city and p.city[:50] or "",
"Pincode": lambda p: int(p.zip) if p.country_id.code == "IN" else 999999,
}.items(),
partner_detail={'from': self.partner_ship_from_id, 'to': self.partner_ship_to_id}.items()
),
"actToStateCode": self._get_partner_state_code(self.partner_ship_to_id),
"actFromStateCode": self._get_partner_state_code(self.partner_ship_from_id),
}
if self.type_id.sub_type_code == '8':
ewaybill_json["subSupplyDesc"] = self.type_description
return ewaybill_json
def _prepare_ewaybill_transportation_json_payload(self):
# only pass transporter details when value is exist
return dict(
filter(lambda kv: kv[1], {
"transporterId": self.transporter_id.vat,
"transporterName": self.transporter_id.name,
"transMode": self.mode,
"transDocNo": self.transportation_doc_no,
"transDocDate": self.transportation_doc_date and self.transportation_doc_date.strftime("%d/%m/%Y"),
"vehicleNo": self.vehicle_no,
"vehicleType": self.vehicle_type,
}.items())
)
def _prepare_ewaybill_tax_details_json_payload(self):
tax_details = self._l10n_in_tax_details()
round_value = self.env['account.edi.format']._l10n_in_round_value
return {
"itemList": [
self._get_l10n_in_ewaybill_line_details(line, tax_details['line_tax_details'][line.id])
for line in self.move_ids
],
"totalValue": round_value(tax_details['tax_details'].get('total_excluded', 0.00)),
**{
f'{tax_type}Value': round_value(tax_details.get('tax_details').get(f'{tax_type}_amount', 0.00))
for tax_type in ['cgst', 'sgst', 'igst', 'cess']
},
"cessNonAdvolValue": round_value(tax_details.get('cess_non_advol_amount', 0.00)),
"otherValue": round_value(tax_details.get('other_amount', 0.00)),
"totInvValue": round_value(tax_details['tax_details'].get('total_included', 0.00)),
}
def _ewaybill_generate_direct_json(self):
return {
**self._prepare_ewaybill_base_json_payload(),
**self._prepare_ewaybill_transportation_json_payload(),
**self._prepare_ewaybill_tax_details_json_payload(),
}
@api.ondelete(at_uninstall=False)
def _unlink_l10n_in_ewaybill_prevent(self):
if self.filtered(lambda ewaybill: ewaybill.state != 'pending'):
raise UserError(_("You cannot delete a generated E-waybill. Instead, you should cancel it."))