import base64 import uuid from lxml import etree from odoo import Command, _, api, fields, models from odoo.exceptions import UserError from odoo.tools import cleanup_xml_node from odoo.tools.xml_utils import find_xml_value from odoo.addons.account_edi_ubl_cii.models.account_edi_xml_ubl_20 import UBL_NAMESPACES class StockPicking(models.Model): _inherit = 'stock.picking' l10n_tr_nilvera_dispatch_type = fields.Selection( string="Dispatch Type", help="Used to populate the type of dispatch.", selection=[ ('SEVK', "Online"), ('MATBUDAN', "Pre-printed"), ], default='SEVK', tracking=True, copy=False, ) l10n_tr_nilvera_carrier_id = fields.Many2one( string="Carrier (TR)", help="Used when the dispatch is made through a third-party carrier company. Populating this makes the Vehicle Plate and Drivers optional.", comodel_name='res.partner', copy=False, ) l10n_tr_nilvera_buyer_id = fields.Many2one( string="Buyer", help="Used for the original party who purchases the good when the Delivery Address is for another recipient", comodel_name='res.partner', copy=False, ) l10n_tr_nilvera_seller_supplier_id = fields.Many2one( string="Seller Supplier", help="Used for the information of the supplier of the goods in the delivery note.", comodel_name='res.partner', copy=False, ) l10n_tr_nilvera_buyer_originator_id = fields.Many2one( string="Buyer Originator", help="Used for the original initiator of the goods acquisition and requesting process.", comodel_name='res.partner', copy=False, ) l10n_tr_nilvera_delivery_printed_number = fields.Char(string="Printed Delivery Note Number", copy=False) l10n_tr_nilvera_delivery_date = fields.Date(string="Printed Delivery Note Date", copy=False) l10n_tr_vehicle_plate = fields.Many2one( string="Vehicle Plate", help="Used to input the plate number of the truck.", comodel_name='l10n_tr.nilvera.trailer.plate', domain="[('plate_number_type', '=', 'vehicle')]", copy=False, ) l10n_tr_nilvera_trailer_plate_ids = fields.Many2many( string="Trailer Plates", help="Used to input the plate numbers of the trailers attached to the truck.", comodel_name='l10n_tr.nilvera.trailer.plate', domain="[('plate_number_type', '=', 'trailer')]", relation='l10n_tr_nilvera_delivery_vehicle_rel', copy=False, ) l10n_tr_nilvera_driver_ids = fields.Many2many( string="Drivers", help="Used for the individuals driving the truck.", comodel_name='res.partner', copy=False, ) l10n_tr_nilvera_delivery_notes = fields.Char(string="Delivery Notes", copy=False) l10n_tr_nilvera_dispatch_state = fields.Selection( string="e-Dispatch State", selection=[('to_send', "To Send"), ('sent', "Sent")], tracking=True, copy=False, ) l10n_tr_nilvera_edispatch_warnings = fields.Json(compute='_compute_edispatch_warnings') @api.depends( 'l10n_tr_nilvera_carrier_id', 'l10n_tr_nilvera_buyer_id', 'l10n_tr_nilvera_seller_supplier_id', 'l10n_tr_nilvera_buyer_originator_id', 'l10n_tr_nilvera_delivery_printed_number', 'l10n_tr_nilvera_delivery_date', 'l10n_tr_vehicle_plate', 'l10n_tr_nilvera_trailer_plate_ids', 'l10n_tr_nilvera_driver_ids', 'partner_id', ) def _compute_edispatch_warnings(self): for picking in self: if ( picking.country_code == "TR" and picking.picking_type_code == "outgoing" and picking.state in {"assigned", "done"} ): picking.l10n_tr_nilvera_edispatch_warnings = picking._l10n_tr_validate_edispatch_fields() else: picking.l10n_tr_nilvera_edispatch_warnings = False def button_validate(self): res = super().button_validate() for picking in self: if picking.country_code != 'TR' or picking.picking_type_code != 'outgoing' or picking.state != 'done': continue if picking.partner_id: picking.l10n_tr_nilvera_dispatch_state = 'to_send' else: picking.message_post( body=_("e-Dispatch will not be generated as the Delivery Address is not set.") ) return res def _l10n_tr_validate_edispatch_on_done(self): partners = ( self.company_id.partner_id | self.partner_id | self.partner_id.commercial_partner_id | self.l10n_tr_nilvera_carrier_id | self.l10n_tr_nilvera_buyer_id | self.l10n_tr_nilvera_seller_supplier_id | self.l10n_tr_nilvera_buyer_originator_id ) error_messages = partners._l10n_tr_nilvera_validate_partner_details() if self.l10n_tr_nilvera_dispatch_type == 'MATBUDAN': if not self.l10n_tr_nilvera_delivery_date: error_messages['invalid_matbudan_date'] = { 'message': _("Printed Delivery Note Date is required."), } if ( not self.l10n_tr_nilvera_delivery_printed_number or len(self.l10n_tr_nilvera_delivery_printed_number) != 16 ): error_messages['invalid_matbudan_number'] = { 'message': _("Printed Delivery Note Number of 16 characters is required."), } invalid_country_drivers = self.l10n_tr_nilvera_driver_ids.filtered( lambda driver: not driver.country_id or driver.country_id.code != 'TR' ) invalid_tckn_drivers = (self.l10n_tr_nilvera_driver_ids - invalid_country_drivers).filtered( lambda driver: not driver.vat or (driver.vat and len(driver.vat) != 11) ) if drivers := len(invalid_country_drivers): error_messages['invalid_driver_country'] = { 'message': _( "Only Drivers from Türkiye are valid. Please update the Country and enter a valid TCKN in the Tax ID." ), 'action_text': _( "View %s", (drivers == 1 and invalid_country_drivers.name) or _("Drivers"), ), 'action': invalid_country_drivers._get_records_action( name=_("Drivers"), ), } if drivers := len(invalid_tckn_drivers): driver_placeholder = drivers > 1 and _("Drivers") or _("%s's", invalid_tckn_drivers.name) error_messages['invalid_driver_tckn'] = { 'message': _("%s TCKN is required.", driver_placeholder), 'action_text': _("View %s", drivers == 1 and invalid_tckn_drivers.name or _("Drivers")), 'action': invalid_tckn_drivers._get_records_action(name=_("Drivers")), } if ( not self.l10n_tr_nilvera_carrier_id and not self.l10n_tr_nilvera_driver_ids and not self.l10n_tr_vehicle_plate ): error_messages['required_carrier_details'] = { 'message': _("Carrier is required (optional when both the Driver and Vehicle Plate are filled)."), } elif not self.l10n_tr_nilvera_carrier_id and not self.l10n_tr_nilvera_driver_ids: error_messages['required_driver_details'] = { 'message': _("At least one Driver is required."), } elif not self.l10n_tr_nilvera_carrier_id and not self.l10n_tr_vehicle_plate: error_messages['required_vehicle_details'] = { 'message': _("Vehicle Plate is required."), } return error_messages or False def _l10n_tr_validate_edispatch_fields(self): self.ensure_one() if self.state not in {'assigned', 'done'}: return { 'invalid_transfer_state': { 'message': _("Please validate the transfer first to generate the XML"), } } if not self.partner_id: return { 'missing_delivery_partner_id': { 'message': _("e-Dispatch will not be generated as the Delivery Address is not set."), } } if self.state == 'done': return self._l10n_tr_validate_edispatch_on_done() def _l10n_tr_generate_edispatch_xml(self): dispatch_uuid = str(uuid.uuid4()) drivers = [] for driver in self.l10n_tr_nilvera_driver_ids: driver_name = driver.name.split(' ', 1) drivers.append({ 'name': driver_name[0], 'fname': driver_name[1] if len(driver_name) > 1 else '\u200B', 'tckn': driver.vat, }) scheduled_date_local = fields.Datetime.context_timestamp( self.with_context(tz='Europe/Istanbul'), self.scheduled_date, ) date_done_local = fields.Datetime.context_timestamp( self.with_context(tz='Europe/Istanbul'), self.date_done, ) values = { 'ubl_version_id': 2.1, 'customization_id': 'TR1.2.1', 'uuid': dispatch_uuid, 'picking': self, 'current_company': self.env.company.partner_id, 'issue_date': scheduled_date_local.date().strftime('%Y-%m-%d'), 'issue_time': scheduled_date_local.time().strftime('%H:%M:%S'), 'actual_date': date_done_local.strftime('%Y-%m-%d'), 'actual_time': date_done_local.strftime('%H:%M:%S'), 'line_count': len(self.move_ids_without_package), 'printed_date': self.l10n_tr_nilvera_delivery_date and self.l10n_tr_nilvera_delivery_date.strftime('%Y-%m-%d'), 'drivers': drivers, 'default_tckn': '22222222222', 'dispatch_scenario': 'TEMELIRSALIYE', 'copy_indicator': 'false', } xml_content = self.env['ir.qweb']._render( 'l10n_tr_nilvera_edispatch.l10n_tr_edispatch_format', values ) xml_string = etree.tostring( cleanup_xml_node(xml_content), pretty_print=False, encoding='UTF-8', ) attachment = self.env['ir.attachment'].create({ 'name': f"{self.name}_e_Dispatch.xml", 'datas': base64.b64encode(xml_string), 'res_model': self._name, 'res_id': self.id, 'type': 'binary', }) self.message_post( body=_("e-Dispatch XML file generated successfully."), attachment_ids=[attachment.id], subtype_xmlid='mail.mt_note', ) def action_generate_l10n_tr_edispatch_xml(self, is_list=False): errors = [] for picking in self: if picking.country_code == 'TR' and picking.picking_type_code == 'outgoing': if picking._l10n_tr_validate_edispatch_fields(): errors.append(picking.name) else: picking._l10n_tr_generate_edispatch_xml() if is_list and errors: raise UserError(_("Error occurred in generating XML for following records:\n- %s", '\n- '.join(errors))) def action_mark_l10n_tr_edispatch_status(self): self.filtered( lambda p: p.country_code == 'TR' and p.picking_type_code == 'outgoing' ).l10n_tr_nilvera_dispatch_state = 'sent' def _get_tag_text(self, xpath, tree, default=''): return find_xml_value(xpath, tree, UBL_NAMESPACES) or default def _get_partner_vals_from_xml(self, tree, xpath): party = tree.find(xpath, namespaces=UBL_NAMESPACES) if party is None: return return { 'name': self._get_tag_text('./cac:PartyName/cbc:Name', party) or f"{self._get_tag_text('./cac:Person/cbc:FirstName', party)} {self._get_tag_text('./cac:Person/cbc:FamilyName', party)}", 'vat': self._get_tag_text('./cac:PartyIdentification/cbc:ID[@schemeID="VKN" or @schemeID="TCKN"]', party), 'street': self._get_tag_text('./cac:PostalAddress/cbc:StreetName', party), 'city': self._get_tag_text('./cac:PostalAddress/cbc:CitySubdivisionName', party), 'zip': self._get_tag_text('./cac:PostalAddress/cbc:PostalZone', party), 'state': self._get_tag_text('./cac:PostalAddress/cbc:CityName', party), 'country': self._get_tag_text('./cac:PostalAddress/cac:Country/cbc:Name', party), 'phone': self._get_tag_text('./cac:Contact/cbc:Telephone', party), 'email': self._get_tag_text('./cac:Contact/cbc:ElectronicMail', party), } def _create_partner_from_xml(self, partner_vals): if (state := partner_vals.pop('state', None)) and ( state_id := self.env['res.country.state'].search([('name', '=', state)], limit=1) ): partner_vals.pop('country') partner_vals.update({ 'state_id': state_id.id, 'country_id': state_id.country_id.id, 'code': state_id.country_id.code }) elif (country := partner_vals.pop('country', None)) and ( country_id := self.env['res.country'].with_context(lang='tr_TR').search([('name', '=', country)], limit=1) ): partner_vals.update({'country_id': country_id.id, 'code': country_id.code}) if (code := partner_vals.pop('code', None)) and code != 'TR': partner_vals['l10n_tr_nilvera_edispatch_customs_zip'] = partner_vals.pop('zip', '') partner = self.env['res.partner'].with_context(no_vat_validation=True).create(partner_vals) return partner.id def _find_or_create_products_from_xml(self, receipt_lines): product_names = [ self._get_tag_text('./cac:Item/cbc:Name', receipt) for receipt in receipt_lines ] existing_products = dict(self.env['product.product']._read_group( [('name', 'in', product_names)], ['name'], ['id:min'], )) products_to_create = [] for receipt in receipt_lines: name = self._get_tag_text('./cac:Item/cbc:Name', receipt) if name not in existing_products: unece_code = receipt.find('./cbc:DeliveredQuantity', namespaces=UBL_NAMESPACES).get('unitCode', '') products_to_create.append({ 'name': name, 'default_code': self._get_tag_text('./cac:Item/cac:SellersItemIdentification/cbc:ID', receipt), 'uom_id': self.env['uom.uom']._get_uom_from_unece_code(unece_code).id, }) if products_to_create: created_products = self.env['product.product'].create(products_to_create) existing_products.update({product.name: product.id for product in created_products}) return existing_products def _import_receipt_lines(self, tree): receipt_lines = tree.findall('./cac:DespatchLine', namespaces=UBL_NAMESPACES) if not receipt_lines: return [] products_dict = self._find_or_create_products_from_xml(receipt_lines) origin = self._get_tag_text('./cbc:ID', tree) source_location = self.picking_type_id.default_location_src_id values = [] for receipt in receipt_lines: name = self._get_tag_text('./cac:Item/cbc:Name', receipt) values.append({ 'name': f"E-Receipt Import - {origin}", 'description_picking': name, 'product_id': products_dict[name], 'product_uom_qty': self._get_tag_text('./cbc:DeliveredQuantity', receipt), 'picking_id': self.id, 'location_dest_id': self.location_dest_id.id, 'location_id': source_location.id, }) return values def _import_vehicle_plate(self, tree): vehicle_plate = self._get_tag_text('.//cac:RoadTransport/cbc:LicensePlateID', tree) if not vehicle_plate: return vehicle_plate_id = self.env['l10n_tr.nilvera.trailer.plate'].search_fetch( [('name', '=', vehicle_plate), ('plate_number_type', '=', 'vehicle')], ['id'], limit=1, ) if not vehicle_plate_id: vehicle_plate_id = self.env['l10n_tr.nilvera.trailer.plate'].create({ 'name': vehicle_plate, 'plate_number_type': 'vehicle', }) return vehicle_plate_id.id def _import_trailer_plate_ids(self, tree): plate_ids = [] trailer_plates = tree.findall('.//cac:TransportHandlingUnit/cac:TransportEquipment', namespaces=UBL_NAMESPACES) existing_plates = dict(self.env['l10n_tr.nilvera.trailer.plate']._read_group( [('plate_number_type', '=', 'trailer')], ['name'], ['id:min'], )) for plate in trailer_plates: if not (plate_name := self._get_tag_text('./cbc:ID', plate)): continue if plate_name in existing_plates: plate_ids.append(existing_plates[plate_name]) else: trailer_plate = self.env['l10n_tr.nilvera.trailer.plate'].create({ 'name': plate_name, 'plate_number_type': 'trailer', }) plate_ids.append(trailer_plate.id) return plate_ids def _import_drivers(self, tree): ResPartner = self.env['res.partner'] existing_partners = dict(ResPartner.with_context(active_test=False)._read_group( [('country_id.code', '=', 'TR'), ('is_company', '=', False)], ['name'], ['id:min'], )) country_id = self.env.ref('base.tr', raise_if_not_found=False) driver_ids = [] partners_to_create = [] for driver in tree.findall('.//cac:DriverPerson', namespaces=UBL_NAMESPACES): name = f"{self._get_tag_text('./cbc:FirstName', driver)} {self._get_tag_text('./cbc:FamilyName', driver)}" if name in existing_partners: driver_ids.append(existing_partners[name]) else: partners_to_create.append({ 'name': name, 'vat': self._get_tag_text('./cbc:NationalityID', driver), 'country_id': country_id.id }) if partners_to_create: partner_id = ResPartner.with_context(no_vat_validation=True).create(partners_to_create) driver_ids += partner_id.ids return driver_ids def _import_matbudan_data(self, tree): additional_doc_infos = tree.findall('.//cac:AdditionalDocumentReference', namespaces=UBL_NAMESPACES) for doc in additional_doc_infos: if self._get_tag_text('./cbc:DocumentType', doc) == 'MATBU': return { 'l10n_tr_nilvera_delivery_date': self._get_tag_text('./cbc:IssueDate', doc), 'l10n_tr_nilvera_delivery_printed_number': self._get_tag_text('./cbc:ID', doc) } def _import_partners(self, tree): xpath_to_field = { './/cac:DespatchSupplierParty/cac:Party': 'partner_id', './/cac:CarrierParty': 'l10n_tr_nilvera_carrier_id', './/cac:BuyerCustomerParty/cac:Party': 'l10n_tr_nilvera_buyer_id', './/cac:SellerSupplierParty/cac:Party': 'l10n_tr_nilvera_seller_supplier_id', './/cac:OriginatorCustomerParty/cac:Party': 'l10n_tr_nilvera_buyer_originator_id', } partner_data = [ (xpath, self._get_partner_vals_from_xml(tree, xpath)) for xpath in xpath_to_field ] partner_data = {xpath: vals for xpath, vals in partner_data if vals} existing_partners = self.env['res.partner'].with_context(active_test=False).search_read( ['|', ('vat', 'in', [vals.get('vat') for vals in partner_data.values() if vals.get('vat')]), ('name', 'in', [vals.get('name') for vals in partner_data.values() if vals.get('name')])], ['id', 'vat', 'name'], ) existing_dict = {partner['vat'] or partner['name']: partner['id'] for partner in existing_partners} partners_vals = {} for xpath, vals in partner_data.items(): key = vals.get('vat') or vals.get('name') partners_vals[xpath_to_field[xpath]] = existing_dict.get(key) or self._create_partner_from_xml(vals) return partners_vals def _import_edispatch_fields(self, tree): vals = { 'l10n_tr_vehicle_plate': self._import_vehicle_plate(tree), 'l10n_tr_nilvera_trailer_plate_ids': self._import_trailer_plate_ids(tree), 'l10n_tr_nilvera_driver_ids': self._import_drivers(tree), 'l10n_tr_nilvera_delivery_notes': self._get_tag_text('./cbc:Note', tree), 'l10n_tr_nilvera_dispatch_type': self._get_tag_text('./cbc:DespatchAdviceTypeCode', tree), } if vals['l10n_tr_nilvera_dispatch_type'] == 'MATBUDAN' and (matbu_info := self._import_matbudan_data(tree)): vals.update(matbu_info) return vals def _update_data_from_xml(self, file_data): tree = file_data['xml_tree'] # Dispatch Scheduled Date & Time scheduled_datetime = self._get_tag_text('./cbc:IssueDate', tree) + " " + self._get_tag_text('./cbc:IssueTime', tree) vals_to_update = { 'scheduled_date': scheduled_datetime, 'origin': self._get_tag_text('./cbc:ID', tree), # sequence of the e-Receipt obtained from XML. 'move_ids_without_package': [Command.create(value) for value in self._import_receipt_lines(tree)], } # Import Partners (Supplier, Carrier, Buyer, Seller, Originator) vals_to_update.update(self._import_partners(tree)) # Import e-Dispatch Fields vals_to_update.update(self._import_edispatch_fields(tree)) self.write(vals_to_update) self.message_post(body=_("e-Receipt uploaded successfully."), attachment_ids=[file_data['attachment'].id]) def _l10n_tr_create_receipts_from_attachment(self, attachments): files_with_errors = [] picking_ids = self.env['stock.picking'] warehouse = self.env.user._get_default_warehouse_id() for attachment in attachments: file_data = attachment._decode_edi_xml(attachment.name, attachment.raw) # `_decode_edi_xml` returns empty array & logs the exception if error occurs while decoding the XML. if not file_data: files_with_errors.append(attachment.name) continue picking = self.create({ 'picking_type_id': warehouse.in_type_id.id, 'location_dest_id': warehouse.lot_stock_id.id, }) picking._update_data_from_xml(file_data[0]) picking_ids |= picking return picking_ids, files_with_errors def l10n_tr_import_ereceipts(self, attachment_ids): result = {} attachments_to_process = self.env['ir.attachment'].browse(attachment_ids) picking_ids, files_with_errors = self._l10n_tr_create_receipts_from_attachment(attachments_to_process) if picking_ids: action_vals = { 'type': 'ir.actions.act_window', 'name': _("Imported E-Receipts"), 'res_model': 'stock.picking', 'domain': [('id', 'in', picking_ids.ids)], } if len(picking_ids) == 1: action_vals.update({ 'views': [[False, "form"]], 'view_mode': 'form', 'res_id': picking_ids[0].id, }) else: action_vals.update({ 'views': [[False, "list"], [False, "form"]], 'view_mode': 'list, form', }) result['action'] = action_vals if files_with_errors: result['skipped_xmls'] = files_with_errors return result