feat(raw-materials): wax and propolis lots consumed by production batches
- RawMaterialLot (beeswax, propolis) in grams with origin (apiary, harvest); stock in the same movement ledger: withdrawals, inventory counts, and the new movement type CONSUMPTION. - Production batches take inputs (raw lot + grams); batch and consumption are one transaction, never more than a lot has left. Deleting a mistaken batch gives the material back as its own movement. - Traceability both ways: batch -> inputs, raw lot -> usedIn. - Movement belongs to exactly one of bottling / production / raw lot (CHECK); raw material changes do not trigger the shop webhook. - Permissions raw-material:*, granted to roles that maintain production batches. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
41c2af7284
commit
e73c8b2cb4
15 files changed
+986
-32
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface StockBody {
|
||||
summary: {
|
||||
initial: number;
|
||||
withdrawn: number;
|
||||
consumed: number;
|
||||
remaining: number;
|
||||
};
|
||||
movements: { type: string; quantity: number; notes: string | null }[];
|
||||
}
|
||||
|
||||
interface LotBody {
|
||||
id: string;
|
||||
remainingCount: number;
|
||||
usedIn: { quantity: number; productionBatch: { id: string } }[];
|
||||
}
|
||||
|
||||
interface BatchBody {
|
||||
id: string;
|
||||
inputs: { quantity: number; rawMaterialLot: { id: string } }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw material lots (wax, propolis) and their consumption in production:
|
||||
* creating a production batch books the inputs off the raw lots in the same
|
||||
* transaction, never more than is left; deleting a mistaken batch gives the
|
||||
* material back as its own movement.
|
||||
*/
|
||||
describe('Raw materials and production (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let token: string;
|
||||
let waxId: string;
|
||||
let propolisId: string;
|
||||
let productId: string;
|
||||
let batchId: string;
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const auth = (req: request.Test) =>
|
||||
req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function stockOf(lotId: string): Promise<StockBody> {
|
||||
const response = await auth(
|
||||
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
|
||||
).expect(200);
|
||||
return response.body as StockBody;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `raw-beekeeper-${suffix}@example.com`,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Beekeeper',
|
||||
role: { connect: { key: 'BEEKEEPER' } },
|
||||
},
|
||||
});
|
||||
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
|
||||
.accessToken;
|
||||
|
||||
const createLot = async (kind: string, lot: string, grams: number) => {
|
||||
const response = await auth(
|
||||
request(server()).post('/api/v1/raw-material-lots'),
|
||||
)
|
||||
.send({
|
||||
kind,
|
||||
lotNumber: `${lot}-${suffix}`,
|
||||
date: '2026-06-01',
|
||||
quantity: grams,
|
||||
})
|
||||
.expect(201);
|
||||
return (response.body as LotBody).id;
|
||||
};
|
||||
waxId = await createLot('BEESWAX', 'W', 1000);
|
||||
propolisId = await createLot('PROPOLIS', 'P', 200);
|
||||
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
name: `Propolis-Salbe ${suffix}`,
|
||||
kind: 'MANUFACTURED',
|
||||
shopCategory: 'PROPOLIS',
|
||||
},
|
||||
});
|
||||
productId = product.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('books the raw material when a production batch is made from it', async () => {
|
||||
const response = await auth(
|
||||
request(server()).post('/api/v1/production-batches'),
|
||||
)
|
||||
.send({
|
||||
productId,
|
||||
lotNumber: `S-${suffix}`,
|
||||
date: '2026-06-10',
|
||||
quantity: 20,
|
||||
inputs: [
|
||||
{ rawMaterialLotId: waxId, quantity: 300 },
|
||||
{ rawMaterialLotId: propolisId, quantity: 50 },
|
||||
],
|
||||
})
|
||||
.expect(201);
|
||||
const batch = response.body as BatchBody;
|
||||
batchId = batch.id;
|
||||
|
||||
expect(batch.inputs).toHaveLength(2);
|
||||
expect((await stockOf(waxId)).summary).toMatchObject({
|
||||
consumed: 300,
|
||||
remaining: 700,
|
||||
});
|
||||
expect((await stockOf(propolisId)).summary.remaining).toBe(150);
|
||||
|
||||
const wax = await auth(
|
||||
request(server()).get(`/api/v1/raw-material-lots/${waxId}`),
|
||||
).expect(200);
|
||||
expect(
|
||||
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
|
||||
).toEqual([batchId]);
|
||||
});
|
||||
|
||||
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
|
||||
await auth(request(server()).post('/api/v1/production-batches'))
|
||||
.send({
|
||||
productId,
|
||||
lotNumber: `S2-${suffix}`,
|
||||
date: '2026-06-11',
|
||||
quantity: 10,
|
||||
inputs: [{ rawMaterialLotId: waxId, quantity: 800 }],
|
||||
})
|
||||
.expect(400);
|
||||
expect(
|
||||
await prisma.productionBatch.count({
|
||||
where: { lotNumber: `S2-${suffix}` },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect((await stockOf(waxId)).summary.remaining).toBe(700);
|
||||
});
|
||||
|
||||
it('withdraws raw material like any other stock', async () => {
|
||||
const response = await auth(
|
||||
request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`),
|
||||
)
|
||||
.send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' })
|
||||
.expect(201);
|
||||
expect((response.body as StockBody).summary).toMatchObject({
|
||||
withdrawn: 100,
|
||||
consumed: 300,
|
||||
remaining: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives the raw material back when a mistaken production batch is deleted', async () => {
|
||||
await auth(
|
||||
request(server()).delete(`/api/v1/production-batches/${batchId}`),
|
||||
).expect(200);
|
||||
|
||||
const wax = await stockOf(waxId);
|
||||
expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 });
|
||||
expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a raw lot with movements as history', async () => {
|
||||
await auth(
|
||||
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
|
||||
).expect(409);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user