Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -123405,6 +123405,9 @@
"type": "number",
"nullable": true
},
"outOfStockDeclared": {
"type": "boolean"
},
"imageFileIds": {
"type": "array",
"items": {
Expand Down
2 changes: 2 additions & 0 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83761,6 +83761,8 @@ components:
minStock:
type: number
nullable: true
outOfStockDeclared:
type: boolean
imageFileIds:
type: array
items:
Expand Down
7 changes: 7 additions & 0 deletions src/modules/pharmacy/dto/read-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,13 @@ export class PharmacyProductReadDto {
@ApiPropertyOptional({ nullable: true, type: Number })
minStock?: number | null;

/**
* «No tengo» de toda la farmacia: gana sobre el conteo y sobre el stock de
* cada sede, que no lo puede apagar.
*/
@ApiPropertyOptional({ type: Boolean })
outOfStockDeclared?: boolean;

/** Imágenes propias, en orden. */
@ApiPropertyOptional({ type: [String], format: 'uuid' })
imageFileIds?: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ describe('MedicineCatalogRepository', () => {
expect(d.em.find).not.toHaveBeenCalled();
});

it('looks the id up among the medicine code systems, one marker each', async () => {
const d = build([]);
await d.repo.findProductById(d.em as any, 'c1');
const [sql, params] = d.em.execute.mock.calls[0];
expect(params.some((value: unknown) => Array.isArray(value))).toBe(false);
expect(sql.split('?').length - 1).toBe(params.length);
expect(params[0]).toBe('c1');
});

it('rebuilds the product from the concept and its properties', async () => {
const d = build([{ id: 'c1' }], [CONCEPT], FULL_PROPS);
const product = await d.repo.findProductById(d.em as any, 'c1');
Expand Down Expand Up @@ -134,7 +143,22 @@ describe('MedicineCatalogRepository', () => {
source: 'invima',
limit: 5,
});
expect(d.em.execute.mock.calls[0][1][0]).toEqual(['invima-medicamentos']);
const [sql, params] = d.em.execute.mock.calls[0];
expect(sql).toContain('s.internal_code in (?)');
expect(params[0]).toBe('invima-medicamentos');
});

it('sends one scalar per marker: no JavaScript array reaches the driver', async () => {
const d = build([]);
await d.repo.search(d.em as any, {
text: 'ibu',
atc: 'M01AE01',
limit: 5,
});
const [sql, params] = d.em.execute.mock.calls[0];
expect(params.some((value: unknown) => Array.isArray(value))).toBe(false);
expect(sql.split('?').length - 1).toBe(params.length);
expect(sql).not.toContain('any(');
});

it('does not hit the properties table when there is nothing to hydrate', async () => {
Expand All @@ -159,7 +183,9 @@ describe('MedicineCatalogRepository', () => {
await expect(
d.repo.findVademecumConceptIdByAtc(d.em as any, ['M01AE01']),
).resolves.toBe('vad-1');
expect(d.em.execute.mock.calls[0][1]).toEqual(['vademecum', ['M01AE01']]);
const [sql, params] = d.em.execute.mock.calls[0];
expect(sql).toContain('c.code in (?)');
expect(params).toEqual(['vademecum', 'M01AE01']);
});

it('returns null when no vademecum concept has that ATC', async () => {
Expand Down
42 changes: 28 additions & 14 deletions src/modules/pharmacy/repositories/medicine-catalog.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export class MedicineCatalogRepository {
join terminology.code_system_versions v on v.id = c.code_system_version_id
join terminology.code_systems s on s.id = v.code_system_id
where c.id = ?
and s.internal_code = any(?::text[])`,
[id, [...MEDICINE_CODE_SYSTEMS]],
and s.internal_code in (${markers(MEDICINE_CODE_SYSTEMS)})`,
[id, ...MEDICINE_CODE_SYSTEMS],
);
if (rows.length === 0) return null;
const [product] = await this.hydrate(em, [rows[0].id]);
Expand All @@ -101,45 +101,48 @@ export class MedicineCatalogRepository {
query: MedicineCatalogSearch,
): Promise<MedicineCatalogPage> {
const text = query.text?.trim() ?? '';
const systems = sourceCodeSystems(query.source);
if (systems.length === 0) return { items: [], truncated: false };
const searched = [
MEDICINE_PROPERTY.INGREDIENTS,
MEDICINE_PROPERTY.ATC,
MEDICINE_PROPERTY.HOLDER,
];
const pattern = `%${escapeLike(text)}%`;
const prefix = `${escapeLike(text)}%`;
const rows = await em.execute<{ id: string }[]>(
`select c.id
from terminology.catalog_concepts c
join terminology.code_system_versions v on v.id = c.code_system_version_id
join terminology.code_systems s on s.id = v.code_system_id
where s.internal_code = any(?::text[])
where s.internal_code in (${markers(systems)})
and (?::text = '' or c.display ilike ? or c.code ilike ?
or exists (
select 1 from terminology.concept_properties p
where p.concept_id = c.id
and p.property_code = any(?::text[])
and p.property_code in (${markers(searched)})
and p.value_json::text ilike ?))
and (?::text is null or exists (
select 1 from terminology.concept_properties p
where p.concept_id = c.id
and p.property_code = ?
and p.value_json @> to_jsonb(?::text)))
order by array_position(?::text[], s.internal_code),
order by array_position(array[${markers(MEDICINE_CODE_SYSTEMS)}]::text[], s.internal_code),
(c.display ilike ?) desc,
c.display,
c.id
limit ?`,
[
sourceCodeSystems(query.source),
...systems,
text,
pattern,
pattern,
[
MEDICINE_PROPERTY.INGREDIENTS,
MEDICINE_PROPERTY.ATC,
MEDICINE_PROPERTY.HOLDER,
],
...searched,
pattern,
query.atc ?? null,
MEDICINE_PROPERTY.ATC,
query.atc ?? null,
[...MEDICINE_CODE_SYSTEMS],
...MEDICINE_CODE_SYSTEMS,
prefix,
// Uno de más: si vuelve, la lista fue recortada.
query.limit + 1,
Expand All @@ -166,10 +169,10 @@ export class MedicineCatalogRepository {
join terminology.code_system_versions v on v.id = c.code_system_version_id
join terminology.code_systems s on s.id = v.code_system_id
where s.internal_code = ?
and c.code = any(?::text[])
and c.code in (${markers(atc)})
order by c.code
limit 1`,
[VADEMECUM_CODE_SYSTEM, [...atc]],
[VADEMECUM_CODE_SYSTEM, ...atc],
);
return rows[0]?.id ?? null;
}
Expand Down Expand Up @@ -211,6 +214,17 @@ function sourceCodeSystems(source: MedicineSource | undefined): string[] {
: MEDICINE_CODE_SYSTEMS.filter((cs) => cs === `${source}-medicamentos`);
}

/**
* Un marcador por valor, para `in (…)` y `array[…]`. El driver no convierte un
* arreglo de JavaScript en uno de Postgres: `any(?::text[])` con un arreglo
* llegaba como lista separada por comas y la consulta fallaba con un error de
* sintaxis (la búsqueda del catálogo oficial respondía 500). Los valores siguen
* yendo como parámetros, nunca dentro del SQL.
*/
function markers(values: readonly unknown[]): string {
return values.map(() => '?').join(', ');
}

/** `%` y `_` escritos por la persona son letras, no comodines. */
function escapeLike(text: string): string {
return text.replace(/[\\%_]/g, (char) => `\\${char}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ export interface PublishedOffer {
price: string;
/** Código de moneda. */
currency: string;
/** Unidades disponibles hoy (0 si la sede no lleva lotes y no declaró conteo). */
/** Unidades disponibles hoy (0 si no tiene posición y no se declaró conteo). */
availableQuantity: number;
/**
* Si la sede lo tiene hoy, según la regla única de existencias
* (`siteProductAvailabilitySql`): lotes si la sede los lleva, lo declarado
* en el catálogo si no, nunca lo marcado «no tengo».
* (`siteProductAvailabilitySql`): lotes si el producto tiene posición en la
* sede, lo declarado en el catálogo si no, nunca lo marcado «no tengo».
*/
inStock: boolean;
/** La sede entrega a domicilio. */
Expand Down Expand Up @@ -185,8 +185,8 @@ export class PharmacyMarketplaceRepository {
ORDER BY d.preferred DESC
LIMIT 1
) es ON true
-- La regla única de disponibilidad por sede (A1): lotes si la sede los
-- lleva, lo declarado en el catálogo si no.
-- La regla única de disponibilidad por sede (A1): lotes si el producto
-- tiene posición en la sede, lo declarado en el catálogo si no.
LEFT JOIN (${availability.sql}) av
ON av.site_id = site.id
AND av.product_id = prod.id
Expand Down
16 changes: 16 additions & 0 deletions src/modules/pharmacy/services/pharmacy-products.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,13 +607,29 @@ describe('PharmacyProductsService', () => {
status: 'PUBLISHED',
stock: null,
minStock: null,
outOfStockDeclared: false,
imageFileIds: [],
});
expect(product).toMatchObject({ updatedByUserId: 'admin-1' });
expect(d.tx.flush).toHaveBeenCalledTimes(1);
expect(d.readRepo.findConcepts).toHaveBeenCalledWith(d.tx, ['c-med']);
});

it('tells the manager the product is marked out of stock, apart from its count', async () => {
const d = build();
given(d, activeProduct({ stockQuantity: 4, outOfStockDeclared: true }));

const res = await inTenant(() =>
d.service.updateProduct('ph1', 'pr1', { brandName: 'Amoxil' }, actor),
);

expect(res).toMatchObject({
stock: 4,
inStock: false,
outOfStockDeclared: true,
});
});

it('clears a field when the body sends null and leaves the rest alone', async () => {
const d = build();
const product = activeProduct();
Expand Down
1 change: 1 addition & 0 deletions src/modules/pharmacy/services/pharmacy-read.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ export function toProductReadDto(
status: catalogStatusOf(row.statusConceptId),
stock: row.stockQuantity ?? null,
minStock: row.minStockQuantity ?? null,
outOfStockDeclared: row.outOfStockDeclared ?? false,
imageFileIds: [...(extras.imageFileIds ?? [])],
}
: {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import type { AuthenticatedUser } from '../../../../common';
import { PHARM } from '../../../pharmacy/pharmacy.concepts';
import type { PharmacyIntegrationConnections } from '../../../pharmacy/entities';
import { IntegrationReadRepository } from '../../infrastructure/repositories';
import { PINV } from '../../domain/pharmacy_inventory.concepts';
import { ExternalCatalogUnavailableError } from '../../domain/sync/external-catalog';
Expand Down Expand Up @@ -33,6 +32,14 @@ const MAX_BATCHES = 20;
* con su vínculo y los últimos lotes. Todo dentro del ámbito del actor: una
* farmacia o una conexión ajena responde el mismo 404 que una inexistente.
*/
/**
* La conexión tal como la lee el repositorio: la capa de aplicación no importa
* entidades de `pharmacy`, sólo `infrastructure/` habla con otros contextos.
*/
type FoundConnection = NonNullable<
Awaited<ReturnType<IntegrationReadRepository['findConnection']>>
>;

@Injectable()
export class IntegrationReadService {
constructor(
Expand Down Expand Up @@ -184,7 +191,7 @@ export class IntegrationReadService {
em: EntityManager,
ref: ConnectionRef,
actor: AuthenticatedUser,
): Promise<PharmacyIntegrationConnections> {
): Promise<FoundConnection> {
const pharmacy = await this.scope.requireManagedPharmacy(
em,
ref.pharmacyId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ interface SyncPlan {
* 4. Crea el lote y lo concilia con los casos de uso de siempre, en proceso
* (por HTTP son solo de la plataforma).
* 5. Aplica cada ítem conciliado con el escritor de stock por sede
* (`SiteStockWriter`, `MV_SYNC_ADJUSTMENT`, origen = el lote), que
* inicializa la sede si hace falta. Si este paso falla, el lote queda
* (`SiteStockWriter`, `MV_SYNC_ADJUSTMENT`, origen = el lote); los
* productos sin vínculo no se tocan. Si este paso falla, el lote queda
* conciliado como rastro y la respuesta es el error.
*/
@Injectable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ export class ReadPharmacyInventoryUseCase {
const now = new Date();
const [availabilityRows, locations, allPriceLists, practiceContext] =
await Promise.all([
// La regla única de existencias por sede (A1): lotes si la sede los
// lleva, lo declarado en el catálogo si no.
// La regla única de existencias por sede (A1): lotes si el producto
// tiene posición en la sede, lo declarado en el catálogo si no.
this.inventoryRepo.findSiteProductAvailability(
em,
sites.map((site) => site.id),
Expand Down Expand Up @@ -264,7 +264,7 @@ export class ReadPharmacyInventoryUseCase {
const listById = new Map(priceLists.map((list) => [list.id, list]));

// Disponible por sede y producto: la fila existe sólo si está disponible;
// la cantidad es `null` si la sede no lleva lotes y no declaró conteo.
// la cantidad es `null` si no tiene posición y no se declaró conteo.
const availableBySite = new Map<string, Map<string, number | null>>();
for (const row of availabilityRows) {
const perProduct =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ function rejected(
*
* Resuelve farmacia → sede → producto dentro del ámbito del actor (lo ajeno y
* lo inexistente responden el mismo 404), rechaza sin escribir lo que la sede
* no puede decidir y escribe por `SiteStockWriter`, que inicializa la sede en
* la misma transacción.
* no puede decidir y escribe por `SiteStockWriter` sólo ese producto: los demás
* siguen lo que declara el catálogo.
*/
@Injectable()
export class SetSiteProductAvailabilityUseCase {
Expand Down
Loading