feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -14,7 +14,7 @@ interface StockBody {
|
||||
corrected: number;
|
||||
remaining: number;
|
||||
};
|
||||
movements: { type: string; quantity: number }[];
|
||||
movements: { id: string; type: string; quantity: number }[];
|
||||
}
|
||||
|
||||
interface ShopResult {
|
||||
@@ -35,6 +35,7 @@ describe('Stock ledger (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let beekeeperId: string;
|
||||
let beekeeperToken: string;
|
||||
let adminToken: string;
|
||||
let writeKey: string;
|
||||
@@ -48,6 +49,23 @@ describe('Stock ledger (e2e)', () => {
|
||||
const asBeekeeper = (req: request.Test) =>
|
||||
req.set('Authorization', `Bearer ${beekeeperToken}`);
|
||||
|
||||
/** The StockChanged events recorded for one movement, shop request or batch. */
|
||||
function stockChangedOf(aggregateId: string) {
|
||||
return prisma.domainEvent.findMany({
|
||||
where: { type: 'StockChanged', aggregateId },
|
||||
select: { aggregateType: true, payload: true, actorUserId: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function shopRequestId(externalId: string): Promise<string> {
|
||||
return (
|
||||
await prisma.shopStockRequest.findUniqueOrThrow({
|
||||
where: { externalId },
|
||||
select: { id: true },
|
||||
})
|
||||
).id;
|
||||
}
|
||||
|
||||
function book(token: string, requests: Record<string, unknown>[]) {
|
||||
return request(server())
|
||||
.post('/api/v1/shop-sync/movements')
|
||||
@@ -68,6 +86,7 @@ describe('Stock ledger (e2e)', () => {
|
||||
role: { connect: { key: 'BEEKEEPER' } },
|
||||
},
|
||||
});
|
||||
beekeeperId = beekeeper.id;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `stock-admin-${suffix}@example.com`,
|
||||
@@ -155,6 +174,34 @@ describe('Stock ledger (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Events and audit entries of this file only, by its own ids - other
|
||||
// e2e files share the database.
|
||||
// Only ids that exist: an undefined id would drop the filter.
|
||||
const batchIds = [bottlingId, newBatchId, oldBatchId].filter(Boolean);
|
||||
const movementIds = (
|
||||
await prisma.batchMovement.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ bottlingBatchId: { in: batchIds } },
|
||||
{ productionBatchId: { in: batchIds } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((movement) => movement.id);
|
||||
const requestIds = (
|
||||
await prisma.shopStockRequest.findMany({
|
||||
where: { externalId: { endsWith: suffix } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((shopRequest) => shopRequest.id);
|
||||
const aggregateIds = [...batchIds, ...movementIds, ...requestIds];
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: aggregateIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityId: { in: aggregateIds } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -181,6 +228,48 @@ describe('Stock ledger (e2e)', () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => {
|
||||
// The withdrawal of 2 above - the refused one of 20 left no trace.
|
||||
const movements = await prisma.batchMovement.findMany({
|
||||
where: { bottlingBatchId: bottlingId },
|
||||
select: { id: true, type: true, quantity: true },
|
||||
});
|
||||
expect(movements).toEqual([
|
||||
expect.objectContaining({ type: 'WITHDRAWAL', quantity: -2 }),
|
||||
]);
|
||||
const [movement] = movements;
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { entityType: 'BatchMovement', entityId: movement.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(await stockChangedOf(movement.id)).toEqual([
|
||||
{
|
||||
aggregateType: 'BatchMovement',
|
||||
payload: {
|
||||
bottlingBatchIds: [bottlingId],
|
||||
productIds: [],
|
||||
rawMaterialKinds: [],
|
||||
},
|
||||
actorUserId: beekeeperId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a production batch records StockChanged for its article', async () => {
|
||||
expect(await stockChangedOf(newBatchId)).toEqual([
|
||||
{
|
||||
aggregateType: 'ProductionBatch',
|
||||
payload: {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [candleId],
|
||||
rawMaterialKinds: [],
|
||||
},
|
||||
actorUserId: beekeeperId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('books a shop sale once, even when it is sent twice', async () => {
|
||||
const sale = {
|
||||
externalId: `sale-honey-${suffix}`,
|
||||
@@ -198,6 +287,19 @@ describe('Stock ledger (e2e)', () => {
|
||||
const [duplicate] = (second.body as { results: ShopResult[] }).results;
|
||||
expect(duplicate.status).toBe('duplicate');
|
||||
expect(duplicate.remainingCount).toBe(5);
|
||||
|
||||
// One event for the booking - the repeat booked nothing.
|
||||
expect(await stockChangedOf(await shopRequestId(sale.externalId))).toEqual([
|
||||
{
|
||||
aggregateType: 'ShopStockRequest',
|
||||
payload: {
|
||||
bottlingBatchIds: [bottlingId],
|
||||
productIds: [],
|
||||
rawMaterialKinds: [],
|
||||
},
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('allocates an article sale to the batch with the earliest best-before date first', async () => {
|
||||
@@ -253,6 +355,34 @@ describe('Stock ledger (e2e)', () => {
|
||||
expect(tooMuch.status).toBe('rejected');
|
||||
});
|
||||
|
||||
it('a shop request records one StockChanged per accepted booking', async () => {
|
||||
const sale = await stockChangedOf(
|
||||
await shopRequestId(`sale-candle-${suffix}`),
|
||||
);
|
||||
const returned = await stockChangedOf(
|
||||
await shopRequestId(`return-candle-${suffix}`),
|
||||
);
|
||||
for (const events of [sale, returned]) {
|
||||
expect(events).toEqual([
|
||||
{
|
||||
aggregateType: 'ShopStockRequest',
|
||||
payload: {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [candleId],
|
||||
rawMaterialKinds: [],
|
||||
},
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// The rejected return was rolled back together with its event.
|
||||
expect(
|
||||
await prisma.shopStockRequest.count({
|
||||
where: { externalId: `return-too-much-${suffix}` },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a bad request but still books the rest of the batch', async () => {
|
||||
const response = await book(writeKey, [
|
||||
{
|
||||
@@ -315,6 +445,20 @@ describe('Stock ledger (e2e)', () => {
|
||||
);
|
||||
expect(attempts.filter((r) => r.status === 201)).toHaveLength(5);
|
||||
expect(attempts.filter((r) => r.status === 400)).toHaveLength(3);
|
||||
// One StockChanged per booked withdrawal, none for a refused one.
|
||||
const withdrawals = await prisma.batchMovement.findMany({
|
||||
where: { productionBatchId: newBatchId, type: 'WITHDRAWAL' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(withdrawals).toHaveLength(5);
|
||||
expect(
|
||||
await prisma.domainEvent.count({
|
||||
where: {
|
||||
type: 'StockChanged',
|
||||
aggregateId: { in: withdrawals.map((movement) => movement.id) },
|
||||
},
|
||||
}),
|
||||
).toBe(5);
|
||||
|
||||
const stock = await asBeekeeper(
|
||||
request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`),
|
||||
|
||||
Reference in new issue
Block a user