feat: make every field of a bottling batch correctable and deletable
A bottling batch could only be corrected in four of its seven fields after creation: honey batch, jar size and jar count were frozen at creation time, an assigned product could not be cleared again, and there was no way to remove a record entered by mistake at all. A mistyped jar count is a data-entry error, not a fact of production, so it has to stay correctable. Backend: - UpdateBottlingBatchDto now covers every field. `productId: null` explicitly clears the product assignment; omitting it leaves it alone. - BottlingService.update rebalances the affected honey batch amounts: the previously bottled amount is given back before the corrected amount is taken again, so a correction never double-charges the source batch. Moving a batch to a different honey batch credits the old one and debits the new one in the same transaction. - New DELETE /bottling-batches/:id gives the bottled amount back to the honey batch and keeps the full record in the audit log (action DELETE, beforeData). The honey batch status is deliberately left untouched - the status it held before being bottled is not recoverable here. - New permission bottling:delete, checked server-side. The catalog sync grants it to system roles on the next boot. Frontend: - The edit dialog exposes all fields, offers "Kein Produkt" to clear the assignment, keeps a discontinued product selectable so opening the dialog cannot silently drop it, and shows backend errors inline. - The form is mounted only while the dialog is open, so a cancelled edit never leaks its values into the next one. - New delete button behind a confirmation that names the amount credited back and warns that the public origin page for the lot number stops resolving. - Edit and delete are additionally hidden without the matching permission. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
961ca04a40
commit
2b15795a60
10 files changed
+632
-109
No files matched your search
@@ -20,6 +20,15 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amounts are kept in kilograms with gram precision. Rounding on every write
|
||||
* keeps a honey batch's remaining amount from drifting when a bottling batch
|
||||
* is corrected or removed and its amount is given back.
|
||||
*/
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BottlingService {
|
||||
constructor(
|
||||
@@ -122,8 +131,16 @@ export class BottlingService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects any field of an existing bottling batch. Changing the honey
|
||||
* batch, the jar size or the jar count also rebalances the remaining amount
|
||||
* of the affected honey batch(es): the previously bottled amount is given
|
||||
* back before the corrected amount is taken again, so a correction never
|
||||
* double-charges the source batch.
|
||||
*/
|
||||
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
if (dto.productId) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
@@ -132,16 +149,87 @@ export class BottlingService {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
}
|
||||
await this.prisma.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
},
|
||||
|
||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
|
||||
const targetHoneyBatch = movedToOtherHoneyBatch
|
||||
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
||||
: before.honeyBatch;
|
||||
if (!targetHoneyBatch) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
|
||||
// Capacity on the target batch, with this bottling batch's current amount
|
||||
// released first when it already sits on that same batch.
|
||||
const availableOnTarget =
|
||||
targetHoneyBatch.amount === null
|
||||
? null
|
||||
: roundKg(
|
||||
targetHoneyBatch.amount +
|
||||
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
||||
);
|
||||
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
||||
throw new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
);
|
||||
}
|
||||
|
||||
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
||||
// 409 with a field-specific message by AllExceptionsFilter.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
honeyBatchId,
|
||||
productId: dto.productId === undefined ? undefined : dto.productId,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
jarSize,
|
||||
jarCount,
|
||||
totalAmount,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
lotNumber: dto.lotNumber,
|
||||
},
|
||||
});
|
||||
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
before.honeyBatch.amount === null
|
||||
? null
|
||||
: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
status: 'BOTTLED',
|
||||
},
|
||||
});
|
||||
} else if (totalAmount !== before.totalAmount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const bottlingBatch = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -151,6 +239,42 @@ export class BottlingService {
|
||||
beforeData: before,
|
||||
afterData: bottlingBatch,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
||||
return bottlingBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a bottling batch that was entered by mistake and gives its amount
|
||||
* back to the honey batch. The full record is kept in the audit log
|
||||
* (action DELETE, beforeData), so the history survives the row.
|
||||
*
|
||||
* The honey batch's status is deliberately left untouched: the status it
|
||||
* held before being bottled is not recoverable here, and guessing it would
|
||||
* write a fact that never happened.
|
||||
*/
|
||||
async remove(id: string, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.delete({ where: { id } });
|
||||
if (before.honeyBatch.amount !== null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
});
|
||||
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user