aziendasanitaria-utils 1.2.49 → 1.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,6 +252,75 @@ const assistiti = await nar2.getAssistitiFromParams({
252
252
  [Nar2.PARAMS.NOME]: 'MARIO',
253
253
  [Nar2.PARAMS.AZIENDA]: '205'
254
254
  });
255
+
256
+ // Elenco medici MMG/PLS, con dati dei rapporti individuali e degli indirizzi
257
+ const medici = await nar2.getMediciFromNar2({
258
+ soloAttivi: true,
259
+ aggiungiDatiRapporti: true, // allega `rapporti` e `indirizzi` a ogni medico
260
+ parallelsRapporti: 10 // richieste eseguite in parallelo
261
+ });
262
+
263
+ // Solo i rapporti / gli indirizzi di un medico dato il suo pf_id
264
+ const rapporti = await nar2.getRapportiMedicoFromId(5860, 'ME');
265
+ const indirizzi = await nar2.getIndirizziMedicoFromId(5860, 'ME');
266
+
267
+ // Lookup comuni (cm_id NAR2) per nome/CAP/ISTAT/catastale, con filtro esatto
268
+ // e fallback CAP generico per le città multi-CAP (98167 → 98100)
269
+ const {comune, candidati} = await nar2.getComuneNar2({cap: '98167'}); // → MESSINA (id 83)
270
+
271
+ // Aggiornamento residenza/domicilio (PUT /pazienti/{id}, dryRun default true).
272
+ // Sorgenti combinabili: daTs (entrambi dal Sistema TS), residenzaDaTs/domicilioDaTs
273
+ // (singola sezione da TS), residenza/domicilio personalizzati {via, civico, cap, comuneId|criterio}
274
+ const dry = await nar2.aggiornaIndirizziAssistito(cf, {daTs: true}); // ispeziona dry.diff/warnings
275
+ const sent = await nar2.aggiornaIndirizziAssistito(cf, {
276
+ residenzaDaTs: true,
277
+ domicilio: {via: 'VIA ROMA', civico: '10', cap: '98057'}, // comune risolto dal CAP
278
+ dryRun: false
279
+ });
280
+ // Vedi docs/CAMBIO_MEDICO_NAR2.md §5.b — la verifica indirizzi è lo step standard
281
+ // che precede il cambio medico (test interattivo: node ./test-cambio-medico.js)
282
+ ```
283
+
284
+ Ogni rapporto è normalizzato così (include anche i rapporti non attivi; `attivo` è `true` solo se manca la data di fine):
285
+
286
+ ```javascript
287
+ {
288
+ id: "75337",
289
+ categoria: "mmg", // "mmg" | "pls"
290
+ tipoRapporto: "MDB",
291
+ descrizioneRapporto: "Medico di base",
292
+ email: "...",
293
+ telefoni: ["3780818127", "3403896880"], // array, scarta i valori nulli
294
+ dataInizio: "25/05/2026", // dd/MM/yyyy
295
+ dataFine: null, // null = rapporto attivo
296
+ attivo: true,
297
+ domicilio: { // indirizzo studio; null se NAR non fornisce il link
298
+ comune: "MESSINA",
299
+ provincia: "ME",
300
+ cap: "98100",
301
+ indirizzo: "VIA CENTONZE",
302
+ numeroCivico: "72"
303
+ }
304
+ }
305
+ ```
306
+
307
+ Gli indirizzi del medico (legati al medico, non al rapporto) sono restituiti tutti in array, normalizzati così:
308
+
309
+ ```javascript
310
+ {
311
+ progressivo: "1",
312
+ descrizione: "RESIDENZA", // può essere null
313
+ comune: "CANICATTI'",
314
+ provincia: "AG",
315
+ cap: "92024",
316
+ indirizzo: "VIA S. CORAZZINI, 9",
317
+ numeroCivico: "1",
318
+ dataAttivazione: "21/03/2065", // dd/MM/yyyy
319
+ dataDisattivazione: null,
320
+ residenza: true, // true se è l'indirizzo di residenza
321
+ telefoni: [],
322
+ email: null
323
+ }
255
324
  ```
256
325
 
257
326
  #### Assistiti - Gestione Dati Assistiti
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=14.0.0"
5
5
  },
6
- "version": "1.2.49",
6
+ "version": "1.2.56",
7
7
  "repository": "deduzzo/aziendasanitaria-utils",
8
8
  "description": "Un utility per gestire i flussi sanitari Siciliani e non solo..",
9
9
  "main": "index.js",
@@ -24,6 +24,8 @@ export class Nar2 {
24
24
  static MEDICI_BY_AMBITO_AUTOCOMPLETE = "https://nar2.regione.sicilia.it/services/index.php/api/mediciByAmbito";
25
25
  static MOTIVI_OPERAZIONE_FROM_TIPO = "https://nar2.regione.sicilia.it/services/index.php/api/motiviOperazioneFromMotivoRevoca/{tipoOp}";
26
26
  static STAMPA_DOCUMENTO_URL = "https://nar2.regione.sicilia.it/services/index.php/api/stampe/{tipo}";
27
+ static GET_COMUNI_AUTOCOMPLETE = "https://nar2.regione.sicilia.it/services/index.php/api/comuni";
28
+ static GET_COMUNE_FROM_ID = "https://nar2.regione.sicilia.it/services/index.php/api/anagrafica/comune/{id}";
27
29
 
28
30
  // Documenti di stampa dell'assistito (endpoint /stampe/{tipo}?paziente={pz_id} → application/pdf).
29
31
  // Etichette UI nella pagina medico-di-base (ultimo rapporto, attivo o cessato).
@@ -92,6 +94,26 @@ export class Nar2 {
92
94
  PAGINATION: 'pagination'
93
95
  };
94
96
 
97
+ // Campi del record paziente inviati dalla UI NAR2 nella PUT /pazienti/{id}
98
+ // (tracciato di rete della pagina anagrafica, 2026-06-04). Il payload viene
99
+ // ricostruito prendendo questi campi dal record corrente del GET /pazienti/{id}:
100
+ // NON vanno inclusi i campi derivati/annidati (comune_residenza, storico_medici, ...).
101
+ static #CAMPI_PUT_PAZIENTE = [
102
+ "pz_cogn", "pz_nome", "pz_dt_nas", "pz_cfis", "pz_sesso", "pz_com_nas", "pz_citt",
103
+ "pz_fcertif", "pz_fcompl", "pz_fstato", "pz_dscadaire", "pz_aire", "pz_ise",
104
+ "pz_dt_dec", "pz_con_pers", "pz_con_parentela", "pz_con_tele", "pz_email",
105
+ "pz_tel1", "pz_tel2", "pz_tel3", "pz_tel1_nota", "pz_tel2_nota", "pz_tel3_nota",
106
+ "pz_flagpriv", "pz_com_dec", "pz_ind_res", "pz_com_res", "pz_nciv_res",
107
+ "pz_com_dom", "pz_ind_dom", "pz_nciv_dom", "pz_categoria_citt", "pz_asl_app",
108
+ "pz_reg_res", "pz_doc_dt_ril", "pz_doc_ril_da", "pz_doc_ril_di", "pz_documento",
109
+ "pz_doc_dt_sca", "pz_doc_tipo", "pz_asl_pro", "pz_com_imm", "pz_com_emi",
110
+ "pz_dt_emi", "pz_dt_imm", "pz_dis_pro", "pz_dt_ini_ssn", "pz_dt_end_ssn",
111
+ "pz_asl_ass", "pz_dt_ini_asl", "pz_dt_end_asl", "pz_distretto_ass", "pz_reg_assi",
112
+ "pz_ts_dt_ini", "pz_dscadtess", "pz_tsan", "pz_ts_asl_emi", "pz_drilstp",
113
+ "pz_dscadstp", "pz_stp", "pz_drileni", "pz_dscadeni", "pz_eni",
114
+ "pz_team_dt_ini", "pz_team_dtsca", "pz_team_tes", "pz_team_id", "pz_team_qualben"
115
+ ];
116
+
95
117
  static #token = null;
96
118
  static #tokenPromise = null; // Variabile per la chiamata in corso
97
119
 
@@ -193,7 +215,7 @@ export class Nar2 {
193
215
  async getSituazioniAssistenzialiAmmesse(codFiscale, config = {}) {
194
216
  const {pmId = null, tipoMedico = Nar2.MEDICO_DI_BASE} = config;
195
217
  const dati = await this.getDatiAssistitoNar2FromCf(codFiscale);
196
- if (!dati || !dati.ok) return {ok: false, data: null};
218
+ if (!dati || !dati.ok) return {ok: false, data: null, error: dati?.error || "Assistito non trovato su NAR2", errorDetail: dati?.errorDetail ?? null};
197
219
  const fullData = dati.fullData.data;
198
220
  const eta = moment().diff(moment(fullData.pz_dt_nas, "YYYY-MM-DD HH:mm:ss"), "years");
199
221
  const azId = fullData.comune_domicilio?._azienda?.[0]?.az_azie ?? "ME";
@@ -251,13 +273,13 @@ export class Nar2 {
251
273
  let sm = sceltaMedico;
252
274
  if (!sm) {
253
275
  const dati = await this.getDatiAssistitoNar2FromCf(codFiscale);
254
- if (!dati || !dati.ok) return {ok: false, data: null};
276
+ if (!dati || !dati.ok) return {ok: false, data: null, error: dati?.error || "Assistito non trovato su NAR2", errorDetail: dati?.errorDetail ?? null};
255
277
  const pazienteId = dati.fullData.data.pz_id;
256
278
  const azId = dati.fullData.data.comune_domicilio?._azienda?.[0]?.az_azie ?? "ME";
257
279
  const res = await this.#getDataFromUrlIdOrParams(Nar2.GET_DATI_PAZIENTEMEDICO, {
258
280
  replaceFromUrl: {id: pazienteId, tipo_medico: tipoMedico, az_id: azId}
259
281
  });
260
- if (!res.ok || !res.data?.sceltaMedico) return {ok: false, data: null};
282
+ if (!res.ok || !res.data?.sceltaMedico) return {ok: false, data: null, error: res.error || "Dati paziente-medico non disponibili su NAR2", errorDetail: res.errorDetail ?? null};
261
283
  sm = res.data.sceltaMedico;
262
284
  }
263
285
  let tipi = Array.isArray(sm.tipoOperazioni_) ? sm.tipoOperazioni_ : [];
@@ -287,18 +309,20 @@ export class Nar2 {
287
309
  async getMotiviOperazione(tipoOp = Nar2.TIPO_OP_REVOCA, config = {}) {
288
310
  const {semplifica = false, retryVuoto = 3} = config;
289
311
  let data = null;
312
+ let lastRes = null;
290
313
  for (let i = 0; i <= retryVuoto; i++) {
291
314
  const res = await this.#getDataFromUrlIdOrParams(Nar2.MOTIVI_OPERAZIONE_FROM_TIPO, {
292
315
  replaceFromUrl: {tipoOp},
293
316
  rawResponse: true
294
317
  });
318
+ lastRes = res;
295
319
  if (res.ok && Array.isArray(res.data)) {
296
320
  data = res.data;
297
321
  if (data.length > 0) break; // ok con motivi
298
322
  }
299
323
  if (i < retryVuoto) await new Promise((r) => setTimeout(r, 300));
300
324
  }
301
- if (!Array.isArray(data)) return {ok: false, data: null};
325
+ if (!Array.isArray(data)) return {ok: false, data: null, error: lastRes?.error || "Motivi operazione non disponibili su NAR2", errorDetail: lastRes?.errorDetail ?? null};
302
326
  if (semplifica) data = data.map((m) => ({id: m.eg_id, codice: m.eg_cod, descrizione: m.eg_desc1}));
303
327
  return {ok: true, data};
304
328
  }
@@ -320,7 +344,7 @@ export class Nar2 {
320
344
  async getMotiviPerTipoOperazione(codFiscale, config = {}) {
321
345
  const {tipoMedico = Nar2.MEDICO_DI_BASE, semplifica = true, gruppo = null} = config;
322
346
  const tipiRes = await this.getTipiOperazione(codFiscale, {tipoMedico, semplifica: false, gruppo});
323
- if (!tipiRes.ok || !Array.isArray(tipiRes.data)) return {ok: false, data: null};
347
+ if (!tipiRes.ok || !Array.isArray(tipiRes.data)) return {ok: false, data: null, error: tipiRes.error || "Tipi operazione non disponibili su NAR2", errorDetail: tipiRes.errorDetail ?? null};
324
348
 
325
349
  const data = await Promise.all(tipiRes.data.map(async (tp) => {
326
350
  const motiviRes = await this.getMotiviOperazione(tp.eg_id, {semplifica});
@@ -380,7 +404,9 @@ export class Nar2 {
380
404
  dataScelta = moment().format("YYYY-MM-DD"),
381
405
  sitAssistenziale = 4
382
406
  } = config;
383
- const fullData = (await this.getDatiAssistitoNar2FromCf(codFiscaleAssistito)).fullData.data;
407
+ const dati = await this.getDatiAssistitoNar2FromCf(codFiscaleAssistito);
408
+ if (!dati || !dati.ok || !dati.fullData?.data) return {ok: false, data: null, error: dati?.error || "Assistito non trovato su NAR2", errorDetail: dati?.errorDetail ?? null};
409
+ const fullData = dati.fullData.data;
384
410
  const azienda = fullData.comune_domicilio?._azienda?.[0]?.az_azie ?? "ME";
385
411
  return await this.#getDataFromUrlIdOrParams(Nar2.MEDICI_BY_AMBITO_AUTOCOMPLETE, {
386
412
  getParams: {
@@ -458,29 +484,64 @@ export class Nar2 {
458
484
  // 1) Dati assistito
459
485
  const datiAssistito = await this.getDatiAssistitoNar2FromCf(codFiscale);
460
486
  if (!datiAssistito || !datiAssistito.ok) {
461
- return {ok: false, dryRun, payload: null, response: null, error: "Assistito non trovato su NAR2"};
487
+ return {ok: false, dryRun, payload: null, response: null, error: datiAssistito?.error || "Assistito non trovato su NAR2", errorDetail: datiAssistito?.errorDetail ?? null};
462
488
  }
463
489
  const fullData = datiAssistito.fullData.data;
464
490
  const pzId = fullData.pz_id;
465
491
  const eta = moment().diff(moment(fullData.pz_dt_nas, "YYYY-MM-DD HH:mm:ss"), "years");
466
492
  const azId = fullData.comune_domicilio?._azienda?.[0]?.az_azie ?? "ME";
467
493
 
468
- // 2) Ambito di domicilio (se non fornito): lo ricavo dai dati paziente o dal catalogo ambiti
469
- let ambitoDom = idAmbitoDomicilio;
494
+ // 2) Ambito di domicilio (se non fornito): SEMPRE dal catalogo CORRENTE
495
+ // (/ambitoDomTable sr_id per il comune di domicilio, vedi spec
496
+ // CAMBIO_MEDICO_NAR2.md §8.1/§12). MAI dallo storico come fonte primaria:
497
+ // gli id ambito vengono riorganizzati nel tempo da NAR2 (es. scelta del 2000
498
+ // con dm_ambito_dom=147; oggi l'ambito valido per lo stesso comune è 3748) e
499
+ // un id stale viene rifiutato dalla POST /pazienti/sceltaMedico con 400.
500
+ let ambitoDom = idAmbitoDomicilio ?? null;
501
+ let ambitiRes = null;
470
502
  if (!ambitoDom) {
471
- const dettStorico = fullData.storico_medici?.[0]?.dett_pazientemedico;
472
- ambitoDom = dettStorico?.dm_ambito_dom ?? null;
473
- if (!ambitoDom) {
474
- // fallback: prendo il primo ambito MMG/Pediatra dalla lista
475
- const ambitiRes = await this.getAmbitiDomicilioAssistito(codFiscale, {dividiAmbitiMMGPediatri: true});
476
- if (ambitiRes?.ok) {
477
- const lista = tipoMedico === Nar2.PEDIATRA ? ambitiRes.data.ambiti.pediatri : ambitiRes.data.ambiti.mmg;
478
- ambitoDom = lista?.[0]?.sr_id ?? null;
503
+ ambitiRes = await this.getAmbitiDomicilioAssistito(codFiscale, {dividiAmbitiMMGPediatri: true});
504
+ const lista = (ambitiRes?.ok
505
+ ? (tipoMedico === Nar2.PEDIATRA ? ambitiRes.data.ambiti.pediatri : ambitiRes.data.ambiti.mmg)
506
+ : null) ?? [];
507
+ const listaIds = lista.map((a) => a?.sr_id?.toString()).filter(Boolean);
508
+ if (listaIds.length === 1) {
509
+ ambitoDom = listaIds[0];
510
+ } else if (listaIds.length > 1) {
511
+ // Comune con più ambiti: disambiguo con l'ambito del rapporto del
512
+ // medico scelto, se compare nel catalogo. Su medici/{id} il campo è
513
+ // lo scalare dett_medico.dm_ambito (verificato live); in altri
514
+ // contesti la spec mostra l'oggetto annidato dett_medico.ambito
515
+ // {sr_id,...} — li leggo entrambi per robustezza.
516
+ const medicoRes = await this.getMedicoFromId(idMedico);
517
+ const ambitiMedico = (medicoRes?.ok ? (medicoRes.data?.rapporto_individuale ?? []) : [])
518
+ .map((ri) => (ri?.dett_medico?.dm_ambito ?? ri?.dett_medico?.ambito?.sr_id)?.toString())
519
+ .filter(Boolean);
520
+ ambitoDom = ambitiMedico.find((a) => listaIds.includes(a)) ?? null;
521
+ if (!ambitoDom) {
522
+ // Continuità: l'ambito della scelta ATTIVA, ma solo se ancora
523
+ // presente nel catalogo corrente (altrimenti è stale).
524
+ const attiva = (Array.isArray(fullData.storico_medici) ? fullData.storico_medici : [])
525
+ .find((s) => s?.pm_fstato === "A" && !s?.pm_dt_disable);
526
+ const ambitoAttiva = attiva?.dett_pazientemedico?.dm_ambito_dom?.toString() ?? null;
527
+ ambitoDom = (ambitoAttiva && listaIds.includes(ambitoAttiva)) ? ambitoAttiva : listaIds[0];
479
528
  }
480
529
  }
530
+ // Last resort (catalogo non disponibile): ambito della scelta attiva,
531
+ // potenzialmente stale ma meglio di niente. NB: scelta ATTIVA, non
532
+ // storico_medici[0] (l'array non è ordinato).
533
+ if (!ambitoDom) {
534
+ const attiva = (Array.isArray(fullData.storico_medici) ? fullData.storico_medici : [])
535
+ .find((s) => s?.pm_fstato === "A" && !s?.pm_dt_disable);
536
+ ambitoDom = attiva?.dett_pazientemedico?.dm_ambito_dom ?? null;
537
+ }
481
538
  }
482
539
  if (!ambitoDom) {
483
- return {ok: false, dryRun, payload: null, response: null, error: "Impossibile determinare l'ambito di domicilio"};
540
+ return {
541
+ ok: false, dryRun, payload: null, response: null,
542
+ error: ambitiRes?.error || "Impossibile determinare l'ambito di domicilio",
543
+ errorDetail: ambitiRes?.errorDetail ?? null
544
+ };
484
545
  }
485
546
  const ambitoScelta = idAmbitoScelta ?? ambitoDom;
486
547
 
@@ -512,7 +573,7 @@ export class Nar2 {
512
573
  // 4) Situazione assistenziale + motivo + tipo operazione (dal catalogo)
513
574
  const sitAmmesse = await this.getSituazioniAssistenzialiAmmesse(codFiscale, {tipoMedico});
514
575
  if (!sitAmmesse || !sitAmmesse.ok || !Array.isArray(sitAmmesse.data)) {
515
- return {ok: false, dryRun, payload: null, response: null, error: "Impossibile recuperare situazioni assistenziali ammesse"};
576
+ return {ok: false, dryRun, payload: null, response: null, error: sitAmmesse?.error || "Impossibile recuperare situazioni assistenziali ammesse", errorDetail: sitAmmesse?.errorDetail ?? null};
516
577
  }
517
578
  let situazioneScelta;
518
579
  if (idSituazioneAssistenziale) {
@@ -577,7 +638,380 @@ export class Nar2 {
577
638
  payload,
578
639
  response: result.fullResponse,
579
640
  data: result.data,
580
- error: result.ok ? undefined : "Errore durante la POST /pazienti/sceltaMedico"
641
+ error: result.ok ? undefined : (result.error || "Errore durante la POST /pazienti/sceltaMedico"),
642
+ errorDetail: result.ok ? undefined : (result.errorDetail ?? null)
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Ricerca comuni via autocomplete NAR2.
648
+ *
649
+ * Endpoint: GET /comuni?start=0&autocomplete=true&searchKey=...
650
+ * La ricerca è "contains" su nome (cm_desc), CAP (cm_ccap), codice ISTAT
651
+ * (cm_cistat) e codice catastale (cm_cfis). NB (verificato live):
652
+ * - searchKey vuota → 0 risultati (serve almeno un carattere);
653
+ * - il CAP in tabella è UNO solo per comune (il generico: Messina=98100),
654
+ * i CAP specifici delle città multi-CAP non matchano (vedi getComuneNar2).
655
+ *
656
+ * @param {string} searchKey - Chiave di ricerca (nome parziale, CAP, ISTAT, catastale).
657
+ * @param {Object} [config={}]
658
+ * @param {boolean} [config.semplifica=true] - Se true ritorna {id, nome, cap, provincia, istat, catastale, regione}; se false i record grezzi cm_*.
659
+ * @returns {Promise<{ok:boolean, data:Array|null, count:number|null}>} `count` = totale lato server (la pagina restituita può essere più corta).
660
+ */
661
+ async getComuni(searchKey, config = {}) {
662
+ const {semplifica = true} = config;
663
+ const res = await this.#getDataFromUrlIdOrParams(Nar2.GET_COMUNI_AUTOCOMPLETE, {
664
+ getParams: {start: 0, autocomplete: true, searchKey: (searchKey ?? "").toString().trim()},
665
+ rawResponse: true // serve l'envelope completo per leggere anche `count`
666
+ });
667
+ if (!res.ok) return {ok: false, data: null, count: null, error: res.error, errorDetail: res.errorDetail ?? null};
668
+ const body = res.data ?? {};
669
+ if (body.status?.toString() !== "true" || !Array.isArray(body.result)) {
670
+ return {ok: false, data: null, count: null, error: Nar2.#extractErrorMessage(body) || "Risposta /comuni non valida", errorDetail: null};
671
+ }
672
+ const data = semplifica
673
+ ? body.result.map((c) => ({
674
+ id: c.cm_id,
675
+ nome: c.cm_desc,
676
+ cap: c.cm_ccap,
677
+ provincia: c.cm_prov,
678
+ istat: c.cm_cistat,
679
+ catastale: c.cm_cfis,
680
+ regione: c.cm_reg
681
+ }))
682
+ : body.result;
683
+ return {ok: true, data, count: body.count ?? data.length};
684
+ }
685
+
686
+ /**
687
+ * Risolve un comune NAR2 (cm_id) a partire da UN criterio: nome, CAP,
688
+ * codice ISTAT (6 cifre con zero iniziale) o codice catastale/Belfiore.
689
+ * Applica un filtro ESATTO lato client sul criterio usato: la searchKey del
690
+ * server è "contains" su più campi e produce falsi positivi (es. CAP 98057
691
+ * matcha anche TERRANOVA DEI PASSERINI per l'ISTAT 098057).
692
+ *
693
+ * CAP delle città multi-CAP: la tabella NAR2 ha solo il CAP generico
694
+ * (98167 → 0 risultati). Se il CAP esatto non trova nulla si riprova col
695
+ * CAP generalizzato (ultime 2 cifre → "00": 98167→98100) e il risultato
696
+ * viene marcato `capGenerico: true`.
697
+ *
698
+ * Precedenza criteri (dal più univoco): catastale > istat > cap > nome.
699
+ *
700
+ * @param {Object} [criteri={}]
701
+ * @param {string} [criteri.nome] - Nome (anche parziale; match esatto se esiste, altrimenti i parziali).
702
+ * @param {string} [criteri.cap] - CAP a 5 cifre.
703
+ * @param {string} [criteri.istat] - Codice ISTAT 6 cifre (es. "083049").
704
+ * @param {string} [criteri.catastale] - Codice catastale (es. "F206").
705
+ * @returns {Promise<{ok:boolean, comune:Object|null, candidati:Array, capGenerico?:boolean}>}
706
+ * `comune` valorizzato solo se il match è univoco; `candidati` per la scelta interattiva.
707
+ */
708
+ async getComuneNar2(criteri = {}) {
709
+ const norm = (s) => (s ?? "").toString().trim().toUpperCase();
710
+ const {nome = null, cap = null, istat = null, catastale = null} = criteri;
711
+ const ricerca = async (searchKey, campo, valore) => {
712
+ const res = await this.getComuni(searchKey);
713
+ if (!res.ok) return res;
714
+ return {ok: true, data: res.data.filter((c) => norm(c[campo]) === norm(valore))};
715
+ };
716
+
717
+ let candidati = null;
718
+ let capGenerico = false;
719
+ let lastRes = null;
720
+ if (catastale) {
721
+ lastRes = await ricerca(catastale, "catastale", catastale);
722
+ candidati = lastRes.ok ? lastRes.data : null;
723
+ } else if (istat) {
724
+ lastRes = await ricerca(istat, "istat", istat);
725
+ candidati = lastRes.ok ? lastRes.data : null;
726
+ } else if (cap) {
727
+ lastRes = await ricerca(cap, "cap", cap);
728
+ candidati = lastRes.ok ? lastRes.data : null;
729
+ if (lastRes.ok && candidati.length === 0 && /^\d{5}$/.test(norm(cap))) {
730
+ // CAP specifico di città multi-CAP: riprovo col CAP generico (98167 → 98100)
731
+ const generico = `${norm(cap).substring(0, 3)}00`;
732
+ if (generico !== norm(cap)) {
733
+ lastRes = await ricerca(generico, "cap", generico);
734
+ if (lastRes.ok && lastRes.data.length > 0) {
735
+ candidati = lastRes.data;
736
+ capGenerico = true;
737
+ }
738
+ }
739
+ }
740
+ } else if (nome) {
741
+ const res = await this.getComuni(nome);
742
+ lastRes = res;
743
+ if (res.ok) {
744
+ const esatti = res.data.filter((c) => norm(c.nome) === norm(nome));
745
+ candidati = esatti.length > 0 ? esatti : res.data;
746
+ }
747
+ } else {
748
+ return {ok: false, comune: null, candidati: [], error: "Nessun criterio di ricerca fornito (nome, cap, istat o catastale)"};
749
+ }
750
+
751
+ if (!candidati) return {ok: false, comune: null, candidati: [], error: lastRes?.error || "Ricerca comuni fallita", errorDetail: lastRes?.errorDetail ?? null};
752
+ if (candidati.length === 0) return {ok: false, comune: null, candidati: [], error: `Nessun comune trovato per ${JSON.stringify(criteri)}`};
753
+ return {ok: true, comune: candidati.length === 1 ? candidati[0] : null, candidati, ...(capGenerico ? {capGenerico} : {})};
754
+ }
755
+
756
+ /**
757
+ * Dettaglio di un comune per cm_id.
758
+ *
759
+ * Endpoint: GET /anagrafica/comune/{id} → record cm_* completo, incluso
760
+ * `uc_asl` (ASL di competenza territoriale, es. "281"), usato per il
761
+ * warning di coerenza ASL in aggiornaIndirizziAssistito.
762
+ *
763
+ * @param {string|number} cmId - cm_id interno NAR2.
764
+ * @returns {Promise<{ok:boolean, data:Object|null}>}
765
+ */
766
+ async getComuneFromId(cmId) {
767
+ return await this.#getDataFromUrlIdOrParams(Nar2.GET_COMUNE_FROM_ID, {replaceFromUrl: {id: cmId}});
768
+ }
769
+
770
+ /**
771
+ * Compone la stringa indirizzo nel formato usato dal Sistema TS / NAR2:
772
+ * "PACE SALITA BISIGNANI 3 , 98167 MESSINA (ME)"
773
+ * (via civico, spazio-virgola-doppio spazio, CAP, doppio spazio, comune,
774
+ * doppio spazio, provincia tra parentesi; tutto maiuscolo).
775
+ * Il CAP è quello reale dell'indirizzo (es. 98167), non per forza il cm_ccap.
776
+ *
777
+ * @param {Object} p
778
+ * @param {string} p.via - Via/denominazione (es. "PACE SALITA BISIGNANI").
779
+ * @param {string|number|null} [p.civico=null] - Numero civico (accodato alla via).
780
+ * @param {string|number} p.cap - CAP a 5 cifre.
781
+ * @param {string} p.nomeComune - Nome comune (es. "MESSINA").
782
+ * @param {string} p.provincia - Sigla provincia (es. "ME").
783
+ * @returns {string} Indirizzo formattato.
784
+ */
785
+ static formattaIndirizzoNar2({via, civico = null, cap, nomeComune, provincia}) {
786
+ const clean = (v) => (v === null || typeof v === "undefined") ? "" : v.toString().trim();
787
+ const viaCivico = [clean(via), clean(civico)].filter((x) => x !== "").join(" ");
788
+ return `${viaCivico} , ${clean(cap)} ${clean(nomeComune)} (${clean(provincia)})`.toUpperCase();
789
+ }
790
+
791
+ /**
792
+ * Risolve un parametro indirizzo di aggiornaIndirizziAssistito in
793
+ * `{indirizzo, comuneId, comune}` pronti per il payload. Accetta:
794
+ * - {indirizzo: "già formattato", comuneId} → passa-through;
795
+ * - {via, civico?, cap, comuneId} → comune da id, stringa composta;
796
+ * - {via, civico?, cap, catastale|istat|nome} → comune risolto univocamente
797
+ * (il cap viene usato come criterio se non c'è altro), stringa composta.
798
+ *
799
+ * @param {Object} param - Parametro indirizzo (vedi sopra).
800
+ * @param {string} etichetta - "residenza" o "domicilio" (per i messaggi di errore).
801
+ * @returns {Promise<{ok:boolean, indirizzo?:string, comuneId?:string, comune?:Object, candidati?:Array}>}
802
+ */
803
+ async #risolviParametroIndirizzo(param, etichetta) {
804
+ if (!param || typeof param !== "object")
805
+ return {ok: false, error: `Parametro ${etichetta} mancante o non valido`};
806
+
807
+ // comune: da id diretto o da criteri
808
+ let comune = null;
809
+ if (param.comuneId) {
810
+ const det = await this.getComuneFromId(param.comuneId);
811
+ if (!det.ok || !det.data)
812
+ return {ok: false, error: `Comune ${etichetta} non trovato per id=${param.comuneId}${det.error ? `: ${det.error}` : ""}`, errorDetail: det.errorDetail ?? null};
813
+ comune = {
814
+ id: det.data.cm_id, nome: det.data.cm_desc, cap: det.data.cm_ccap,
815
+ provincia: det.data.cm_prov, istat: det.data.cm_cistat,
816
+ catastale: det.data.cm_cfis, regione: det.data.cm_reg
817
+ };
818
+ } else {
819
+ const criteri = {};
820
+ if (param.catastale) criteri.catastale = param.catastale;
821
+ else if (param.istat) criteri.istat = param.istat;
822
+ else if (param.nome) criteri.nome = param.nome;
823
+ else if (param.cap) criteri.cap = param.cap;
824
+ if (Object.keys(criteri).length === 0)
825
+ return {ok: false, error: `Comune ${etichetta} non determinabile: fornire comuneId oppure catastale/istat/nome/cap`};
826
+ const res = await this.getComuneNar2(criteri);
827
+ if (!res.ok) return {ok: false, error: `Comune ${etichetta}: ${res.error}`, errorDetail: res.errorDetail ?? null};
828
+ if (!res.comune) {
829
+ return {
830
+ ok: false,
831
+ error: `Comune ${etichetta} ambiguo per ${JSON.stringify(criteri)}: ${res.candidati.map((c) => `${c.nome} (${c.provincia}, id=${c.id})`).join("; ")}`,
832
+ candidati: res.candidati
833
+ };
834
+ }
835
+ comune = res.comune;
836
+ }
837
+
838
+ // stringa indirizzo: passa-through o composizione nel formato TS
839
+ let indirizzo;
840
+ if (typeof param.indirizzo === "string" && param.indirizzo.trim() !== "") {
841
+ indirizzo = param.indirizzo.trim().toUpperCase();
842
+ } else {
843
+ if (!param.via || !param.cap)
844
+ return {ok: false, error: `Indirizzo ${etichetta} incompleto: servono almeno via e cap (oppure la stringa già formattata in 'indirizzo')`};
845
+ indirizzo = Nar2.formattaIndirizzoNar2({
846
+ via: param.via, civico: param.civico ?? null, cap: param.cap,
847
+ nomeComune: comune.nome, provincia: comune.provincia
848
+ });
849
+ }
850
+ return {ok: true, indirizzo, comuneId: comune.id?.toString(), comune};
851
+ }
852
+
853
+ /**
854
+ * Aggiorna residenza e/o domicilio di un assistito su NAR2 replicando la PUT
855
+ * della pagina anagrafica: PUT /pazienti/{pz_id} con il record completo
856
+ * (whitelist #CAMPI_PUT_PAZIENTE presa dal record corrente) e i soli campi
857
+ * indirizzo modificati (pz_ind_res/pz_com_res/pz_nciv_res, pz_ind_dom/
858
+ * pz_com_dom/pz_nciv_dom). Come la UI: civico dentro la stringa, pz_nciv_*=null,
859
+ * pz_com_* = cm_id interno NAR2 (NON il codice ISTAT).
860
+ *
861
+ * Sorgenti combinabili per ciascuna sezione (residenza, domicilio):
862
+ * - {daTs: true} → scorciatoia: residenza E domicilio = indirizzo del Sistema TS
863
+ * (p801recapitoTessera; comune dal catastale TS, fallback ISTAT)
864
+ * - {residenzaDaTs: true} → la sola residenza dall'indirizzo TS
865
+ * - {domicilioDaTs: true} → il solo domicilio dall'indirizzo TS
866
+ * - {residenza: {...}} → residenza da parametro
867
+ * - {domicilio: {...}} → domicilio da parametro
868
+ * Es.: {residenzaDaTs: true, domicilio: {...}} = residenza da TS + domicilio manuale.
869
+ * Conflitti (daTs con altri; *DaTs con il parametro della stessa sezione) → ok:false.
870
+ *
871
+ * Parametro indirizzo (residenza/domicilio): vedi #risolviParametroIndirizzo.
872
+ *
873
+ * Nessun campo ASL/regione viene toccato (comportamento identico alla UI):
874
+ * se l'ASL di competenza del comune (uc_asl) differisce da pz_asl_app viene
875
+ * aggiunto un warning non bloccante in `warnings[]`.
876
+ *
877
+ * @param {string} codFiscale - Codice fiscale dell'assistito.
878
+ * @param {Object} [config={}]
879
+ * @param {boolean} [config.dryRun=true] - DEFAULT TRUE: ritorna payload e diff senza inviare.
880
+ * @param {boolean} [config.daTs=false] - Residenza E domicilio dall'indirizzo TS (scorciatoia).
881
+ * @param {boolean} [config.residenzaDaTs=false] - La sola residenza dall'indirizzo TS.
882
+ * @param {boolean} [config.domicilioDaTs=false] - Il solo domicilio dall'indirizzo TS.
883
+ * @param {Object|null} [config.residenza=null] - Indirizzo residenza da parametro.
884
+ * @param {Object|null} [config.domicilio=null] - Indirizzo domicilio da parametro.
885
+ * @returns {Promise<{ok:boolean, dryRun:boolean, payload:Object|null, diff:Object|null, warnings:string[], response:Object|null, error?:string}>}
886
+ * `diff` = {campo: {da, a}} dei soli campi che cambiano rispetto al record corrente.
887
+ */
888
+ async aggiornaIndirizziAssistito(codFiscale, config = {}) {
889
+ const {dryRun = true, daTs = false, residenzaDaTs = false, domicilioDaTs = false, residenza = null, domicilio = null} = config;
890
+ const warnings = [];
891
+ const ko = (error, extra = {}) => ({ok: false, dryRun, payload: null, diff: null, warnings, response: null, error, ...extra});
892
+
893
+ if (!daTs && !residenzaDaTs && !domicilioDaTs && !residenza && !domicilio)
894
+ return ko("Nessuna modifica richiesta: specificare daTs, residenzaDaTs, domicilioDaTs e/o residenza/domicilio");
895
+ if (daTs && (residenzaDaTs || domicilioDaTs || residenza || domicilio))
896
+ return ko("daTs è esclusivo: non combinare con residenzaDaTs/domicilioDaTs/residenza/domicilio");
897
+ if (residenzaDaTs && residenza)
898
+ return ko("residenzaDaTs e residenza sono alternativi");
899
+ if (domicilioDaTs && domicilio)
900
+ return ko("domicilioDaTs e domicilio sono alternativi");
901
+
902
+ // 1) Record corrente NAR2
903
+ const datiAssistito = await this.getDatiAssistitoNar2FromCf(codFiscale);
904
+ if (!datiAssistito || !datiAssistito.ok)
905
+ return ko(datiAssistito?.error || "Assistito non trovato su NAR2", {errorDetail: datiAssistito?.errorDetail ?? null});
906
+ const fullData = datiAssistito.fullData.data;
907
+
908
+ // 2) Indirizzo dal Sistema TS (se richiesto)
909
+ let nuovaResidenza = null; // {indirizzo, comuneId, comune}
910
+ let nuovoDomicilio = null;
911
+ if (daTs || residenzaDaTs || domicilioDaTs) {
912
+ const sogei = await this.getDatiAssistitoFromCfSuSogeiNew(codFiscale);
913
+ const tsData = sogei?.fullData?.data ?? null;
914
+ const indirizzoTs = (typeof tsData?.p801recapitoTessera === "string" && tsData.p801recapitoTessera.trim() !== "")
915
+ ? tsData.p801recapitoTessera.trim() : null;
916
+ if (!sogei?.ok || sogei?.data?.okTs !== true || !indirizzoTs)
917
+ return ko(sogei?.data?.erroreTs || "Indirizzo Sistema TS non disponibile per l'assistito");
918
+ // comune TS: catastale (univoco su NAR2), fallback ISTAT
919
+ const catastaleTs = (tsData.p801codiceComuneResidenza ?? "").toString().trim();
920
+ const istatTs = (tsData.p801codiceistatiComuneResidenza ?? "").toString().trim();
921
+ let comuneRes = catastaleTs ? await this.getComuneNar2({catastale: catastaleTs}) : {ok: false, comune: null};
922
+ if (!comuneRes.ok || !comuneRes.comune)
923
+ comuneRes = istatTs ? await this.getComuneNar2({istat: istatTs}) : comuneRes;
924
+ if (!comuneRes.ok || !comuneRes.comune)
925
+ return ko(`Comune TS non risolvibile su NAR2 (catastale='${catastaleTs}', istat='${istatTs}')${comuneRes.error ? `: ${comuneRes.error}` : ""}`);
926
+ // normalizza nel formato concordato: l'API Sogei usa spaziatura semplice
927
+ // ("VIA X 3, 98167 MESSINA (ME)"), il formato target è quello della UI
928
+ // ("VIA X 3 , 98167 MESSINA (ME)") — scompongo e ricompongo
929
+ let indirizzoTsNormalizzato;
930
+ const m = indirizzoTs.match(/^(.+?)\s*,\s*(\d{5})\s+(.+?)\s+\(([A-Za-z]{2})\)\s*$/);
931
+ if (m) {
932
+ indirizzoTsNormalizzato = Nar2.formattaIndirizzoNar2({
933
+ via: m[1], cap: m[2],
934
+ nomeComune: comuneRes.comune.nome, provincia: comuneRes.comune.provincia
935
+ });
936
+ } else {
937
+ indirizzoTsNormalizzato = indirizzoTs.toUpperCase();
938
+ warnings.push(`Indirizzo TS non scomponibile ("${indirizzoTs}"): usato così com'è`);
939
+ }
940
+ const daSistemaTs = {indirizzo: indirizzoTsNormalizzato, comuneId: comuneRes.comune.id?.toString(), comune: comuneRes.comune};
941
+ if (daTs || residenzaDaTs) nuovaResidenza = daSistemaTs;
942
+ if (daTs || domicilioDaTs) nuovoDomicilio = daSistemaTs;
943
+ }
944
+
945
+ // 3) Indirizzi da parametro
946
+ if (residenza) {
947
+ const r = await this.#risolviParametroIndirizzo(residenza, "residenza");
948
+ if (!r.ok) return ko(r.error, {errorDetail: r.errorDetail ?? null, ...(r.candidati ? {candidati: r.candidati} : {})});
949
+ nuovaResidenza = r;
950
+ }
951
+ if (domicilio) {
952
+ const d = await this.#risolviParametroIndirizzo(domicilio, "domicilio");
953
+ if (!d.ok) return ko(d.error, {errorDetail: d.errorDetail ?? null, ...(d.candidati ? {candidati: d.candidati} : {})});
954
+ nuovoDomicilio = d;
955
+ }
956
+
957
+ // 4) Payload: whitelist dei campi della PUT UI + override dei soli campi toccati
958
+ const payloadData = {};
959
+ for (const campo of Nar2.#CAMPI_PUT_PAZIENTE)
960
+ payloadData[campo] = typeof fullData[campo] === "undefined" ? null : fullData[campo];
961
+ if (nuovaResidenza) {
962
+ payloadData.pz_ind_res = nuovaResidenza.indirizzo;
963
+ payloadData.pz_com_res = nuovaResidenza.comuneId;
964
+ payloadData.pz_nciv_res = null;
965
+ }
966
+ if (nuovoDomicilio) {
967
+ payloadData.pz_ind_dom = nuovoDomicilio.indirizzo;
968
+ payloadData.pz_com_dom = nuovoDomicilio.comuneId;
969
+ payloadData.pz_nciv_dom = null;
970
+ }
971
+
972
+ // diff: prima → dopo dei soli campi modificati
973
+ const diff = {};
974
+ for (const campo of ["pz_ind_res", "pz_com_res", "pz_nciv_res", "pz_ind_dom", "pz_com_dom", "pz_nciv_dom"]) {
975
+ const da = typeof fullData[campo] === "undefined" ? null : fullData[campo];
976
+ if ((da ?? null) !== (payloadData[campo] ?? null)) diff[campo] = {da, a: payloadData[campo]};
977
+ }
978
+
979
+ // 5) Warning coerenza ASL (best effort, non bloccante)
980
+ const daVerificare = [];
981
+ if (nuovaResidenza) daVerificare.push({etichetta: "residenza", ...nuovaResidenza});
982
+ if (nuovoDomicilio && nuovoDomicilio.comuneId !== nuovaResidenza?.comuneId)
983
+ daVerificare.push({etichetta: "domicilio", ...nuovoDomicilio});
984
+ for (const {etichetta, comuneId, comune} of daVerificare) {
985
+ try {
986
+ const det = await this.getComuneFromId(comuneId);
987
+ const ucAsl = det.ok ? (det.data?.uc_asl ?? null) : null;
988
+ if (!ucAsl)
989
+ warnings.push(`ASL di competenza del comune di ${etichetta} (${comune?.nome ?? comuneId}) non determinabile`);
990
+ else if (fullData.pz_asl_app && ucAsl.toString() !== fullData.pz_asl_app.toString())
991
+ warnings.push(`Il comune di ${etichetta} (${comune?.nome ?? comuneId}) ricade nell'ASL ${ucAsl}, diversa dall'ASL di appartenenza dell'assistito (${fullData.pz_asl_app}): i campi ASL NON vengono modificati`);
992
+ } catch (e) {
993
+ warnings.push(`Verifica ASL ${etichetta} non riuscita: ${e.message}`);
994
+ }
995
+ }
996
+
997
+ const payload = {data: payloadData};
998
+
999
+ // 6) Dry-run: ritorna senza inviare
1000
+ if (dryRun) return {ok: true, dryRun: true, payload, diff, warnings, response: null};
1001
+
1002
+ // 7) Submit: PUT /pazienti/{pz_id}
1003
+ const result = await this.#postDataToUrl(Nar2.GET_ASSISTITO_NAR_FROM_ID, payload, {
1004
+ method: "put",
1005
+ replaceFromUrl: {id: fullData.pz_id}
1006
+ });
1007
+ return {
1008
+ ok: result.ok,
1009
+ dryRun: false,
1010
+ payload, diff, warnings,
1011
+ response: result.fullResponse,
1012
+ data: result.data,
1013
+ error: result.ok ? undefined : (result.error || "Errore durante la PUT /pazienti/{id}"),
1014
+ errorDetail: result.ok ? undefined : (result.errorDetail ?? null)
581
1015
  };
582
1016
  }
583
1017
 
@@ -604,9 +1038,9 @@ export class Nar2 {
604
1038
  if (includeFullData)
605
1039
  out.data.fullData = data.data;
606
1040
  return out;
607
- } else return {ok: false, data: null};
1041
+ } else return {ok: false, data: null, error: data?.error || "Dati paziente-medico non disponibili su NAR2", errorDetail: data?.errorDetail ?? null};
608
1042
  } catch (e) {
609
- return {ok: false, data: null};
1043
+ return {ok: false, data: null, error: e.message, errorDetail: null};
610
1044
  }
611
1045
  }
612
1046
 
@@ -637,7 +1071,7 @@ export class Nar2 {
637
1071
  let result = fullData;
638
1072
  if (!result) {
639
1073
  const datiRes = await this.getDatiAssistitoNar2FromCf(codFiscale);
640
- if (!datiRes || !datiRes.ok) return {ok: false, data: null};
1074
+ if (!datiRes || !datiRes.ok) return {ok: false, data: null, error: datiRes?.error || "Assistito non trovato su NAR2", errorDetail: datiRes?.errorDetail ?? null};
641
1075
  result = datiRes.fullData.data;
642
1076
  }
643
1077
 
@@ -751,7 +1185,7 @@ export class Nar2 {
751
1185
  let result = fullData;
752
1186
  if (!result) {
753
1187
  const datiRes = await this.getDatiAssistitoNar2FromCf(codFiscale);
754
- if (!datiRes || !datiRes.ok) return {ok: false, data: null};
1188
+ if (!datiRes || !datiRes.ok) return {ok: false, data: null, error: datiRes?.error || "Assistito non trovato su NAR2", errorDetail: datiRes?.errorDetail ?? null};
755
1189
  result = datiRes.fullData.data;
756
1190
  }
757
1191
  return {ok: true, data: Nar2.estraiRapportoMedicoAttivo(result, {categoria})};
@@ -808,7 +1242,7 @@ export class Nar2 {
808
1242
  return {ok: true, data: out};
809
1243
  }
810
1244
  }
811
- return {ok: false, data: null};
1245
+ return {ok: false, data: null, error: data?.error || "Ambiti di domicilio non disponibili su NAR2", errorDetail: data?.errorDetail ?? null};
812
1246
  }
813
1247
 
814
1248
 
@@ -858,7 +1292,7 @@ export class Nar2 {
858
1292
  }
859
1293
  return {ok: true, data: out};
860
1294
  }
861
- return {ok: false, data: []};
1295
+ return {ok: false, data: [], error: data?.error || "Medici per ambito non disponibili su NAR2", errorDetail: data?.errorDetail ?? null};
862
1296
  }
863
1297
 
864
1298
 
@@ -873,6 +1307,11 @@ export class Nar2 {
873
1307
  * @param {boolean} [config.soloMMG=false] - Flag indicating whether to include only MMG records.
874
1308
  * @param {string} [config.asl="281"] - The ASL code to filter the records (default is "281" for Messina).
875
1309
  * @param {string} [config.azienda="ME"] - The company code to filter the records (default is "ME" for Messina).
1310
+ * @param {boolean} [config.aggiungiDatiRapporti=false] - Se true, per ogni medico recupera (con una sola
1311
+ * chiamata `medici/{id}`) sia l'elenco dei rapporti individuali (categoria, email, telefoni, date,
1312
+ * domicilio/indirizzo studio), allegato al campo `rapporti`, sia gli indirizzi del medico, allegati
1313
+ * al campo `indirizzi`. Include tutti i rapporti, anche quelli non attivi.
1314
+ * @param {number} [config.parallelsRapporti=10] - Numero di richieste rapporti eseguite in parallelo.
876
1315
  *
877
1316
  * @return {Promise<Object>} A promise that resolves to the retrieved medical data.
878
1317
  */
@@ -885,6 +1324,8 @@ export class Nar2 {
885
1324
  soloMMG = false,
886
1325
  asl = "281", // messina,
887
1326
  azienda = "ME", //messina
1327
+ aggiungiDatiRapporti = false,
1328
+ parallelsRapporti = 10,
888
1329
  } = config;
889
1330
  let getParams = {
890
1331
  "tipo_rapporto": "Medico_base",
@@ -917,6 +1358,24 @@ export class Nar2 {
917
1358
  if (nascondiCessati)
918
1359
  medici = medici.filter(m => m && (m.fine_rapporto === null || typeof m.fine_rapporto === "undefined"));
919
1360
 
1361
+ if (aggiungiDatiRapporti) {
1362
+ const concorrenza = Math.max(1, parallelsRapporti);
1363
+ for (let i = 0; i < medici.length; i += concorrenza) {
1364
+ const blocco = medici.slice(i, i + concorrenza);
1365
+ await Promise.all(blocco.map(async (m) => {
1366
+ // Una sola chiamata medici/{id} fornisce sia rapporti che indirizzi
1367
+ const datiMedico = m.pf_id ? await this.getMedicoFromId(m.pf_id, azienda) : null;
1368
+ if (datiMedico && datiMedico.ok) {
1369
+ m.rapporti = this.#estraiRapportiDaDatiMedico(datiMedico.data);
1370
+ m.indirizzi = this.#estraiIndirizziDaDatiMedico(datiMedico.data);
1371
+ } else {
1372
+ m.rapporti = [];
1373
+ m.indirizzi = [];
1374
+ }
1375
+ }));
1376
+ }
1377
+ }
1378
+
920
1379
  return {ok: true, data: medici};
921
1380
  }
922
1381
  return data;
@@ -929,6 +1388,7 @@ export class Nar2 {
929
1388
  assistito = new Assistito();
930
1389
  let datiAssistito = null;
931
1390
  let datiIdAssistito;
1391
+ let lastException = null;
932
1392
  const retry = 3;
933
1393
  for (let i = 0; i < retry; i++) {
934
1394
  try {
@@ -956,6 +1416,7 @@ export class Nar2 {
956
1416
  }
957
1417
  }
958
1418
  } catch (e) {
1419
+ lastException = e;
959
1420
  console.log("[getDatiAssistitoNar2FromCf] Eccezione durante recupero Nar2:", e.message);
960
1421
  }
961
1422
  if (datiAssistito && datiAssistito.ok) break;
@@ -1012,9 +1473,19 @@ export class Nar2 {
1012
1473
  };
1013
1474
  } else {
1014
1475
  console.log(`[getDatiAssistitoNar2FromCf] Nar2 fallito dopo ${retry} tentativi per CF: ${codiceFiscale?.substring(0, 6)}***`);
1015
- assistito.erroreNar2 = "Nessun assistito trovato con il codice fiscale fornito";
1476
+ // Determina il motivo più specifico possibile del fallimento (additivo, non cambia lo shape)
1477
+ const numRisultati = (!fallback && datiIdAssistito?.ok && Array.isArray(datiIdAssistito.data)) ? datiIdAssistito.data.length : null;
1478
+ const errMessage = datiAssistito?.error || datiIdAssistito?.error ||
1479
+ (numRisultati !== null && numRisultati !== 1 ? `La ricerca per codice fiscale su NAR2 ha restituito ${numRisultati} risultati` : null) ||
1480
+ (lastException ? `Eccezione durante il recupero da NAR2: ${lastException.message}` : null) ||
1481
+ "Nessun assistito trovato con il codice fiscale fornito";
1482
+ assistito.erroreNar2 = errMessage;
1016
1483
  assistito.okNar2 = false;
1017
- return {ok: false, data: null, fullData: datiIdAssistito};
1484
+ return {
1485
+ ok: false, data: null, fullData: datiIdAssistito,
1486
+ error: errMessage,
1487
+ errorDetail: datiAssistito?.errorDetail || datiIdAssistito?.errorDetail || null
1488
+ };
1018
1489
  }
1019
1490
  }
1020
1491
 
@@ -1040,7 +1511,7 @@ export class Nar2 {
1040
1511
  * @param {Object} [config.replaceFromUrl] - Sostituzioni nei placeholder URL.
1041
1512
  * @param {Object} [config.getParams] - Eventuali query params.
1042
1513
  * @param {string} [config.method="post"] - Metodo HTTP (es. "post", "put"). `aggiornaSceltaMedico` richiede PUT.
1043
- * @returns {Promise<{ok:boolean, data:any, fullResponse:Object|null}>}
1514
+ * @returns {Promise<{ok:boolean, data:any, fullResponse:Object|null, error:string|null, errorDetail:Object|null}>}
1044
1515
  */
1045
1516
  async #postDataToUrl(url, body, config = {}) {
1046
1517
  const {replaceFromUrl = null, getParams = null, method = "post"} = config;
@@ -1057,7 +1528,8 @@ export class Nar2 {
1057
1528
  finalUrl = finalUrl.replace(`{${key}}`, (value === null || typeof value === "undefined") ? "null" : value.toString());
1058
1529
  }
1059
1530
 
1060
- let out = {ok: false, data: null, fullResponse: null};
1531
+ let out = {ok: false, data: null, fullResponse: null, error: null, errorDetail: null};
1532
+ let lastError = null;
1061
1533
  for (let i = 0; i < this._maxRetry && !out.ok; i++) {
1062
1534
  try {
1063
1535
  await this.getToken();
@@ -1072,15 +1544,33 @@ export class Nar2 {
1072
1544
  });
1073
1545
  if (!response?.data?.status || response.data.status.toString() !== "true" ||
1074
1546
  /token is (invalid|expired)/i.test(response.data.status.toString())) {
1547
+ // NAR2 risponde 200 con status != true: errore business (o token scaduto)
1548
+ lastError = {
1549
+ type: "nar2",
1550
+ statusCode: response.status ?? null,
1551
+ message: Nar2.#extractErrorMessage(response?.data) || `Risposta NAR2 con status='${response?.data?.status ?? "assente"}'`,
1552
+ response: response?.data ?? null
1553
+ };
1075
1554
  await this.getToken({newToken: true});
1076
1555
  } else {
1077
- out = {ok: true, data: response.data.result, fullResponse: response.data};
1556
+ out = {ok: true, data: response.data.result, fullResponse: response.data, error: null, errorDetail: null};
1078
1557
  }
1079
1558
  } catch (e) {
1080
- console.log(`[#postDataToUrl] Errore ${httpMethod.toUpperCase()} ${finalUrl}: ${e.message}`);
1559
+ lastError = {
1560
+ type: "http",
1561
+ statusCode: e.response?.status ?? null,
1562
+ message: Nar2.#extractErrorMessage(e.response?.data) || e.message,
1563
+ response: e.response?.data ?? null
1564
+ };
1565
+ console.log(`[#postDataToUrl] Errore ${httpMethod.toUpperCase()} ${finalUrl}: ${lastError.message}`);
1081
1566
  await this.getToken({newToken: true});
1082
1567
  }
1083
1568
  }
1569
+ if (!out.ok && lastError) {
1570
+ out.error = lastError.message;
1571
+ out.errorDetail = {...lastError, method: httpMethod.toUpperCase(), url: finalUrl, tentativi: this._maxRetry};
1572
+ out.fullResponse = lastError.response;
1573
+ }
1084
1574
  return out;
1085
1575
  }
1086
1576
 
@@ -1092,8 +1582,9 @@ export class Nar2 {
1092
1582
  replaceFromUrl = null,
1093
1583
  rawResponse = false, // se true, accetta risposte non incapsulate in {status, result}
1094
1584
  } = config;
1095
- let out = {ok: false, data: null};
1585
+ let out = {ok: false, data: null, error: null, errorDetail: null};
1096
1586
  let ok = false;
1587
+ let lastError = null;
1097
1588
 
1098
1589
  // Build URL with get parameters if provided
1099
1590
  let finalUrl = url;
@@ -1132,32 +1623,196 @@ export class Nar2 {
1132
1623
  // se contiene un messaggio "token is invalid" rinnovo
1133
1624
  const asStr = typeof response?.data === "string" ? response.data : JSON.stringify(response?.data ?? "");
1134
1625
  if (/token is (invalid|expired)/i.test(asStr)) {
1626
+ lastError = {
1627
+ type: "nar2",
1628
+ statusCode: response.status ?? null,
1629
+ message: "Token NAR2 non valido o scaduto",
1630
+ response: response?.data ?? null
1631
+ };
1135
1632
  await this.getToken({newToken: true});
1136
1633
  } else {
1137
1634
  ok = true;
1138
- out = {ok: true, data: response.data};
1635
+ out = {ok: true, data: response.data, error: null, errorDetail: null};
1139
1636
  }
1140
1637
  } else if (!response?.data?.status || response.data.status.toString() !== "true" ||
1141
1638
  /token is (invalid|expired)/i.test(response.data.status.toString())) {
1639
+ // NAR2 risponde 200 con status != true: errore business (o token scaduto)
1640
+ lastError = {
1641
+ type: "nar2",
1642
+ statusCode: response.status ?? null,
1643
+ message: Nar2.#extractErrorMessage(response?.data) || `Risposta NAR2 con status='${response?.data?.status ?? "assente"}'`,
1644
+ response: response?.data ?? null
1645
+ };
1142
1646
  await this.getToken({newToken: true});
1143
1647
  } else {
1144
1648
  ok = true;
1145
- out = {ok: true, data: response.data.result};
1649
+ out = {ok: true, data: response.data.result, error: null, errorDetail: null};
1146
1650
  }
1147
1651
  } catch (e) {
1652
+ lastError = {
1653
+ type: "http",
1654
+ statusCode: e.response?.status ?? null,
1655
+ message: Nar2.#extractErrorMessage(e.response?.data) || e.message,
1656
+ response: e.response?.data ?? null
1657
+ };
1148
1658
  await this.getToken({newToken: true});
1149
1659
  }
1150
1660
  }
1151
1661
 
1662
+ if (!out.ok && lastError) {
1663
+ out.error = lastError.message;
1664
+ out.errorDetail = {...lastError, method: "GET", url: finalUrl, tentativi: this._maxRetry};
1665
+ }
1152
1666
  return out;
1153
1667
  }
1154
1668
 
1669
+ /**
1670
+ * Estrae un messaggio d'errore leggibile da un body di risposta NAR2/HTTP.
1671
+ * Accetta stringhe (troncate a 500 char) o oggetti con i campi comuni
1672
+ * message/error/msg/errore/result.
1673
+ *
1674
+ * @param {any} data - Body della risposta.
1675
+ * @returns {string|null} Messaggio estratto, o null se non determinabile.
1676
+ */
1677
+ static #extractErrorMessage(data) {
1678
+ if (data === null || typeof data === "undefined") return null;
1679
+ if (typeof data === "string") {
1680
+ const s = data.trim();
1681
+ return s.length === 0 ? null : (s.length > 500 ? `${s.slice(0, 500)}…` : s);
1682
+ }
1683
+ if (typeof data !== "object") return String(data);
1684
+ const candidate = data.message || data.error || data.msg || data.errore ||
1685
+ (typeof data.result === "string" ? data.result : null);
1686
+ if (candidate) return typeof candidate === "string" ? candidate : JSON.stringify(candidate);
1687
+ if (data.status && data.status.toString() !== "true") return `status='${data.status}'`;
1688
+ return null;
1689
+ }
1690
+
1155
1691
  async getAssistitoFromId(id) {
1156
1692
  return await this.#getDataFromUrlIdOrParams(Nar2.GET_ASSISTITO_NAR_FROM_ID, {urlId: id});
1157
1693
  }
1158
1694
 
1159
- async getMedicoFromId(id) {
1160
- return await this.#getDataFromUrlIdOrParams(Nar2.GET_DATI_MEDICO_FROM_ID, {urlId: id});
1695
+ async getMedicoFromId(id, azienda = null) {
1696
+ const config = {urlId: id};
1697
+ if (azienda) config.getParams = {azienda};
1698
+ return await this.#getDataFromUrlIdOrParams(Nar2.GET_DATI_MEDICO_FROM_ID, config);
1699
+ }
1700
+
1701
+ /**
1702
+ * Estrae l'elenco normalizzato dei rapporti individuali dai dati grezzi di un medico
1703
+ * (risultato di `getMedicoFromId` / endpoint `medici/{id}`).
1704
+ *
1705
+ * Include tutti i rapporti, anche quelli non attivi. Un rapporto è considerato attivo
1706
+ * solo se non ha data di fine (`ri_dt_disable` nullo).
1707
+ *
1708
+ * @param {Object} datiMedico - Oggetto restituito da `getMedicoFromId(...).data`.
1709
+ * @return {Array<Object>} Elenco rapporti normalizzati.
1710
+ */
1711
+ #estraiRapportiDaDatiMedico(datiMedico) {
1712
+ const indirizziMedico = Array.isArray(datiMedico?.indirizzi) ? datiMedico.indirizzi : [];
1713
+ const rapporti = Array.isArray(datiMedico?.rapporto_individuale) ? datiMedico.rapporto_individuale : [];
1714
+ const fmt = (d) => d ? moment(d, "YYYY-MM-DD HH:mm:ss").format("DD/MM/YYYY") : null;
1715
+
1716
+ return rapporti.map(r => {
1717
+ let categoria = null;
1718
+ if (r.ri_categoria === Nar2.CAT_MMG) categoria = "mmg";
1719
+ else if (r.ri_categoria === Nar2.CAT_PEDIATRI) categoria = "pls";
1720
+
1721
+ const telefoni = [r.ri_telefono1, r.ri_telefono2, r.ri_telefono3]
1722
+ .map(t => (t ?? "").toString().trim())
1723
+ .filter(t => t !== "");
1724
+
1725
+ // Il domicilio (indirizzo studio) è collegato all'indirizzo del medico tramite il progressivo.
1726
+ // Quando NAR non fornisce il link, il domicilio resta null.
1727
+ const progr = r.indirizzo?.rd_progr ?? null;
1728
+ const ind = progr ? (indirizziMedico.find(i => i.pr_progr === progr) ?? null) : null;
1729
+ const comune = ind?.comune ?? null;
1730
+ const domicilio = ind ? {
1731
+ comune: comune?.cm_desc ?? null,
1732
+ provincia: comune?.cm_prov ?? null,
1733
+ cap: ind.pr_cap ?? comune?.cm_ccap ?? null,
1734
+ indirizzo: ind.pr_indi ?? null,
1735
+ numeroCivico: ind.pr_nciv ?? null,
1736
+ } : null;
1737
+
1738
+ return {
1739
+ id: r.ri_id ?? null,
1740
+ categoria,
1741
+ tipoRapporto: r.ri_rapporto ?? r.tipo_rapporto?.ra_cod ?? null,
1742
+ descrizioneRapporto: r.tipo_rapporto?.ra_desc ?? null,
1743
+ email: r.ri_email ?? null,
1744
+ telefoni,
1745
+ dataInizio: fmt(r.ri_dt_enable),
1746
+ dataFine: fmt(r.ri_dt_disable),
1747
+ attivo: !r.ri_dt_disable,
1748
+ domicilio,
1749
+ };
1750
+ });
1751
+ }
1752
+
1753
+ /**
1754
+ * Recupera l'elenco normalizzato dei rapporti individuali di un medico dato il suo id NAR2.
1755
+ *
1756
+ * @param {string|number} id - Id persona del medico (`pf_id`).
1757
+ * @param {string} [azienda="ME"] - Codice azienda.
1758
+ * @return {Promise<Array<Object>>} Elenco rapporti normalizzati (vuoto in caso di errore).
1759
+ */
1760
+ async getRapportiMedicoFromId(id, azienda = "ME") {
1761
+ const datiMedico = await this.getMedicoFromId(id, azienda);
1762
+ if (datiMedico && datiMedico.ok)
1763
+ return this.#estraiRapportiDaDatiMedico(datiMedico.data);
1764
+ return [];
1765
+ }
1766
+
1767
+ /**
1768
+ * Estrae l'elenco normalizzato degli indirizzi del medico dai dati grezzi
1769
+ * (risultato di `getMedicoFromId` / endpoint `medici/{id}`).
1770
+ *
1771
+ * Gli indirizzi sono legati al medico (non al rapporto). Corrispondono alla scheda
1772
+ * "Indirizzi" del portale NAR2 (`/operatore/medici/{id}/dati/indirizzi`).
1773
+ *
1774
+ * @param {Object} datiMedico - Oggetto restituito da `getMedicoFromId(...).data`.
1775
+ * @return {Array<Object>} Elenco indirizzi normalizzati.
1776
+ */
1777
+ #estraiIndirizziDaDatiMedico(datiMedico) {
1778
+ const indirizzi = Array.isArray(datiMedico?.indirizzi) ? datiMedico.indirizzi : [];
1779
+ const fmt = (d) => d ? moment(d, "YYYY-MM-DD HH:mm:ss").format("DD/MM/YYYY") : null;
1780
+
1781
+ return indirizzi.map(ind => {
1782
+ const comune = ind?.comune ?? null;
1783
+ const telefoni = [ind.pr_telefono1, ind.pr_telefono2, ind.pr_telefono3]
1784
+ .map(t => (t ?? "").toString().trim())
1785
+ .filter(t => t !== "");
1786
+
1787
+ return {
1788
+ progressivo: ind.pr_progr ?? null,
1789
+ descrizione: ind.pr_desc ?? null,
1790
+ comune: comune?.cm_desc ?? null,
1791
+ provincia: comune?.cm_prov ?? null,
1792
+ cap: ind.pr_cap ?? comune?.cm_ccap ?? null,
1793
+ indirizzo: ind.pr_indi ?? null,
1794
+ numeroCivico: ind.pr_nciv ?? null,
1795
+ dataAttivazione: fmt(ind.pr_dt_enable),
1796
+ dataDisattivazione: fmt(ind.pr_dt_disable),
1797
+ residenza: ind.pr_residenza === "S",
1798
+ telefoni,
1799
+ email: ind.pr_email ?? null,
1800
+ };
1801
+ });
1802
+ }
1803
+
1804
+ /**
1805
+ * Recupera l'elenco normalizzato degli indirizzi di un medico dato il suo id NAR2.
1806
+ *
1807
+ * @param {string|number} id - Id persona del medico (`pf_id`).
1808
+ * @param {string} [azienda="ME"] - Codice azienda.
1809
+ * @return {Promise<Array<Object>>} Elenco indirizzi normalizzati (vuoto in caso di errore).
1810
+ */
1811
+ async getIndirizziMedicoFromId(id, azienda = "ME") {
1812
+ const datiMedico = await this.getMedicoFromId(id, azienda);
1813
+ if (datiMedico && datiMedico.ok)
1814
+ return this.#estraiIndirizziDaDatiMedico(datiMedico.data);
1815
+ return [];
1161
1816
  }
1162
1817
 
1163
1818
  async getNumAssistitiMedico(id) {
@@ -1331,7 +1986,7 @@ export class Nar2 {
1331
1986
 
1332
1987
  const datiAssistito = await this.getDatiAssistitoNar2FromCf(codFiscale);
1333
1988
  if (!datiAssistito || !datiAssistito.ok) {
1334
- return {ok: false, dryRun, payload: null, response: null, error: "Assistito non trovato su NAR2"};
1989
+ return {ok: false, dryRun, payload: null, response: null, error: datiAssistito?.error || "Assistito non trovato su NAR2", errorDetail: datiAssistito?.errorDetail ?? null};
1335
1990
  }
1336
1991
  const full = datiAssistito.fullData.data;
1337
1992
  const storico = Array.isArray(full.storico_medici) ? full.storico_medici : [];
@@ -1394,7 +2049,8 @@ export class Nar2 {
1394
2049
  payload,
1395
2050
  response: result.fullResponse,
1396
2051
  data: result.data,
1397
- error: result.ok ? undefined : "Errore durante la PUT /pazienti/aggiornaSceltaMedico"
2052
+ error: result.ok ? undefined : (result.error || "Errore durante la PUT /pazienti/aggiornaSceltaMedico"),
2053
+ errorDetail: result.ok ? undefined : (result.errorDetail ?? null)
1398
2054
  };
1399
2055
  }
1400
2056
 
@@ -1413,10 +2069,11 @@ export class Nar2 {
1413
2069
  let {pzId = null} = config;
1414
2070
  if (pzId == null) {
1415
2071
  const dati = await this.getDatiAssistitoNar2FromCf(codFiscale);
1416
- if (!dati || !dati.ok) return {ok: false, pdf: null, tipo};
2072
+ if (!dati || !dati.ok) return {ok: false, pdf: null, tipo, error: dati?.error || "Assistito non trovato su NAR2", errorDetail: dati?.errorDetail ?? null};
1417
2073
  pzId = dati.fullData.data.pz_id;
1418
2074
  }
1419
2075
  const url = Nar2.STAMPA_DOCUMENTO_URL.replace("{tipo}", tipo);
2076
+ let lastError = null;
1420
2077
  for (let i = 0; i < this._maxRetry; i++) {
1421
2078
  try {
1422
2079
  await this.getToken();
@@ -1429,13 +2086,15 @@ export class Nar2 {
1429
2086
  if (resp.status === 200 && /pdf/i.test(ct)) {
1430
2087
  return {ok: true, pdf: Buffer.from(resp.data), tipo};
1431
2088
  }
2089
+ lastError = {type: "nar2", statusCode: resp.status ?? null, message: `Risposta non PDF (content-type: ${ct || "assente"})`, response: null};
1432
2090
  await this.getToken({newToken: true});
1433
2091
  } catch (e) {
2092
+ lastError = {type: "http", statusCode: e.response?.status ?? null, message: e.message, response: null};
1434
2093
  console.log(`[getDocumentoPdf] Errore stampe/${tipo}: ${e.message}`);
1435
2094
  await this.getToken({newToken: true});
1436
2095
  }
1437
2096
  }
1438
- return {ok: false, pdf: null, tipo};
2097
+ return {ok: false, pdf: null, tipo, error: lastError?.message || null, errorDetail: lastError ? {...lastError, method: "GET", url, tentativi: this._maxRetry} : null};
1439
2098
  }
1440
2099
 
1441
2100
  /**
@@ -1459,7 +2118,7 @@ export class Nar2 {
1459
2118
  } = config;
1460
2119
 
1461
2120
  const dati = await this.getDatiAssistitoNar2FromCf(codFiscale);
1462
- if (!dati || !dati.ok) return {ok: false, unico: null, documenti: [], error: "Assistito non trovato su NAR2"};
2121
+ if (!dati || !dati.ok) return {ok: false, unico: null, documenti: [], error: dati?.error || "Assistito non trovato su NAR2", errorDetail: dati?.errorDetail ?? null};
1463
2122
  const pzId = dati.fullData.data.pz_id;
1464
2123
 
1465
2124
  const risultati = [];
@@ -1497,11 +2156,16 @@ export class Nar2 {
1497
2156
  }
1498
2157
  }
1499
2158
 
2159
+ if (!ok) {
2160
+ const primoErrore = risultati.find((r) => !r.ok && r.error);
2161
+ return {ok, unico, documenti: risultati, error: primoErrore?.error || "Nessun documento disponibile su NAR2", errorDetail: primoErrore?.errorDetail ?? null};
2162
+ }
1500
2163
  return {ok, unico, documenti: risultati};
1501
2164
  }
1502
2165
 
1503
2166
  async getDatiAssistitoFromCfSuSogeiNew(cf, assistito = null, fallback = false) {
1504
2167
  let ok = false;
2168
+ let risultato = null; // valore di ritorno del ramo di successo (fix: prima non veniva mai ritornato)
1505
2169
  const nullArray = (data) => {
1506
2170
  return Array.isArray(data) && data.length === 0 ? "" : data;
1507
2171
  };
@@ -1583,7 +2247,7 @@ export class Nar2 {
1583
2247
  assistito.setTs(DATI.DATA_DECESSO, deceduto ? nullArray(out.data.data.p801dataDecesso) : null);
1584
2248
  assistito.okTs = true;
1585
2249
  assistito.fullDataTs = out.data;
1586
- out = {
2250
+ risultato = {
1587
2251
  ok: true,
1588
2252
  fullData: out.data,
1589
2253
  data: assistito
@@ -1602,6 +2266,7 @@ export class Nar2 {
1602
2266
  data: assistito
1603
2267
  };
1604
2268
  }
2269
+ return risultato;
1605
2270
  }
1606
2271
 
1607
2272