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
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 11 additions & 0 deletions packages/warehouse-core/src/inventory/stock-movement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -36,6 +42,7 @@ export interface StockMovementSnapshot {
toItemId: string | null;
reason: string | null;
idempotencyKey: string | null;
actorId: string | null;
occurredAt: Date;
}

Expand Down Expand Up @@ -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,
) {}

Expand All @@ -95,6 +103,7 @@ export class StockMovement {
to,
normalizeText(props.reason),
normalizeText(props.idempotencyKey),
normalizeText(props.actorId),
props.occurredAt ?? new Date(),
);
}
Expand All @@ -109,6 +118,7 @@ export class StockMovement {
s.toItemId !== null ? StockItemId.fromString(s.toItemId) : null,
s.reason,
s.idempotencyKey,
s.actorId,
s.occurredAt,
);
}
Expand All @@ -134,6 +144,7 @@ export class StockMovement {
toItemId: this.toItemId?.value ?? null,
reason: this.reason,
idempotencyKey: this.idempotencyKey,
actorId: this.actorId,
occurredAt: this.occurredAt,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -48,6 +49,7 @@ describe('DrizzleStockMovementRepository (integración)', () => {
toItemId: opts.toItemId,
reason: 'Entrada de donación',
idempotencyKey: opts.idempotencyKey ?? null,
actorId: opts.actorId ?? null,
});
}

Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions packages/warehouse-postgres/src/inventory/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand All @@ -251,6 +252,7 @@ export function stockMovementSnapshotToRow(
toItemId: s.toItemId,
reason: s.reason,
idempotencyKey: s.idempotencyKey,
actorId: s.actorId,
occurredAt: s.occurredAt,
};
}
1 change: 1 addition & 0 deletions packages/warehouse-postgres/src/inventory/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
Loading