Coverage for ingadhoc-argentina-sale / l10n_ar_stock_ux / models / stock_picking.py: 13%
154 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
1import datetime
2import logging
3import xml.etree.ElementTree as ET
5import requests
6from odoo import api, fields, models
7from odoo.exceptions import UserError
9_logger = logging.getLogger(__name__)
12class StockPicking(models.Model):
13 _inherit = "stock.picking"
15 dispatch_number = fields.Char(
16 help="Si define un número de despacho, al validar la transferencia, "
17 "el mismo será asociado a los lotes sin número de despacho vinculados "
18 "a la transferencia."
19 )
20 cot_numero_unico = fields.Char(
21 "COT - Nro Único",
22 help="Número único del último COT solicitado",
23 )
24 cot_numero_comprobante = fields.Char(
25 "COT - Nro Comprobante",
26 help="Número de comprobante del último COT solicitado",
27 )
28 cot = fields.Char(
29 "COT",
30 help="Número de COT del último COT solicitado",
31 )
32 # lo mantenemos por esta versión y re-evaluamos en próximas
33 l10n_ar_afip_barcode = fields.Char(
34 compute="_compute_l10n_ar_afip_barcode",
35 string="AFIP Barcode",
36 )
37 l10n_ar_delivery_guide_number = fields.Char(
38 # lo hacemos editable para facturas de proveedor y tmb para que este disponible en acciones masivas, importaciones y demás
39 readonly=False,
40 tracking=True,
41 help='Número o lista de números de remitos separadas por ",". Por ejempplo:\n'
42 "* 0001-00000001\n"
43 "* 0001-00000001,0001-00000002",
44 )
45 # agregamos estos dos campos calculados para facilitar mucho el código
46 # el de fecha principalmente porque no hay nada facil para en qweb manipular y sacarlo en formato argentino
47 document_type_id = fields.Many2one(
48 string="Document Type (AR)", compute="_compute_cai_data", comodel_name="l10n_latam.document.type"
49 )
50 l10n_ar_cai_expiration_date = fields.Date(
51 compute="_compute_cai_data",
52 string="CAI Expiration Date",
53 )
55 @api.onchange("l10n_ar_delivery_guide_number")
56 def _format_document_number(self):
57 if self.l10n_ar_delivery_guide_number:
58 if "," in self.l10n_ar_delivery_guide_number:
59 docs = self.l10n_ar_delivery_guide_number.split(",")
60 else:
61 docs = [self.l10n_ar_delivery_guide_number]
62 l10n_ar_delivery_guide_numbers = []
63 for doc in docs:
64 l10n_ar_delivery_guide_numbers.append(self.env.ref("l10n_ar.dc_r_r")._format_document_number(doc))
65 self.l10n_ar_delivery_guide_number = ",".join(l10n_ar_delivery_guide_numbers)
66 # # if
67 # self.l10n_ar_delivery_guide_number = self.env.ref('l10n_ar.dc_r_r')._format_document_number(
68 # self.l10n_ar_delivery_guide_number)
70 def _get_name_delivery_report(self, report_xml_id):
71 """Method similar to the '_get_name_invoice_report' of l10n_latam_invoice_document
72 Basically it allows different localizations to define it's own report
73 This method should actually go in a sale_ux module that later can be extended by different localizations
74 Another option would be to use report_substitute module and setup a subsitution with a domain
75 """
76 self.ensure_one()
77 if self.company_id.country_id.code == "AR" and self.l10n_ar_delivery_guide_number:
78 return "l10n_ar_stock_ux.report_delivery_document"
79 return report_xml_id
81 @api.depends("l10n_ar_cai_data")
82 def _compute_cai_data(self):
83 for rec in self:
84 if not rec.l10n_ar_cai_data:
85 rec.document_type_id = False
86 rec.l10n_ar_cai_expiration_date = False
87 else:
88 expiration_date = rec.l10n_ar_cai_data.get("cai_expiration_date")
89 rec.l10n_ar_cai_expiration_date = fields.Date.to_date(expiration_date) if expiration_date else False
90 rec.document_type_id = self.env["l10n_latam.document.type"].browse(
91 rec.l10n_ar_cai_data.get("document_type_id")
92 )
94 def _compute_l10n_ar_afip_barcode(self):
95 for rec in self:
96 barcode = False
97 if (
98 rec.l10n_ar_delivery_guide_number
99 and rec.document_type_id
100 and rec.l10n_ar_cai_data
101 and rec.l10n_ar_cai_expiration_date
102 ):
103 cae_due = rec.l10n_ar_cai_expiration_date.strftime("%Y%m%d")
104 pos_number = self.env["account.move"]._l10n_ar_get_document_number_parts(
105 rec.l10n_ar_delivery_guide_number, rec.document_type_id.code
106 )["point_of_sale"]
107 barcode = "".join(
108 [
109 str(rec.picking_type_id.report_partner_id.l10n_ar_vat or rec.company_id.partner_id.l10n_ar_vat),
110 "%03d" % int(rec.document_type_id.code),
111 "%05d" % pos_number,
112 rec.l10n_ar_cai_data.get("cai_authorization_code"),
113 cae_due,
114 ]
115 )
116 rec.l10n_ar_afip_barcode = barcode
118 def _get_arba_file_data(
119 self,
120 datetime_out,
121 tipo_recorrido,
122 carrier_partner,
123 patente_vehiculo,
124 patente_acoplado,
125 prod_no_term_dev,
126 importe,
127 ):
128 """
129 NOTA: esta implementado como para soportar seleccionar varios remitos
130 y mandarlos juntos pero por ahora no le estamos dando uso.
131 Tener en cuenta que si se numera con más de un remito nosotros mandamos
132 solo el primero ya que para cada remito reportado se debe indicar
133 los productos y ese dato no lo tenemos (solo sabemos cuantas hojas
134 consume)
135 """
137 FECHA_SALIDA_TRANSPORTE = datetime_out.strftime("%Y%m%d")
138 HORA_SALIDA_TRANSPORTE = datetime_out.strftime("%H%M")
140 company = self.mapped("company_id")
141 if len(company) > 1:
142 raise UserError(self.env._("Los remitos seleccionados deben pertenecer a la misma compañía"))
143 cuit = company.partner_id.ensure_vat()
144 cuit_carrier = carrier_partner.ensure_vat()
146 if cuit_carrier == cuit and not patente_vehiculo:
147 raise UserError(
148 self.env._(
149 "Si el CUIT de la compañía y el cuit del transportista son el "
150 "mismo, se debe informar la patente del vehículo."
151 )
152 )
154 # ej. nombre archivo TB_30111111118_003002_20060716_000183.txt
155 # TODO ver de donde obtener estos datos
156 nro_planta = "000"
157 nro_puerta = "000"
158 nro_secuencial = self.env["ir.sequence"].with_company(company).next_by_code("arba.cot.file")
159 if not nro_secuencial:
160 raise UserError(
161 self.env._(
162 'No sequence found for COT files (code = "arba.cot.file") on company "%s',
163 company.name,
164 )
165 )
167 filename = "TB_%s_%s%s_%s_%s.txt" % (
168 cuit,
169 nro_planta,
170 nro_puerta,
171 datetime.date.today().strftime("%Y%m%d"),
172 nro_secuencial,
173 )
175 # 01 - HEADER: TIPO_REGISTRO & CUIT_EMPRESA
176 HEADER = ["01", cuit]
178 # 04 - FOOTER (Pie): TIPO_REGISTRO: 04 & CANTIDAD_TOTAL_REMITOS
179 FOOTER = ["04", str(len(self))]
181 # TODO, si interesa se puede repetir esto para cada uno
182 REMITOS_PRODUCTOS = []
184 for rec in self:
185 if not rec.l10n_ar_delivery_guide_number:
186 raise UserError(self.env._("No se asignó número de remito"))
187 voucher = rec.l10n_ar_delivery_guide_number
188 dest_partner = rec.partner_id
189 source_partner = rec.picking_type_id.warehouse_id.partner_id or rec.company_id.partner_id
190 commercial_partner = dest_partner.commercial_partner_id
192 if not source_partner.state_id.code or not dest_partner.state_id.code:
193 raise UserError(
194 self.env._("Las provincias de origen y destino son obligatorias y deben tener un código válido")
195 )
197 if not rec.document_type_id:
198 raise UserError(self.env._('Picking has no "Document type" linked (Id: %s)', rec.id))
199 CODIGO_DGI = rec.document_type_id.code
200 CODIGO_DGI = CODIGO_DGI.rjust(3, "0")
201 letter = rec.document_type_id.l10n_ar_letter
202 if not CODIGO_DGI or not letter:
203 raise UserError(
204 self.env._(
205 "Document type has no validator, code or letter configured (Id: %s",
206 rec.document_type_id.id,
207 )
208 )
210 # TODO ver de hacer uno por número de remito?
211 document_parts = self.env["account.move"]._l10n_ar_get_document_number_parts(voucher, CODIGO_DGI)
212 PREFIJO = str(document_parts["point_of_sale"])
213 NUMERO = str(document_parts["invoice_number"])
214 PREFIJO = PREFIJO.rjust(5, "0")
215 NUMERO = NUMERO.rjust(8, "0")
217 # si nro doc y tipo en ‘DNI’, ‘LC’, ‘LE’, ‘PAS’, ‘CI’ y doc
218 doc_categ_id = commercial_partner.l10n_latam_identification_type_id
219 if commercial_partner.vat and doc_categ_id.name in [
220 "DNI",
221 "LC",
222 "LE",
223 "PAS",
224 "CI",
225 ]:
226 dest_tipo_doc = doc_categ_id.l10n_ar_afip_code
227 dest_doc = commercial_partner.vat
228 dest_cuit = ""
229 else:
230 dest_tipo_doc = ""
231 dest_doc = ""
232 dest_cuit = commercial_partner.ensure_vat()
234 dest_cons_final = commercial_partner.l10n_ar_afip_responsibility_type_id.code == "5" and "1" or "0"
236 REMITOS_PRODUCTOS.append(
237 [
238 "02", # TIPO_REGISTRO
239 # FECHA_EMISION
240 datetime.date.today().strftime("%Y%m%d"),
241 # CODIGO_UNICO formato (CODIGO_DGI, TIPO, PREFIJO, NUMERO)
242 # ej. 91 |R999900068148|
243 "%s%s%s" % (CODIGO_DGI, PREFIJO, NUMERO),
244 # FECHA_SALIDA_TRANSPORTE: formato AAAAMMDD
245 FECHA_SALIDA_TRANSPORTE,
246 # HORA_SALIDA_TRANSPORTE: formato HHMM
247 HORA_SALIDA_TRANSPORTE,
248 # SUJETO_GENERADOR: 'E' emisor, 'D' destinatario
249 "E",
250 # DESTINATARIO_CONSUMIDOR_FINAL: 0 no, 1 sí
251 dest_cons_final,
252 # DESTINATARIO_TIPO_DOCUMENTO: 'DNI', 'LE', 'PAS', 'CI'
253 dest_tipo_doc,
254 # DESTINATARIO_DOCUMENTO
255 dest_doc,
256 # DESTIANTARIO_CUIT
257 dest_cuit,
258 # DESTINATARIO_RAZON_SOCIAL
259 commercial_partner.name[:50],
260 # DESTINATARIO_TENEDOR: 0=no, 1=si.
261 dest_cons_final and "0" or "1",
262 # DESTINO_DOMICILIO_CALLE
263 (dest_partner.street or "")[:40],
264 # DESTINO_DOMICILIO_NUMERO
265 # TODO implementar
266 "",
267 # DESTINO_DOMICILIO_COMPLE
268 # TODO implementar valores ’ ’, ‘S/N’ , ‘1/2’, ‘1/4’, ‘BIS’
269 "S/N",
270 # DESTINO_DOMICILIO_PISO
271 # TODO implementar
272 "",
273 # DESTINO_DOMICILIO_DTO
274 # TODO implementar
275 "",
276 # DESTINO_DOMICILIO_BARRIO
277 # TODO implementar
278 "",
279 # DESTINO_DOMICILIO_CODIGOP
280 (dest_partner.zip or "")[:8],
281 # DESTINO_DOMICILIO_LOCALIDAD
282 (dest_partner.city or "")[:50],
283 # DESTINO_DOMICILIO_PROVINCIA: ver tabla de provincias
284 # usa código distinto al de afip
285 (dest_partner.state_id.code or "")[:1],
286 # PROPIO_DESTINO_DOMICILIO_CODIGO
287 # TODO implementar
288 "",
289 # ENTREGA_DOMICILIO_ORIGEN: 'SI' o 'NO'
290 # TODO implementar
291 "NO",
292 # ORIGEN_CUIT
293 cuit,
294 # ORIGEN_RAZON_SOCIAL
295 company.name[:50],
296 # EMISOR_TENEDOR: 0=no, 1=si
297 # TODO implementar
298 "0",
299 # ORIGEN_DOMICILIO_CALLE
300 (source_partner.street or "")[:40],
301 # ORIGEN DOMICILIO_NUMBERO
302 # TODO implementar
303 "",
304 # ORIGEN_DOMICILIO_COMPLE
305 # TODO implementar valores ’ ’, ‘S/N’ , ‘1/2’, ‘1/4’, ‘BIS’
306 "S/N",
307 # ORIGEN_DOMICILIO_PISO
308 # TODO implementar
309 "",
310 # ORIGEN_DOMICILIO_DTO
311 # TODO implementar
312 "",
313 # ORIGEN_DOMICILIO_BARRIO
314 # TODO implementar
315 "",
316 # ORIGEN_DOMICILIO_CODIGOP
317 (source_partner.zip or "")[:8],
318 # ORIGEN_DOMICILIO_LOCALIDAD
319 (source_partner.city or "")[:50],
320 # ORIGEN_DOMICILIO_PROVINCIA: ver tabla de provincias
321 (source_partner.state_id.code or "")[:1],
322 # TRANSPORTISTA_CUIT
323 cuit_carrier,
324 # TIPO_RECORRIDO: 'U' urbano, 'R' rural, 'M' mixto
325 tipo_recorrido,
326 # RECORRIDO_LOCALIDAD: máx. 50 caracteres,
327 # TODO implementar
328 "",
329 # RECORRIDO_CALLE: máx. 40 caracteres
330 # TODO implementar
331 "",
332 # RECORRIDO_RUTA: máx. 40 caracteres
333 # TODO implementar
334 "",
335 # PATENTE_VEHICULO: 3 letras y 3 números
336 patente_vehiculo or "",
337 # PATENTE_ACOPLADO: 3 letras y 3 números
338 patente_acoplado or "",
339 # PRODUCTO_NO_TERM_DEV: 0=No, 1=Si (devoluciones)
340 str(prod_no_term_dev),
341 # IMPORTE: formato 8 enteros 2 decimales,
342 str(int(round(importe * 100.0)))[-14:],
343 ]
344 )
346 for line in rec.mapped("move_ids").filtered(lambda x: x.product_uom_qty):
347 # buscamos si hay unidad de medida de la cateogria que tenga
348 # codigo de arba y usamos esa, ademas convertimos la cantidad
349 product_qty = line.product_uom_qty
350 if line.product_uom.arba_code:
351 uom_arba_with_code = line.product_uom
352 else:
353 uom_arba_with_code = line.product_uom.search(
354 [
355 ("category_id", "=", line.product_uom.category_id.id),
356 ("arba_code", "!=", False),
357 ],
358 limit=1,
359 )
360 if not uom_arba_with_code:
361 raise UserError(
362 self.env._(
363 'No arba code for uom "%(uom)s" (Id: %(id)s) or any uom in category "%(category)s"',
364 uom=line.product_uom.name,
365 id=line.product_uom.id,
366 category=line.product_uom.category_id.name,
367 )
368 )
370 product_qty = line.product_uom._compute_quantity(product_qty, uom_arba_with_code)
372 if not line.product_id.arba_code:
373 raise UserError(
374 self.env._(
375 'No arba code for product "%(product)s" (Id: %(id)s)',
376 product=line.product_id.name,
377 id=line.product_id.id,
378 )
379 )
381 REMITOS_PRODUCTOS.append(
382 [
383 # TIPO_REGISTRO: 03
384 "03",
385 # CODIGO_UNICO_PRODUCTO
386 # nomenclador COT (Transporte de Bienes)
387 line.product_id.arba_code,
388 # RENTAS_CODIGO_UNIDAD_MEDIDA: ver tabla unidades de medida
389 uom_arba_with_code.arba_code,
390 # CANTIDAD: 13 enteros y 2 decimales (no incluir coma
391 # ni punto), ej 200 un -> 20000
392 str(int(round(product_qty * 100.0)))[-15:],
393 # PROPIO_CODIGO_PRODUCTO: máx. 25 caracteres
394 (line.product_id.default_code or "")[:25],
395 # PROPIO_DESCRIPCION_PRODUCTO: máx. 40 caracteres
396 (line.product_id.name)[:40],
397 # PROPIO_DESCRIPCION_UNIDAD_MEDIDA: máx. 20 caracteres
398 (uom_arba_with_code.name)[:20],
399 # CANTIDAD_AJUSTADA: 13 enteros y 2 decimales (no incluir
400 # coma ni punto), ej 200 un -> 20000 (en los que vi mandan
401 # lo mismo)
402 str(int(round(product_qty * 100.0)))[-15:],
403 ]
404 )
406 content = ""
407 for line in [HEADER] + REMITOS_PRODUCTOS + [FOOTER]:
408 content += "%s\r" % ("|".join(line))
409 return (filename, content, "text/plain")
411 def _parse_arba_response(self, response_content):
412 root = ET.fromstring(response_content)
413 process = root.find(".//procesado") and root.find(".//procesado").text == "SI"
414 if process:
415 try:
416 return {
417 "procesado": root.find(".//procesado").text,
418 "numeroUnico": root.find(".//numeroUnico").text,
419 "cot": root.find(".//cot").text,
420 "numeroComprobante": root.find(".//numeroComprobante").text,
421 "codigoIntegridad": root.find(".//codigoIntegridad").text,
422 }
423 except Exception as e:
424 _logger.error("Error parsing ARBA COT response: %s\n %s", e, str(response_content))
425 raise UserError(self.env._("Error parsing ARBA COT response"))
426 else:
427 errors = root.findall(".//errores/error")
428 error_string = self.env._("Error al presentar remito:\n")
429 for error in errors:
430 error_string += f"* * {error.find('codigo').text} {error.find('descripcion').text}\n"
431 _logger.warning(error_string)
432 raise UserError(error_string)
434 def _arba_send_picking(
435 self,
436 datetime_out,
437 tipo_recorrido,
438 carrier_partner,
439 patente_vehiculo,
440 patente_acoplado,
441 prod_no_term_dev,
442 importe,
443 ):
444 for rec in self:
445 if not carrier_partner:
446 raise UserError(
447 'Debe vincular una "Empresa transportista" a la forma de envío'
448 " seleccionada o elegir otra forma de envío"
449 )
450 file = rec._get_arba_file_data(
451 datetime_out,
452 tipo_recorrido,
453 carrier_partner,
454 patente_vehiculo,
455 patente_acoplado,
456 prod_no_term_dev,
457 importe,
458 )
460 login_url = rec.company_id._get_arba_cot_login_url()
461 request_data = rec.company_id._get_arba_cot_request_data()
462 arba_cot_timeout = int(
463 self.env["ir.config_parameter"].sudo().get_param("l10n_ar_stock_ux.arba_cot_timeout", default=40)
464 )
465 res = requests.post(
466 login_url,
467 data=request_data,
468 files={"file": file},
469 timeout=arba_cot_timeout,
470 )
471 if res.ok:
472 cot = self._parse_arba_response(res.content)
473 attachments = [(file[0], file[1])]
474 body = f"""
475 <p>
476 Resultado solicitud COT:
477 <ul>
478 <li>Número Comprobante: {cot["numeroComprobante"]}</li>
479 <li>Codigo Integridad: {cot["codigoIntegridad"]}</li>
480 <li>Procesado: {cot["procesado"]}</li>
481 <li>Número Único: {cot["numeroUnico"]}</li>
482 <li>COT: {cot["cot"]}</li>
483 </ul>
484 </p>
485 """
486 rec.write(
487 {
488 "cot_numero_unico": cot["numeroUnico"],
489 "cot_numero_comprobante": cot["numeroComprobante"],
490 "cot": cot["cot"],
491 }
492 )
493 rec.message_post(
494 body=body,
495 subject=self.env._("Remito Electrónico Solicitado"),
496 attachments=attachments,
497 body_is_html=True,
498 )
500 else:
501 raise UserError(
502 self.env._(
503 "Error al conectar con ARBA COT. Estado: %(status)s. Mensaje: %(msg)s",
504 status=res.status_code,
505 msg=res.text,
506 )
507 )
509 return True
511 def _action_done(self):
512 # asignamos nro antes de validar para que el reporte que salga al enviar por correo (_send_confirmation_email)
513 # sea con look y nro argentino
514 # TODO revisar si alguien lo usa y sacar este contexto
515 if not self._context.get("do_not_assign_numbers", False):
516 for picking in self.filtered("picking_type_id.auto_assign_delivery_guide"):
517 picking.l10n_ar_action_create_delivery_guide()
518 res = super()._action_done()
519 for rec in self.filtered(lambda x: x.picking_type_code == "incoming" and x.dispatch_number):
520 rec.move_line_ids.filtered(lambda l: l.lot_id and not l.lot_id.dispatch_number).mapped("lot_id").write(
521 {"dispatch_number": rec.dispatch_number}
522 )
523 return res