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:37 +0000

1import datetime 

2import logging 

3import xml.etree.ElementTree as ET 

4 

5import requests 

6from odoo import api, fields, models 

7from odoo.exceptions import UserError 

8 

9_logger = logging.getLogger(__name__) 

10 

11 

12class StockPicking(models.Model): 

13 _inherit = "stock.picking" 

14 

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 ) 

54 

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) 

69 

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 

80 

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 ) 

93 

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 

117 

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 """ 

136 

137 FECHA_SALIDA_TRANSPORTE = datetime_out.strftime("%Y%m%d") 

138 HORA_SALIDA_TRANSPORTE = datetime_out.strftime("%H%M") 

139 

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() 

145 

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 ) 

153 

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 ) 

166 

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 ) 

174 

175 # 01 - HEADER: TIPO_REGISTRO & CUIT_EMPRESA 

176 HEADER = ["01", cuit] 

177 

178 # 04 - FOOTER (Pie): TIPO_REGISTRO: 04 & CANTIDAD_TOTAL_REMITOS 

179 FOOTER = ["04", str(len(self))] 

180 

181 # TODO, si interesa se puede repetir esto para cada uno 

182 REMITOS_PRODUCTOS = [] 

183 

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 

191 

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 ) 

196 

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 ) 

209 

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") 

216 

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() 

233 

234 dest_cons_final = commercial_partner.l10n_ar_afip_responsibility_type_id.code == "5" and "1" or "0" 

235 

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 ) 

345 

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 ) 

369 

370 product_qty = line.product_uom._compute_quantity(product_qty, uom_arba_with_code) 

371 

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 ) 

380 

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 ) 

405 

406 content = "" 

407 for line in [HEADER] + REMITOS_PRODUCTOS + [FOOTER]: 

408 content += "%s\r" % ("|".join(line)) 

409 return (filename, content, "text/plain") 

410 

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) 

433 

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 ) 

459 

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 ) 

499 

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 ) 

508 

509 return True 

510 

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