diff --git a/packages/warehouse-core/src/inventory/stock-movement-actor.test.ts b/packages/warehouse-core/src/inventory/stock-movement-actor.test.ts new file mode 100644 index 00000000..f4ed6ad8 --- /dev/null +++ b/packages/warehouse-core/src/inventory/stock-movement-actor.test.ts @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { StockMovement } from './stock-movement.js'; +import { StockMovementId } from './stock-movement-id.js'; +import { StockItemId } from './stock-item-id.js'; +import { Quantity } from './quantity.js'; +import { MovementKind } from './movement-enums.js'; +import { ScopeId } from '../kernel/index.js'; + +const BASE = { + id: StockMovementId.fromString('11111111-1111-4111-8111-111111111111'), + scopeId: ScopeId.fromString('22222222-2222-4222-8222-222222222222'), + kind: MovementKind.Receipt, + quantity: Quantity.of(5, 'und'), + toItemId: StockItemId.fromString('33333333-3333-4333-8333-333333333333'), + occurredAt: new Date('2026-07-14T10:00:00.000Z'), +}; + +test('actorId por defecto null (movimiento sin autor conocido)', () => { + const m = StockMovement.record(BASE); + assert.equal(m.actorId, null); +}); + +test('actorId opaco se conserva y hace round-trip por snapshot', () => { + const m = StockMovement.record({ ...BASE, actorId: 'user-42' }); + assert.equal(m.actorId, 'user-42'); + const back = StockMovement.fromSnapshot(m.toSnapshot()); + assert.equal(back.actorId, 'user-42'); +}); + +test('actorId vacío se normaliza a null', () => { + const m = StockMovement.record({ ...BASE, actorId: ' ' }); + assert.equal(m.actorId, null); +}); diff --git a/packages/warehouse-core/src/inventory/stock-movement.ts b/packages/warehouse-core/src/inventory/stock-movement.ts index 88f74091..a1b7896d 100644 --- a/packages/warehouse-core/src/inventory/stock-movement.ts +++ b/packages/warehouse-core/src/inventory/stock-movement.ts @@ -23,6 +23,12 @@ export interface RecordStockMovementProps { * movement by key before applying, guarding against double entradas. */ idempotencyKey?: string | null; + /** + * Quién ejecutó el movimiento (cadena de custodia). Id opaco: el paquete no + * resuelve identidad ni permisos — eso lo hace el host (#355). Null = sin + * autor conocido (movimientos históricos o del sistema). + */ + actorId?: string | null; occurredAt?: Date; } @@ -36,6 +42,7 @@ export interface StockMovementSnapshot { toItemId: string | null; reason: string | null; idempotencyKey: string | null; + actorId: string | null; occurredAt: Date; } @@ -73,6 +80,7 @@ export class StockMovement { public readonly toItemId: StockItemId | null, public readonly reason: string | null, public readonly idempotencyKey: string | null, + public readonly actorId: string | null, public readonly occurredAt: Date, ) {} @@ -95,6 +103,7 @@ export class StockMovement { to, normalizeText(props.reason), normalizeText(props.idempotencyKey), + normalizeText(props.actorId), props.occurredAt ?? new Date(), ); } @@ -109,6 +118,7 @@ export class StockMovement { s.toItemId !== null ? StockItemId.fromString(s.toItemId) : null, s.reason, s.idempotencyKey, + s.actorId, s.occurredAt, ); } @@ -134,6 +144,7 @@ export class StockMovement { toItemId: this.toItemId?.value ?? null, reason: this.reason, idempotencyKey: this.idempotencyKey, + actorId: this.actorId, occurredAt: this.occurredAt, }; } diff --git a/packages/warehouse-postgres/migrations/wms_0004_movement_actor.sql b/packages/warehouse-postgres/migrations/wms_0004_movement_actor.sql new file mode 100644 index 00000000..9dd17d9c --- /dev/null +++ b/packages/warehouse-postgres/migrations/wms_0004_movement_actor.sql @@ -0,0 +1,13 @@ +-- wms_0004_movement_actor — cadena de custodia del kardex (Inc 3 del EPIC de +-- flota). Añade a `wms.stock_movements` el autor del asiento (`actor_id`): un id +-- OPACO (el paquete no resuelve identidad ni permisos — eso lo hace el host, +-- #355). Nullable: los asientos históricos quedan sin autor. +-- +-- Aditiva: el libro mayor sigue siendo inmutable (sólo INSERT); esto sólo suma +-- una columna que se rellena en el alta. Idempotente. +-- +-- Migración inmutable una vez fusionada: corregir hacia adelante con un nuevo +-- `wms_*.sql`, nunca editar este fichero. + +ALTER TABLE wms.stock_movements + ADD COLUMN IF NOT EXISTS actor_id text; diff --git a/packages/warehouse-postgres/src/inventory/drizzle-stock-movement.repository.test.ts b/packages/warehouse-postgres/src/inventory/drizzle-stock-movement.repository.test.ts index 370c8240..6fc600ed 100644 --- a/packages/warehouse-postgres/src/inventory/drizzle-stock-movement.repository.test.ts +++ b/packages/warehouse-postgres/src/inventory/drizzle-stock-movement.repository.test.ts @@ -39,6 +39,7 @@ describe('DrizzleStockMovementRepository (integración)', () => { toItemId: StockItemId; amount?: number; idempotencyKey?: string | null; + actorId?: string | null; }): StockMovement { return StockMovement.record({ id: StockMovementId.create(), @@ -48,6 +49,7 @@ describe('DrizzleStockMovementRepository (integración)', () => { toItemId: opts.toItemId, reason: 'Entrada de donación', idempotencyKey: opts.idempotencyKey ?? null, + actorId: opts.actorId ?? null, }); } @@ -61,6 +63,20 @@ describe('DrizzleStockMovementRepository (integración)', () => { assert.deepEqual(loaded.toSnapshot(), movement.toSnapshot()); }); + it('actorId (cadena de custodia) hace round-trip', async () => { + const scopeId = ScopeId.create(); + const movement = receipt({ + scopeId, + toItemId: StockItemId.create(), + actorId: 'coordinador-7', + }); + await repo.append(movement); + + const loaded = await repo.findById(movement.id); + assert.ok(loaded); + assert.equal(loaded.actorId, 'coordinador-7'); + }); + it('idempotencia: la misma clave insertada dos veces deja una sola fila', async () => { const scopeId = ScopeId.create(); const toItemId = StockItemId.create(); diff --git a/packages/warehouse-postgres/src/inventory/mappers.ts b/packages/warehouse-postgres/src/inventory/mappers.ts index 0284cca9..81671c41 100644 --- a/packages/warehouse-postgres/src/inventory/mappers.ts +++ b/packages/warehouse-postgres/src/inventory/mappers.ts @@ -234,6 +234,7 @@ export function rowToStockMovementSnapshot( toItemId: row.toItemId ?? null, reason: row.reason ?? null, idempotencyKey: row.idempotencyKey ?? null, + actorId: row.actorId ?? null, occurredAt: row.occurredAt, }; } @@ -251,6 +252,7 @@ export function stockMovementSnapshotToRow( toItemId: s.toItemId, reason: s.reason, idempotencyKey: s.idempotencyKey, + actorId: s.actorId, occurredAt: s.occurredAt, }; } diff --git a/packages/warehouse-postgres/src/inventory/schema.ts b/packages/warehouse-postgres/src/inventory/schema.ts index ded4a2ff..12085c2a 100644 --- a/packages/warehouse-postgres/src/inventory/schema.ts +++ b/packages/warehouse-postgres/src/inventory/schema.ts @@ -110,6 +110,7 @@ export const stockMovementsTable = wms.table('stock_movements', { toItemId: uuid('to_item_id'), reason: text('reason'), idempotencyKey: text('idempotency_key'), + actorId: text('actor_id'), occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), });