Files
ApiCore/ApiCore-Backend/src/bottling/bottling.service.ts
T
Nick SzaunigandClaude Opus 5.5 5d11756a87 fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore:

- Bottling booked the honey batch amount from a value read outside the
  transaction and wrote an absolute result back, so two concurrent
  bottlings (double tap, two devices) could both pass the check and one
  deduction was lost. Bookings now use a conditional UPDATE inside the
  transaction; corrections and deletions give amounts back atomically.
- Outgoing stock movements could drive stock below zero; the removal is
  now a conditional decrement that refuses when stock is insufficient.
- Weak, conspicuous and queenless colonies were treated as "not active"
  and could not be moved, split or merged - uniting a queenless colony
  is the textbook merge. Only terminal statuses lock a colony now; merging
  a colony into itself and dissolving twice are rejected.
- Moving a queen to another colony failed on the unique currentQueenId
  because the old colony kept pointing at her. The old colony now releases
  her (and becomes QUEENLESS); a queenless colony receiving a queen becomes
  ACTIVE; dissolved colonies cannot receive a queen.
- Completing a task twice moved completedAt and re-sent the webhook;
  reopening a task kept completedAt. Recurring templates created tasks
  without a due date and caught up missed weeks one stale task at a time.
- Automation rules created a new task and warning on every trigger (every
  stock removal below minimum). Tasks now record their source trigger and
  at most one open task per rule and object exists. The varroa threshold
  (mites per day) is only compared with natural-mite-fall rates, not with
  sample-based counts in another unit.
- New opt-in Idempotency-Key support for record-creating endpoints, so
  the offline queue can retry after a lost response without duplicates.
- Permission catalog sync crashed replicas booting concurrently on a
  fresh database (unique violation); it now uses createMany/skipDuplicates.
- Server-side validation for inspection rating scales (1-5), unique
  colony/harvest lists and net weight <= gross weight.
- Stale e2e expectations updated (permission count, trigger list, real PNG
  fixture for the file-signature check).

Migration is additive only (nullable Task column, new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:27:45 +02:00

314 lines
10 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { BottlingBatch, Prisma } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
const bottlingBatchInclude = {
honeyBatch: true,
product: true,
} as const;
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;
}
const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
type Tx = Prisma.TransactionClient;
/**
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
* database - not a value read earlier - decides whether enough is left.
* A batch without a recorded amount (null) is not tracked and stays null.
*/
async function takeFromHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
const current = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (!current) {
throw new NotFoundException('Honey batch not found');
}
if (current.amount === null) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
return;
}
// Tolerance of half a gram absorbs float noise from earlier writes.
const booked = await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
data: { amount: { decrement: amount }, status: 'BOTTLED' },
});
if (booked.count === 0) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
await normalizeAmount(tx, honeyBatchId);
}
async function giveBackToHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { not: null } },
data: { amount: { increment: amount } },
});
await normalizeAmount(tx, honeyBatchId);
}
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
const row = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (row?.amount == null) return;
const normalized = Math.max(0, roundKg(row.amount));
if (normalized !== row.amount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: normalized },
});
}
}
@Injectable()
export class BottlingService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
) {}
async findAll(filter: BottlingBatchFilter = {}) {
return this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId },
include: bottlingBatchInclude,
orderBy: { date: 'desc' },
});
}
async findById(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id },
include: bottlingBatchInclude,
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
return bottlingBatch;
}
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId },
});
if (!honeyBatch) {
throw new NotFoundException('Honey batch not found');
}
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const lotNumber =
dto.lotNumber ??
(await this.automation.generateNumber('BOTTLING_LOT_NUMBER_FORMAT'));
if (!lotNumber) {
throw new BadRequestException(
'Los-/Chargennummer erforderlich (oder Automatisierung "Los-/Chargennummer-Format" aktivieren)',
);
}
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => {
// Booked against the stored amount inside the transaction, never
// against the value read above: two bottlings submitted at the same
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
return tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
productId: dto.productId,
date: new Date(dto.date),
jarSize: dto.jarSize,
jarCount: dto.jarCount,
totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate),
lotNumber,
userId: actingUserId,
},
});
},
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: bottlingBatch,
});
const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
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 },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
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');
}
// Early, friendly check against the amounts read above. The booking
// itself happens atomically inside the transaction below.
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
// 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) => {
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
// Give the previous amount back first, then take the corrected one -
// a correction never double-charges the source batch.
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
}
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
}
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,
},
});
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
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 } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, 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 };
}
}