hvp-shared 13.18.0 → 13.27.0

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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Commission Reconciliation — categories & classification data (hvp-backend#434)
3
+ *
4
+ * Cross-references what QVET SOLD against what was COMMISSIONED, for 4 categories.
5
+ * Detection is by the SALE LINE's section/family (catalog_items is incomplete).
6
+ *
7
+ * @see resources/research/20260609-qvet-section-to-commission-mapping.md
8
+ */
9
+ /** The 4 commissionable categories the reconciliation tracks. */
10
+ export declare enum ReconciliationCategory {
11
+ consulta = "consulta",
12
+ vacuna = "vacuna",
13
+ cirugia = "cirugia",
14
+ emergencia = "emergencia"
15
+ }
16
+ export declare const RECONCILIATION_CATEGORY_LABELS: Record<ReconciliationCategory, string>;
17
+ /** QVET section that holds clinical services. */
18
+ export declare const SERVICIOS_MEDICOS_SECTION = "SERVICIOS MEDICOS";
19
+ /** Surgical families within SERVICIOS MEDICOS (any of these → Cirugía). */
20
+ export declare const SURGERY_FAMILIES: readonly string[];
21
+ /**
22
+ * Maps a stored `commissionAllocations.services[].serviceName` to its category.
23
+ * Service names not listed here are outside the reconciliation scope.
24
+ */
25
+ export declare const COMMISSION_SERVICE_TO_CATEGORY: Record<string, ReconciliationCategory>;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * Commission Reconciliation — categories & classification data (hvp-backend#434)
4
+ *
5
+ * Cross-references what QVET SOLD against what was COMMISSIONED, for 4 categories.
6
+ * Detection is by the SALE LINE's section/family (catalog_items is incomplete).
7
+ *
8
+ * @see resources/research/20260609-qvet-section-to-commission-mapping.md
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.COMMISSION_SERVICE_TO_CATEGORY = exports.SURGERY_FAMILIES = exports.SERVICIOS_MEDICOS_SECTION = exports.RECONCILIATION_CATEGORY_LABELS = exports.ReconciliationCategory = void 0;
12
+ // ─── Category ──────────────────────────────────────────────────────────────
13
+ /** The 4 commissionable categories the reconciliation tracks. */
14
+ var ReconciliationCategory;
15
+ (function (ReconciliationCategory) {
16
+ ReconciliationCategory["consulta"] = "consulta";
17
+ ReconciliationCategory["vacuna"] = "vacuna";
18
+ ReconciliationCategory["cirugia"] = "cirugia";
19
+ ReconciliationCategory["emergencia"] = "emergencia";
20
+ })(ReconciliationCategory || (exports.ReconciliationCategory = ReconciliationCategory = {}));
21
+ exports.RECONCILIATION_CATEGORY_LABELS = {
22
+ [ReconciliationCategory.consulta]: "Consulta",
23
+ [ReconciliationCategory.vacuna]: "Vacuna",
24
+ [ReconciliationCategory.cirugia]: "Cirugía",
25
+ [ReconciliationCategory.emergencia]: "Emergencia",
26
+ };
27
+ // ─── QVET sale classification (by section/family of the sale line) ───────────
28
+ /** QVET section that holds clinical services. */
29
+ exports.SERVICIOS_MEDICOS_SECTION = "SERVICIOS MEDICOS";
30
+ /** Surgical families within SERVICIOS MEDICOS (any of these → Cirugía). */
31
+ exports.SURGERY_FAMILIES = [
32
+ "CIRUGIA DE TEJIDOS BLANDOS",
33
+ "ASISTENCIA QUIRURGICA",
34
+ "OFTALMOLOGIA",
35
+ "ORTOPEDIA",
36
+ ];
37
+ // ─── Commission service → category ───────────────────────────────────────────
38
+ /**
39
+ * Maps a stored `commissionAllocations.services[].serviceName` to its category.
40
+ * Service names not listed here are outside the reconciliation scope.
41
+ */
42
+ exports.COMMISSION_SERVICE_TO_CATEGORY = {
43
+ // Consulta (cualquier tipo)
44
+ Consulta: ReconciliationCategory.consulta,
45
+ Revisión: ReconciliationCategory.consulta,
46
+ Especialista: ReconciliationCategory.consulta,
47
+ "Revisión especialista": ReconciliationCategory.consulta,
48
+ "Consulta no convencionales": ReconciliationCategory.consulta,
49
+ // Vacuna
50
+ Vacuna: ReconciliationCategory.vacuna,
51
+ // Cirugía (cualquier tipo, incluye roles quirúrgicos)
52
+ Cirugía: ReconciliationCategory.cirugia,
53
+ Anestesista: ReconciliationCategory.cirugia,
54
+ "Primer ayudante": ReconciliationCategory.cirugia,
55
+ // Emergencia
56
+ Emergencia: ReconciliationCategory.emergencia,
57
+ };
@@ -166,6 +166,79 @@ export declare const SCHEDULER_WITH_DATE_REGEX: RegExp;
166
166
  * date.
167
167
  */
168
168
  export declare const SCHEDULER_DATE_FALLBACK_REGEX: RegExp;
169
+ /**
170
+ * Version tag stored in `extendedProperties.private.descriptionFormat` on
171
+ * events created with the labeled format. Lets analytics / future migration
172
+ * scripts distinguish labeled from positional events.
173
+ */
174
+ export declare const LABELED_DESCRIPTION_FORMAT_VERSION = "labeled-v1";
175
+ /**
176
+ * Per-field spec for the labeled description format.
177
+ *
178
+ * Each field has:
179
+ * - `label`: canonical label written when building (e.g., "Tutor").
180
+ * - `regex`: extraction regex with capturing groups. Group 1 = value.
181
+ * For `tutor`, group 2 captures the optional QVET id digits.
182
+ *
183
+ * Order-independent: labels anchor each field, so the format tolerates
184
+ * reordering or manual edits as long as the labels are present.
185
+ *
186
+ * Tolerates: `Tutor:`, `tutor :`, `TUTOR: `, trailing whitespace.
187
+ */
188
+ export declare const LABELED_DESCRIPTION_FIELDS: {
189
+ readonly tutor: {
190
+ readonly label: "Tutor";
191
+ readonly regex: RegExp;
192
+ };
193
+ readonly phones: {
194
+ readonly label: "Teléfonos";
195
+ readonly regex: RegExp;
196
+ };
197
+ readonly pet: {
198
+ readonly label: "Mascota";
199
+ readonly regex: RegExp;
200
+ };
201
+ readonly breed: {
202
+ readonly label: "Raza";
203
+ readonly regex: RegExp;
204
+ };
205
+ readonly age: {
206
+ readonly label: "Edad";
207
+ readonly regex: RegExp;
208
+ };
209
+ readonly motivo: {
210
+ readonly label: "Motivo";
211
+ readonly regex: RegExp;
212
+ };
213
+ readonly discount: {
214
+ readonly label: "Descuento";
215
+ readonly regex: RegExp;
216
+ };
217
+ };
218
+ export type LabeledDescriptionField = keyof typeof LABELED_DESCRIPTION_FIELDS;
219
+ /**
220
+ * Returns "labeled" if any known field label is present at the start of a line
221
+ * in the description; otherwise "positional" (legacy format).
222
+ */
223
+ export declare function detectDescriptionFormat(description: string): "labeled" | "positional";
224
+ /**
225
+ * Extract a single-line labeled field. Returns trimmed value or null.
226
+ *
227
+ * For `motivo` (which may span multiple lines), use {@link extractMotivoLabeled}.
228
+ * For the tutor's QVET id, use {@link extractTutorQvetIdLabeled}.
229
+ */
230
+ export declare function extractLabeledField(description: string, field: Exclude<LabeledDescriptionField, "motivo">): string | null;
231
+ /**
232
+ * Extract the QVET client id from the `Tutor: NAME (Q12345)` line. Returns the
233
+ * numeric portion as a string (e.g., "12345") or null.
234
+ */
235
+ export declare function extractTutorQvetIdLabeled(description: string): string | null;
236
+ /**
237
+ * Extract the motivo block which may span multiple lines. Starts at the
238
+ * `Motivo:` line and collects subsequent lines until another labeled field,
239
+ * an AGENDÓ/REAGENDÓ line, the HVP marker, or end of description.
240
+ */
241
+ export declare function extractMotivoLabeled(description: string): string | null;
169
242
  /**
170
243
  * Captures pet age expressions in Spanish from descriptions.
171
244
  *
@@ -197,4 +270,11 @@ export declare const HVP_EVENT_PROP_KEYS: {
197
270
  readonly isPreferredVet: "isPreferredVet";
198
271
  readonly source: "source";
199
272
  readonly standardVersion: "standardVersion";
273
+ /**
274
+ * Marks the description-builder format version used at create/update time
275
+ * (e.g., "labeled-v1"). Informational only — the parser detects format from
276
+ * content, not from this prop. Useful for analytics ("% of events on labeled
277
+ * format") and future migration scripts.
278
+ */
279
+ readonly descriptionFormat: "descriptionFormat";
200
280
  };
@@ -13,7 +13,11 @@
13
13
  * runs remain reproducible.
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- exports.HVP_EVENT_PROP_KEYS = exports.KNOWN_BREEDS = exports.PET_AGE_REGEX = exports.SCHEDULER_DATE_FALLBACK_REGEX = exports.SCHEDULER_WITH_DATE_REGEX = exports.SCHEDULER_REGEX = exports.PHONE_REGEX = exports.QVET_ID_REGEX = exports.CANCELLED_TITLE_REGEX = exports.REMINDER_TITLE_REGEX = exports.PREFERRED_VET_REGEX = exports.EXCLUDED_TITLE_PREFIXES = exports.SERVICE_CODE_SYNONYMS = exports.CANONICAL_SERVICE_CODES = exports.EXCLUDED_COLOR_IDS = exports.NO_SHOW_COLOR_ID = exports.CANCELLED_COLOR_ID = exports.COLOR_ID_TO_BRANCH = exports.BRANCH_COLOR_IDS = exports.STANDARD_V1 = exports.STANDARD_V3 = exports.STANDARD_V2 = void 0;
16
+ exports.HVP_EVENT_PROP_KEYS = exports.KNOWN_BREEDS = exports.PET_AGE_REGEX = exports.LABELED_DESCRIPTION_FIELDS = exports.LABELED_DESCRIPTION_FORMAT_VERSION = exports.SCHEDULER_DATE_FALLBACK_REGEX = exports.SCHEDULER_WITH_DATE_REGEX = exports.SCHEDULER_REGEX = exports.PHONE_REGEX = exports.QVET_ID_REGEX = exports.CANCELLED_TITLE_REGEX = exports.REMINDER_TITLE_REGEX = exports.PREFERRED_VET_REGEX = exports.EXCLUDED_TITLE_PREFIXES = exports.SERVICE_CODE_SYNONYMS = exports.CANONICAL_SERVICE_CODES = exports.EXCLUDED_COLOR_IDS = exports.NO_SHOW_COLOR_ID = exports.CANCELLED_COLOR_ID = exports.COLOR_ID_TO_BRANCH = exports.BRANCH_COLOR_IDS = exports.STANDARD_V1 = exports.STANDARD_V3 = exports.STANDARD_V2 = void 0;
17
+ exports.detectDescriptionFormat = detectDescriptionFormat;
18
+ exports.extractLabeledField = extractLabeledField;
19
+ exports.extractTutorQvetIdLabeled = extractTutorQvetIdLabeled;
20
+ exports.extractMotivoLabeled = extractMotivoLabeled;
17
21
  // ─── Standard v1 ─────────────────────────────────────────────────────────────
18
22
  /**
19
23
  * Multi-factor appointment standard v2.
@@ -147,31 +151,31 @@ exports.STANDARD_V3 = {
147
151
  id: "context",
148
152
  weight: 8,
149
153
  label: "Motivo / contexto",
150
- description: 'La descripción tiene contexto clínico relevante (motivo de la visita, síntomas, antecedentes) más allá del nombre y teléfono. Mínimo 20 caracteres tras descontar nombre, teléfono y Q{id}.',
154
+ description: 'Eventos en formato labeled: cuenta el bloque "Motivo:" (>20 caracteres). Eventos legacy posicionales: la descripción tiene contexto clínico relevante más allá del nombre y teléfono mínimo 20 caracteres tras descontar nombre, teléfono y Q{id}.',
151
155
  },
152
156
  {
153
157
  id: "owner_name",
154
158
  weight: 8,
155
159
  label: "Nombre del tutor",
156
- description: 'Nombre del cliente/tutor. Auto-pasa si hay Q{id} en la descripción (cliente linkeado a QVET) o si el teléfono matchea un cliente QVET existente. Si no, busca nombre completo del tutor en la descripción (mínimo 2 palabras, mayúsculas/minúsculas con letras).',
160
+ description: 'Nombre del cliente/tutor. Auto-pasa si hay Q{id} en la descripción (cliente linkeado a QVET) o si el teléfono matchea un cliente QVET existente. Eventos labeled: lo lee de la línea "Tutor:". Eventos legacy: lo busca en la primera línea con 2+ palabras de letras (o el patrón "NAME | PETS | Q{id}").',
157
161
  },
158
162
  {
159
163
  id: "owner_phone",
160
164
  weight: 8,
161
165
  label: "Teléfono del tutor",
162
- description: 'Teléfono del tutor en cualquier formato común (con o sin +52, con o sin separadores). Auto-pasa si hay Q{id} (sabemos el teléfono del registro QVET). Necesario para confirmar la cita y avisar de cambios.',
166
+ description: 'Teléfono del tutor en cualquier formato común (con o sin +52, con o sin separadores). Eventos labeled: lo lee de la línea "Teléfonos:". Eventos legacy: lo busca en cualquier línea de la descripción. Auto-pasa si hay Q{id} (sabemos el teléfono del registro QVET).',
163
167
  },
164
168
  {
165
169
  id: "pet_age",
166
170
  weight: 4,
167
171
  label: "Edad de la mascota",
168
- description: 'Edad anotada en la descripción: "4 meses", "1 año y 4 meses", "8 semanas", "2 años". Importante para vacunación, dosis y diagnóstico.',
172
+ description: 'Edad anotada en la descripción. Eventos labeled: línea "Edad:". Eventos legacy: detección por patrón ("4 meses", "1 año y 4 meses", "8 semanas", "2 años"). Importante para vacunación, dosis y diagnóstico.',
169
173
  },
170
174
  {
171
175
  id: "pet_breed",
172
176
  weight: 4,
173
177
  label: "Raza de la mascota",
174
- description: 'Raza detectada en la descripción contra un diccionario de razas comunes: Yorkie, Shih Tzu, Cocker Spaniel, French Bulldog, Chihuahua, Schnauzer, Golden Retriever, Labrador, Persa, Siamés, mestizo, etc. Útil para protocolos clínicos y dosificación.',
178
+ description: 'Raza detectada en la descripción. Eventos labeled: línea "Raza:". Eventos legacy: match contra un diccionario de razas comunes (Yorkie, Shih Tzu, Cocker Spaniel, French Bulldog, Chihuahua, Schnauzer, Golden Retriever, Labrador, Persa, Siamés, mestizo, etc.).',
175
179
  },
176
180
  {
177
181
  id: "discount_format",
@@ -433,6 +437,122 @@ exports.SCHEDULER_WITH_DATE_REGEX = /\b(?:AG[.:]?|AGE[.:]|AGEND[OÓ][.:]?|AGENG[
433
437
  * date.
434
438
  */
435
439
  exports.SCHEDULER_DATE_FALLBACK_REGEX = /\b(\d{1,2}[./\-]\d{1,2}(?:[./\-]\d{2,4})?|\d{1,2}[./\-](?:ENE|FEB|MAR|ABR|MAY|JUN|JUL|AGO|SEP|OCT|NOV|DIC|ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)(?:[./\-]\d{2,4})?)\s+([A-Z]{2,4})\b/i;
440
+ // ─── Labeled description format (06/2026) ───────────────────────────────────
441
+ /**
442
+ * Version tag stored in `extendedProperties.private.descriptionFormat` on
443
+ * events created with the labeled format. Lets analytics / future migration
444
+ * scripts distinguish labeled from positional events.
445
+ */
446
+ exports.LABELED_DESCRIPTION_FORMAT_VERSION = "labeled-v1";
447
+ /**
448
+ * Per-field spec for the labeled description format.
449
+ *
450
+ * Each field has:
451
+ * - `label`: canonical label written when building (e.g., "Tutor").
452
+ * - `regex`: extraction regex with capturing groups. Group 1 = value.
453
+ * For `tutor`, group 2 captures the optional QVET id digits.
454
+ *
455
+ * Order-independent: labels anchor each field, so the format tolerates
456
+ * reordering or manual edits as long as the labels are present.
457
+ *
458
+ * Tolerates: `Tutor:`, `tutor :`, `TUTOR: `, trailing whitespace.
459
+ */
460
+ exports.LABELED_DESCRIPTION_FIELDS = {
461
+ tutor: {
462
+ label: "Tutor",
463
+ regex: /^\s*tutor\s*:\s*(.+?)(?:\s*\(\s*Q(\d{4,7})\s*\))?\s*$/im,
464
+ },
465
+ phones: {
466
+ label: "Teléfonos",
467
+ regex: /^\s*tel[eé]fonos?\s*:\s*(.+?)\s*$/im,
468
+ },
469
+ pet: {
470
+ label: "Mascota",
471
+ regex: /^\s*mascota\s*:\s*(.+?)\s*$/im,
472
+ },
473
+ breed: {
474
+ label: "Raza",
475
+ regex: /^\s*raza\s*:\s*(.+?)\s*$/im,
476
+ },
477
+ age: {
478
+ label: "Edad",
479
+ regex: /^\s*edad\s*:\s*(.+?)\s*$/im,
480
+ },
481
+ motivo: {
482
+ label: "Motivo",
483
+ // Motivo is multi-line; extraction handled by extractMotivoLabeled().
484
+ // Regex below only detects presence of the label.
485
+ regex: /^\s*motivo\s*:/im,
486
+ },
487
+ discount: {
488
+ label: "Descuento",
489
+ regex: /^\s*descuento\s*:\s*(.+?)\s*$/im,
490
+ },
491
+ };
492
+ /**
493
+ * Matches a line that starts a new labeled field or the AGENDÓ/REAGENDÓ block
494
+ * or the HVP marker. Used to bound multi-line `Motivo:` capture.
495
+ */
496
+ const LABELED_BOUNDARY_REGEX = /^\s*(?:tutor|tel[eé]fonos?|mascota|raza|edad|descuento|motivo|agend|reagend|—)/i;
497
+ /**
498
+ * Returns "labeled" if any known field label is present at the start of a line
499
+ * in the description; otherwise "positional" (legacy format).
500
+ */
501
+ function detectDescriptionFormat(description) {
502
+ if (!description)
503
+ return "positional";
504
+ for (const field of Object.values(exports.LABELED_DESCRIPTION_FIELDS)) {
505
+ if (field.regex.test(description))
506
+ return "labeled";
507
+ }
508
+ return "positional";
509
+ }
510
+ /**
511
+ * Extract a single-line labeled field. Returns trimmed value or null.
512
+ *
513
+ * For `motivo` (which may span multiple lines), use {@link extractMotivoLabeled}.
514
+ * For the tutor's QVET id, use {@link extractTutorQvetIdLabeled}.
515
+ */
516
+ function extractLabeledField(description, field) {
517
+ if (!description)
518
+ return null;
519
+ const match = description.match(exports.LABELED_DESCRIPTION_FIELDS[field].regex);
520
+ const value = match?.[1]?.trim();
521
+ return value && value.length > 0 ? value : null;
522
+ }
523
+ /**
524
+ * Extract the QVET client id from the `Tutor: NAME (Q12345)` line. Returns the
525
+ * numeric portion as a string (e.g., "12345") or null.
526
+ */
527
+ function extractTutorQvetIdLabeled(description) {
528
+ if (!description)
529
+ return null;
530
+ const match = description.match(exports.LABELED_DESCRIPTION_FIELDS.tutor.regex);
531
+ const id = match?.[2]?.trim();
532
+ return id && id.length > 0 ? id : null;
533
+ }
534
+ /**
535
+ * Extract the motivo block which may span multiple lines. Starts at the
536
+ * `Motivo:` line and collects subsequent lines until another labeled field,
537
+ * an AGENDÓ/REAGENDÓ line, the HVP marker, or end of description.
538
+ */
539
+ function extractMotivoLabeled(description) {
540
+ if (!description)
541
+ return null;
542
+ const lines = description.split("\n");
543
+ const startIdx = lines.findIndex((l) => /^\s*motivo\s*:/i.test(l));
544
+ if (startIdx === -1)
545
+ return null;
546
+ const firstLine = lines[startIdx].replace(/^\s*motivo\s*:\s*/i, "");
547
+ const collected = [firstLine];
548
+ for (let i = startIdx + 1; i < lines.length; i++) {
549
+ if (LABELED_BOUNDARY_REGEX.test(lines[i]))
550
+ break;
551
+ collected.push(lines[i]);
552
+ }
553
+ const value = collected.join("\n").trim();
554
+ return value.length > 0 ? value : null;
555
+ }
436
556
  // ─── Pet age detection ───────────────────────────────────────────────────────
437
557
  /**
438
558
  * Captures pet age expressions in Spanish from descriptions.
@@ -541,4 +661,11 @@ exports.HVP_EVENT_PROP_KEYS = {
541
661
  isPreferredVet: "isPreferredVet",
542
662
  source: "source", // "hvp"
543
663
  standardVersion: "standardVersion",
664
+ /**
665
+ * Marks the description-builder format version used at create/update time
666
+ * (e.g., "labeled-v1"). Informational only — the parser detects format from
667
+ * content, not from this prop. Useful for analytics ("% of events on labeled
668
+ * format") and future migration scripts.
669
+ */
670
+ descriptionFormat: "descriptionFormat",
544
671
  };
@@ -207,6 +207,157 @@ describe("PET_AGE_REGEX", () => {
207
207
  expect("Cytopoint 30MG".match(google_calendar_constants_1.PET_AGE_REGEX)).toBeNull();
208
208
  });
209
209
  });
210
+ describe("Labeled description format (06/2026)", () => {
211
+ const labeledSample = [
212
+ "Tutor: OMAR GOMEZ (Q123456)",
213
+ "Teléfonos: 9992920692 / 9991234567",
214
+ "Mascota: MILO",
215
+ "Raza: Shih Tzu",
216
+ "Edad: 1 año",
217
+ "Motivo: Consulta de seguimiento por dermatitis",
218
+ "Descuento: 15% recordatorio",
219
+ "AGENDÓ JG 14/06/2026",
220
+ "— creada desde HVP app · [HVP]",
221
+ ].join("\n");
222
+ const positionalSample = [
223
+ "OMAR GOMEZ | MILO | Q123456",
224
+ "9992920692",
225
+ "Consulta de seguimiento por dermatitis",
226
+ "Shih Tzu, 1 año",
227
+ "15% recordatorio",
228
+ "AGENDÓ JG 14/06/2026",
229
+ "— creada desde HVP app · [HVP]",
230
+ ].join("\n");
231
+ describe("LABELED_DESCRIPTION_FORMAT_VERSION", () => {
232
+ it("is a stable string", () => {
233
+ expect(google_calendar_constants_1.LABELED_DESCRIPTION_FORMAT_VERSION).toBe("labeled-v1");
234
+ });
235
+ });
236
+ describe("LABELED_DESCRIPTION_FIELDS", () => {
237
+ it("has all expected fields with labels", () => {
238
+ const labels = Object.fromEntries(Object.entries(google_calendar_constants_1.LABELED_DESCRIPTION_FIELDS).map(([k, v]) => [k, v.label]));
239
+ expect(labels).toEqual({
240
+ tutor: "Tutor",
241
+ phones: "Teléfonos",
242
+ pet: "Mascota",
243
+ breed: "Raza",
244
+ age: "Edad",
245
+ motivo: "Motivo",
246
+ discount: "Descuento",
247
+ });
248
+ });
249
+ });
250
+ describe("detectDescriptionFormat", () => {
251
+ it("identifies a labeled description", () => {
252
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)(labeledSample)).toBe("labeled");
253
+ });
254
+ it("identifies a positional (legacy) description", () => {
255
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)(positionalSample)).toBe("positional");
256
+ });
257
+ it("treats empty / undefined as positional", () => {
258
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)("")).toBe("positional");
259
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)(undefined)).toBe("positional");
260
+ });
261
+ it("detects when only one label is present", () => {
262
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)("Tutor: NORA\n9999999999")).toBe("labeled");
263
+ });
264
+ it("is case-insensitive on the label", () => {
265
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)("tutor: NORA\n9999999999")).toBe("labeled");
266
+ expect((0, google_calendar_constants_1.detectDescriptionFormat)("TUTOR: NORA\n9999999999")).toBe("labeled");
267
+ });
268
+ });
269
+ describe("extractLabeledField", () => {
270
+ it("extracts the tutor name (without QVET id)", () => {
271
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "tutor")).toBe("OMAR GOMEZ");
272
+ });
273
+ it("extracts the tutor name when no QVET id is present", () => {
274
+ const desc = "Tutor: NORA DIRCIO\nMascota: KIRI";
275
+ expect((0, google_calendar_constants_1.extractLabeledField)(desc, "tutor")).toBe("NORA DIRCIO");
276
+ });
277
+ it("extracts phones, pet, breed, age, discount", () => {
278
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "phones")).toBe("9992920692 / 9991234567");
279
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "pet")).toBe("MILO");
280
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "breed")).toBe("Shih Tzu");
281
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "age")).toBe("1 año");
282
+ expect((0, google_calendar_constants_1.extractLabeledField)(labeledSample, "discount")).toBe("15% recordatorio");
283
+ });
284
+ it("returns null when the label is missing", () => {
285
+ const desc = "Tutor: OMAR\nMascota: MILO";
286
+ expect((0, google_calendar_constants_1.extractLabeledField)(desc, "discount")).toBeNull();
287
+ expect((0, google_calendar_constants_1.extractLabeledField)(desc, "breed")).toBeNull();
288
+ });
289
+ it("tolerates trailing whitespace and extra spaces around the colon", () => {
290
+ const desc = "Tutor : OMAR GOMEZ \nMascota:MILO";
291
+ expect((0, google_calendar_constants_1.extractLabeledField)(desc, "tutor")).toBe("OMAR GOMEZ");
292
+ expect((0, google_calendar_constants_1.extractLabeledField)(desc, "pet")).toBe("MILO");
293
+ });
294
+ it("is order-independent", () => {
295
+ const reordered = [
296
+ "Mascota: MILO",
297
+ "Tutor: OMAR GOMEZ (Q123456)",
298
+ "Edad: 1 año",
299
+ ].join("\n");
300
+ expect((0, google_calendar_constants_1.extractLabeledField)(reordered, "tutor")).toBe("OMAR GOMEZ");
301
+ expect((0, google_calendar_constants_1.extractLabeledField)(reordered, "pet")).toBe("MILO");
302
+ expect((0, google_calendar_constants_1.extractLabeledField)(reordered, "age")).toBe("1 año");
303
+ });
304
+ it("returns null for empty description", () => {
305
+ expect((0, google_calendar_constants_1.extractLabeledField)("", "tutor")).toBeNull();
306
+ });
307
+ });
308
+ describe("extractTutorQvetIdLabeled", () => {
309
+ it("extracts the QVET id from the tutor line", () => {
310
+ expect((0, google_calendar_constants_1.extractTutorQvetIdLabeled)(labeledSample)).toBe("123456");
311
+ });
312
+ it("returns null when no QVET id is present", () => {
313
+ const desc = "Tutor: NORA DIRCIO\nMascota: KIRI";
314
+ expect((0, google_calendar_constants_1.extractTutorQvetIdLabeled)(desc)).toBeNull();
315
+ });
316
+ it("tolerates extra whitespace inside the parens", () => {
317
+ const desc = "Tutor: NORA DIRCIO ( Q987654 )";
318
+ expect((0, google_calendar_constants_1.extractTutorQvetIdLabeled)(desc)).toBe("987654");
319
+ });
320
+ it("returns null when the tutor line is missing entirely", () => {
321
+ expect((0, google_calendar_constants_1.extractTutorQvetIdLabeled)("Mascota: KIRI")).toBeNull();
322
+ });
323
+ });
324
+ describe("extractMotivoLabeled", () => {
325
+ it("extracts a single-line motivo", () => {
326
+ expect((0, google_calendar_constants_1.extractMotivoLabeled)(labeledSample)).toBe("Consulta de seguimiento por dermatitis");
327
+ });
328
+ it("extracts a multi-line motivo", () => {
329
+ const desc = [
330
+ "Tutor: OMAR",
331
+ "Mascota: MILO",
332
+ "Motivo: Consulta inicial",
333
+ "Antecedentes de alergia",
334
+ "Sospecha de sarna",
335
+ "AGENDÓ JG 14/06/2026",
336
+ ].join("\n");
337
+ expect((0, google_calendar_constants_1.extractMotivoLabeled)(desc)).toBe("Consulta inicial\nAntecedentes de alergia\nSospecha de sarna");
338
+ });
339
+ it("stops at the next labeled field even when AGENDÓ is far below", () => {
340
+ const desc = [
341
+ "Tutor: OMAR",
342
+ "Motivo: Solo este motivo",
343
+ "Mascota: MILO",
344
+ "Raza: Shih Tzu",
345
+ "AGENDÓ JG 14/06/2026",
346
+ ].join("\n");
347
+ expect((0, google_calendar_constants_1.extractMotivoLabeled)(desc)).toBe("Solo este motivo");
348
+ });
349
+ it("stops at the HVP marker", () => {
350
+ const desc = [
351
+ "Motivo: Consulta",
352
+ "— creada desde HVP app · [HVP]",
353
+ ].join("\n");
354
+ expect((0, google_calendar_constants_1.extractMotivoLabeled)(desc)).toBe("Consulta");
355
+ });
356
+ it("returns null when no motivo label is present", () => {
357
+ expect((0, google_calendar_constants_1.extractMotivoLabeled)("Tutor: OMAR\nMascota: MILO")).toBeNull();
358
+ });
359
+ });
360
+ });
210
361
  describe("KNOWN_BREEDS", () => {
211
362
  it("includes common breeds observed in the discovery data", () => {
212
363
  const lower = google_calendar_constants_1.KNOWN_BREEDS.map((b) => b.toLowerCase());
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export * from './mexican-states';
5
5
  export * from './sat-catalogs';
6
+ export * from './commission-reconciliation.constants';
6
7
  export * from './collaborator.constants';
7
8
  export * from './catalog-item.constants';
8
9
  export * from './pricing.constants';
@@ -19,6 +19,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  */
20
20
  __exportStar(require("./mexican-states"), exports);
21
21
  __exportStar(require("./sat-catalogs"), exports);
22
+ __exportStar(require("./commission-reconciliation.constants"), exports);
22
23
  __exportStar(require("./collaborator.constants"), exports);
23
24
  __exportStar(require("./catalog-item.constants"), exports);
24
25
  __exportStar(require("./pricing.constants"), exports);
@@ -119,4 +119,4 @@ export declare const QVET_CATALOG: {
119
119
  /**
120
120
  * All section values as array
121
121
  */
122
- export declare const QVET_SECTIONS_LIST: ("CONSUMO INTERNO" | "EQUIPAMIENTO" | "FARMACIA" | "FARMACIA INTERNA" | "INSUMOS ESCANDALLO" | "INSUMOS MEDICOS" | "OTROS ARTICULOS" | "OTROS SERVICIOS" | "SERVICIOS EXTERNOS" | "SERVICIOS MEDICOS")[];
122
+ export declare const QVET_SECTIONS_LIST: ("SERVICIOS MEDICOS" | "CONSUMO INTERNO" | "EQUIPAMIENTO" | "FARMACIA" | "FARMACIA INTERNA" | "INSUMOS ESCANDALLO" | "INSUMOS MEDICOS" | "OTROS ARTICULOS" | "OTROS SERVICIOS" | "SERVICIOS EXTERNOS")[];
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Commission Reconciliation API Contracts (hvp-backend#434)
3
+ */
4
+ export * from './responses';
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * Commission Reconciliation API Contracts (hvp-backend#434)
19
+ */
20
+ __exportStar(require("./responses"), exports);
@@ -0,0 +1,148 @@
1
+ import { ReconciliationCategory } from '../../constants/commission-reconciliation.constants';
2
+ /**
3
+ * Commission Reconciliation API Contracts (hvp-backend#434)
4
+ *
5
+ * GET /api/commission-reconciliation/summary?startDate=&endDate=&branchId=
6
+ * Cross-references commissioned vs sold (QVET) by invoice folio, jul-2025+.
7
+ */
8
+ /**
9
+ * High-level match quality of a commission (3 states, for review):
10
+ * - `perfecto`: has ticket + category sold + seller IS the commissioner
11
+ * - `parcial`: has ticket + category sold, but seller ≠ commissioner (someone else rang up the sale)
12
+ * - `sin_match`: no corresponding sale (no factura, or the commissioned category wasn't sold)
13
+ */
14
+ export type ReconciliationMatchLevel = 'perfecto' | 'parcial' | 'sin_match';
15
+ /**
16
+ * Per-commission-service validation within a ticket. Each commissioned service is matched
17
+ * to its EXACT sale line by qvetCode (2026+, where qvetCode is populated), falling back to
18
+ * the section/family category when the code isn't available. Then the service's principal
19
+ * commissioner is compared to the seller of that line.
20
+ */
21
+ export interface ReconciliationServiceCheck {
22
+ /** The CommissionableService name (e.g. "Consulta", "Cirugía"). */
23
+ serviceName: string;
24
+ /** Principal commissioner of this service (its SIMPLE / highest-amount earner). */
25
+ principalCode: string | null;
26
+ /** How the sale line was matched: exact product (qvetCode), coarse (category), or not found. */
27
+ matchType: 'qvetCode' | 'category' | 'none';
28
+ /** Exact product matched on the factura (qvetCode match only). */
29
+ productName: string | null;
30
+ qvetCode: number | null;
31
+ /** Seller of the matched sale line. Null when not found. */
32
+ sellerCode: string | null;
33
+ /** principal == line seller (the per-commission verdict). */
34
+ ok: boolean;
35
+ }
36
+ /** Status of a commissioned ticket against QVET sales. */
37
+ export type ReconciliationTicketStatus = 'ok' | 'folio_corregido' | 'sin_venta' | 'categoria_no_vendida';
38
+ /** One commissioned ticket (factura) with its reconciliation against sales. */
39
+ export interface ReconciliationTicketRow {
40
+ /** Raw folio as entered in the commission (commissionAllocations.ticketNumber). */
41
+ ticketNumber: string;
42
+ /** Normalized folio used for the cross-reference. */
43
+ normalizedFolio: string;
44
+ /** Commission date (ISO). */
45
+ date: string;
46
+ /** Branch name (Urban/Harbor/Montejo) or id when unresolved. */
47
+ branch: string;
48
+ /** Raw commission service names on the ticket (e.g. "Consulta", "Hemograma"). */
49
+ commissionedServices: string[];
50
+ /** Tracked categories present in the commission (subset of the 4; may be empty). */
51
+ commissionedCategories: ReconciliationCategory[];
52
+ /** Categories sold on the matched factura (empty when no sale matched). */
53
+ soldCategories: ReconciliationCategory[];
54
+ /** Whether the folio matched a QVET sale (exact or auto-corrected). */
55
+ saleFound: boolean;
56
+ /**
57
+ * Nearest factura folio when the raw folio didn't match exactly.
58
+ * For `folio_corregido` it's the auto-linked folio; for `sin_venta` it's a suggestion to review.
59
+ */
60
+ suggestedFolio: string | null;
61
+ /** Commissioned categories that were NOT sold (the discrepancy). */
62
+ missingCategories: ReconciliationCategory[];
63
+ status: ReconciliationTicketStatus;
64
+ /** Commissioner codes (col_code) on the commission. */
65
+ collaboratorCodes: string[];
66
+ /** Principal commissioner (highest commission amount — the SIMPLE/main vet). Should equal the seller. */
67
+ principalCode: string | null;
68
+ /** 3-state quality of the match (see ReconciliationMatchLevel). */
69
+ matchLevel: ReconciliationMatchLevel;
70
+ /** Per-commission-service checks (exact product/qvetCode validation). */
71
+ serviceChecks: ReconciliationServiceCheck[];
72
+ /** Who created the commission (col_code), or null if unknown. */
73
+ createdByCode: string | null;
74
+ /** Resolved seller of the matched factura (null when no sale / unresolved). */
75
+ sellerCode: string | null;
76
+ /** Resolved collector (cobrador) of the matched factura's payment (null when none). */
77
+ collectorCode: string | null;
78
+ /** Whether the resolved seller equals the PRINCIPAL commissioner (null when unknown). */
79
+ sellerMatchesCommission: boolean | null;
80
+ /** Total commission amount on the ticket (rounded). */
81
+ commissionAmount: number;
82
+ /** Heuristic flag: folio looks mistyped (not `[UHM]\d{4,6}`). */
83
+ suspectFolio: boolean;
84
+ }
85
+ /** A factura that sold a category but has no commission for it (reverse/secondary gap). */
86
+ export interface ReconciliationGapRow {
87
+ invoiceNumber: string;
88
+ date: string;
89
+ branch: string;
90
+ /** Sold categories that were not commissioned. */
91
+ missingCategories: ReconciliationCategory[];
92
+ /** Resolved seller of the factura (null when unresolved). */
93
+ sellerCode: string | null;
94
+ }
95
+ /** Per-collaborator aggregate of sold (as seller) vs commissioned, by category. */
96
+ export interface ReconciliationPersonCategoryStat {
97
+ category: ReconciliationCategory;
98
+ soldAsSeller: number;
99
+ commissioned: number;
100
+ }
101
+ export interface ReconciliationPersonRow {
102
+ collaboratorCode: string;
103
+ stats: ReconciliationPersonCategoryStat[];
104
+ }
105
+ /**
106
+ * Per-person registration quality (matched tickets only). Used from two perspectives:
107
+ * - seller: tickets where this person is the recorded VENDEDOR.
108
+ * - collector: tickets where this person is the COBRADOR.
109
+ * `perfecto` = ticket where vendedor == comisionista principal; `parcial` = it doesn't.
110
+ * High `parcial` from the collector view = "quién está registrando mal el vendedor al cobrar".
111
+ */
112
+ export interface ReconciliationStaffQualityRow {
113
+ code: string;
114
+ total: number;
115
+ perfecto: number;
116
+ parcial: number;
117
+ }
118
+ export interface ReconciliationTotals {
119
+ /** Commissions in scope (with at least one tracked category). */
120
+ commissions: number;
121
+ /** Commissioned but not sold (sin_venta + categoria_no_vendida). */
122
+ commissionedNotSold: number;
123
+ /** Auto-linked via folio correction (unambiguous near match). */
124
+ folioCorrected: number;
125
+ /** Of commissionedNotSold, how many have a suspect/mistyped folio. */
126
+ suspectFolios: number;
127
+ /** Facturas that sold a category without a matching commission (secondary gap). */
128
+ soldNotCommissioned: number;
129
+ }
130
+ export interface ReconciliationSummaryResponse {
131
+ period: {
132
+ startDate: string;
133
+ endDate: string;
134
+ };
135
+ totals: ReconciliationTotals;
136
+ /** PRIMARY view: commissioned tickets with a problem (status !== 'ok'). */
137
+ commissionedNotSold: ReconciliationTicketRow[];
138
+ /** Full list of commissioned tickets in the period with their status. */
139
+ tickets: ReconciliationTicketRow[];
140
+ /** SECONDARY view: sold-but-not-commissioned facturas. */
141
+ soldNotCommissioned: ReconciliationGapRow[];
142
+ /** Per-person sold-vs-commissioned aggregates. */
143
+ byPerson: ReconciliationPersonRow[];
144
+ /** Quality from the VENDEDOR perspective (to whom the sale was wrongly assigned). */
145
+ sellerQuality: ReconciliationStaffQualityRow[];
146
+ /** Quality from the COBRADOR perspective (who assigned it wrongly when collecting). */
147
+ collectorQuality: ReconciliationStaffQualityRow[];
148
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -85,4 +85,16 @@ export interface CreateAppointmentRequest {
85
85
  * collaborators list. If omitted, the auth user's col_code is used.
86
86
  */
87
87
  schedulerColCode?: string;
88
+ /**
89
+ * Optional manual override for the description body. When present, the
90
+ * backend uses this verbatim instead of building the description from the
91
+ * structured fields above. The backend still appends the AGENDÓ/REAGENDÓ
92
+ * scheduler line and the HVP marker so attribution and provenance are not
93
+ * lost.
94
+ *
95
+ * Used when the receptionist enables "Editar texto" in the form to fine-tune
96
+ * wording before saving. On PATCH, supplying this also short-circuits the
97
+ * labeled rebuild — useful for surgical edits.
98
+ */
99
+ descriptionOverride?: string;
88
100
  }
@@ -24,3 +24,4 @@ export * from './external-study';
24
24
  export * from './pending-dashboard';
25
25
  export * from './document';
26
26
  export * from './supplier-orders';
27
+ export * from './commission-reconciliation';
@@ -40,3 +40,4 @@ __exportStar(require("./external-study"), exports);
40
40
  __exportStar(require("./pending-dashboard"), exports);
41
41
  __exportStar(require("./document"), exports);
42
42
  __exportStar(require("./supplier-orders"), exports);
43
+ __exportStar(require("./commission-reconciliation"), exports);
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export * from './utils/sync-field.helpers';
11
11
  export * from './utils/qvet-catalog.helpers';
12
12
  export * from './utils/qvet-staff.helpers';
13
13
  export * from './utils/enum.helpers';
14
+ export * from './utils/commission-reconciliation.helpers';
package/dist/index.js CHANGED
@@ -29,3 +29,4 @@ __exportStar(require("./utils/sync-field.helpers"), exports);
29
29
  __exportStar(require("./utils/qvet-catalog.helpers"), exports);
30
30
  __exportStar(require("./utils/qvet-staff.helpers"), exports);
31
31
  __exportStar(require("./utils/enum.helpers"), exports);
32
+ __exportStar(require("./utils/commission-reconciliation.helpers"), exports);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Commission Reconciliation — pure classification & key helpers (hvp-backend#434)
3
+ */
4
+ import { ReconciliationCategory } from "../constants/commission-reconciliation.constants";
5
+ /**
6
+ * Normalize an invoice/folio for cross-referencing commission ↔ sale.
7
+ * Strips spaces, slashes and any non-alphanumeric, uppercases.
8
+ *
9
+ * @example
10
+ * normalizeInvoiceNumber(" H/05355") // "H05355"
11
+ * normalizeInvoiceNumber("H05346") // "H05346"
12
+ */
13
+ export declare function normalizeInvoiceNumber(value: string | null | undefined): string;
14
+ /**
15
+ * Classify a QVET sale line into a reconciliation category by its section/family.
16
+ * Returns null when the line is outside the 4 tracked categories.
17
+ *
18
+ * @example
19
+ * classifySaleLine("SERVICIOS MEDICOS", "CONSULTA") // ReconciliationCategory.consulta
20
+ * classifySaleLine("INSUMOS ESCANDALLO", "VACUNAS") // ReconciliationCategory.vacuna
21
+ */
22
+ export declare function classifySaleLine(section: string | null | undefined, family: string | null | undefined): ReconciliationCategory | null;
23
+ /**
24
+ * Classify a commission service name into a reconciliation category.
25
+ * Returns null when the service is outside the 4 tracked categories.
26
+ */
27
+ export declare function classifyCommissionService(serviceName: string | null | undefined): ReconciliationCategory | null;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeInvoiceNumber = normalizeInvoiceNumber;
4
+ exports.classifySaleLine = classifySaleLine;
5
+ exports.classifyCommissionService = classifyCommissionService;
6
+ /**
7
+ * Commission Reconciliation — pure classification & key helpers (hvp-backend#434)
8
+ */
9
+ const commission_reconciliation_constants_1 = require("../constants/commission-reconciliation.constants");
10
+ /**
11
+ * Normalize an invoice/folio for cross-referencing commission ↔ sale.
12
+ * Strips spaces, slashes and any non-alphanumeric, uppercases.
13
+ *
14
+ * @example
15
+ * normalizeInvoiceNumber(" H/05355") // "H05355"
16
+ * normalizeInvoiceNumber("H05346") // "H05346"
17
+ */
18
+ function normalizeInvoiceNumber(value) {
19
+ return String(value ?? "")
20
+ .replace(/[^A-Za-z0-9]/g, "")
21
+ .toUpperCase();
22
+ }
23
+ /**
24
+ * Classify a QVET sale line into a reconciliation category by its section/family.
25
+ * Returns null when the line is outside the 4 tracked categories.
26
+ *
27
+ * @example
28
+ * classifySaleLine("SERVICIOS MEDICOS", "CONSULTA") // ReconciliationCategory.consulta
29
+ * classifySaleLine("INSUMOS ESCANDALLO", "VACUNAS") // ReconciliationCategory.vacuna
30
+ */
31
+ function classifySaleLine(section, family) {
32
+ const s = String(section ?? "").toUpperCase();
33
+ const f = String(family ?? "").toUpperCase();
34
+ if (f === "VACUNAS")
35
+ return commission_reconciliation_constants_1.ReconciliationCategory.vacuna;
36
+ if (s === commission_reconciliation_constants_1.SERVICIOS_MEDICOS_SECTION) {
37
+ if (f === "CONSULTA")
38
+ return commission_reconciliation_constants_1.ReconciliationCategory.consulta;
39
+ if (f === "EMERGENCIA")
40
+ return commission_reconciliation_constants_1.ReconciliationCategory.emergencia;
41
+ if (commission_reconciliation_constants_1.SURGERY_FAMILIES.includes(f))
42
+ return commission_reconciliation_constants_1.ReconciliationCategory.cirugia;
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * Classify a commission service name into a reconciliation category.
48
+ * Returns null when the service is outside the 4 tracked categories.
49
+ */
50
+ function classifyCommissionService(serviceName) {
51
+ if (!serviceName)
52
+ return null;
53
+ return commission_reconciliation_constants_1.COMMISSION_SERVICE_TO_CATEGORY[serviceName] ?? null;
54
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const commission_reconciliation_helpers_1 = require("./commission-reconciliation.helpers");
4
+ const commission_reconciliation_constants_1 = require("../constants/commission-reconciliation.constants");
5
+ describe('normalizeInvoiceNumber', () => {
6
+ it('strips spaces and slashes, uppercases', () => {
7
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)(' H/05355')).toBe('H05355');
8
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)('H05346')).toBe('H05346');
9
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)(' h05355')).toBe('H05355');
10
+ });
11
+ it('matches the sale-factura form to the commission-folio form', () => {
12
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)(' H/05355')).toBe((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)('H05355'));
13
+ });
14
+ it('handles null/undefined', () => {
15
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)(null)).toBe('');
16
+ expect((0, commission_reconciliation_helpers_1.normalizeInvoiceNumber)(undefined)).toBe('');
17
+ });
18
+ });
19
+ describe('classifySaleLine', () => {
20
+ it('classifies consulta / emergencia by SERVICIOS MEDICOS section', () => {
21
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('SERVICIOS MEDICOS', 'CONSULTA')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.consulta);
22
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('SERVICIOS MEDICOS', 'EMERGENCIA')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.emergencia);
23
+ });
24
+ it('classifies vacuna by VACUNAS family in any section', () => {
25
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('SERVICIOS MEDICOS', 'VACUNAS')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.vacuna);
26
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('INSUMOS ESCANDALLO', 'VACUNAS')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.vacuna);
27
+ });
28
+ it('classifies all surgical families as cirugia (within SERVICIOS MEDICOS)', () => {
29
+ for (const fam of ['CIRUGIA DE TEJIDOS BLANDOS', 'ASISTENCIA QUIRURGICA', 'OFTALMOLOGIA', 'ORTOPEDIA']) {
30
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('SERVICIOS MEDICOS', fam)).toBe(commission_reconciliation_constants_1.ReconciliationCategory.cirugia);
31
+ }
32
+ });
33
+ it('does NOT classify surgical-named families outside SERVICIOS MEDICOS (e.g. insumos oftalmología)', () => {
34
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('INSUMOS MEDICOS', 'OFTALMOLOGIA')).toBeNull();
35
+ });
36
+ it('returns null for out-of-scope sections (farmacia, insumos, lab)', () => {
37
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('FARMACIA', 'ANTIPARASITARIOS')).toBeNull();
38
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('SERVICIOS EXTERNOS', 'LABORATORIO')).toBeNull();
39
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)(null, null)).toBeNull();
40
+ });
41
+ it('is case-insensitive', () => {
42
+ expect((0, commission_reconciliation_helpers_1.classifySaleLine)('servicios medicos', 'consulta')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.consulta);
43
+ });
44
+ });
45
+ describe('classifyCommissionService', () => {
46
+ it('maps consulta-type services to consulta', () => {
47
+ for (const s of ['Consulta', 'Revisión', 'Especialista', 'Revisión especialista', 'Consulta no convencionales']) {
48
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)(s)).toBe(commission_reconciliation_constants_1.ReconciliationCategory.consulta);
49
+ }
50
+ });
51
+ it('maps surgical roles to cirugia', () => {
52
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Cirugía')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.cirugia);
53
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Anestesista')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.cirugia);
54
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Primer ayudante')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.cirugia);
55
+ });
56
+ it('maps Vacuna and Emergencia', () => {
57
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Vacuna')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.vacuna);
58
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Emergencia')).toBe(commission_reconciliation_constants_1.ReconciliationCategory.emergencia);
59
+ });
60
+ it('returns null for out-of-scope services', () => {
61
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Hemograma')).toBeNull();
62
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)('Corte de uñas')).toBeNull();
63
+ expect((0, commission_reconciliation_helpers_1.classifyCommissionService)(null)).toBeNull();
64
+ });
65
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hvp-shared",
3
- "version": "13.18.0",
3
+ "version": "13.27.0",
4
4
  "description": "Shared types and utilities for HVP backend and frontend",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",