feat(stock): batch movement ledger, production batches and shop write-back API

One remaining quantity per batch, kept in ApiCore and always derived:
initial quantity (jarCount / produced quantity) plus the signed movements
in the new BatchMovement ledger, which is never deleted.

- Production batches for MANUFACTURED articles (candles, propolis
  products, ...); articles get a kind and a shop category.
- Withdrawals (farm shop, own use, breakage, sample) and inventory
  corrections on bottlings and production batches, never more than the
  remaining stock; every write locks the bottling or the article row.
- Shop bookings via POST /shop-sync/movements (new scope
  shop-sync:write): idempotent per externalId, articles allocated oldest
  best-before first, returns mirrored onto the batches of the sale,
  invalid entries rejected one by one.
- GET /shop-sync/stock and remainingCount in the shop feed; webhook
  stock.changed after every change (ids only, receivers fetch numbers).
- Bottlings and production batches with movements cannot be deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 06:38:12 +02:00
1 parent 85f05dc541
commit 04f93e954a
28 files changed
+2445 -35

No files matched your search

@@ -0,0 +1,72 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
/**
* One booking from the shop. Exactly one target: a bottling (honey, booked
* per vintage) or a MANUFACTURED article (allocated to its production
* batches, oldest stock first). That rule and the link of a return to its
* sale are checked per request in StockService, so a single bad entry is
* rejected on its own instead of failing the whole request.
*/
export class ShopStockRequestDto {
@ApiProperty({
description:
'Vom Shop vergebene, eindeutige ID - dieselbe ID zweimal bucht nur einmal',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
externalId!: string;
@ApiProperty({ enum: ['SALE', 'RETURN'] })
@IsIn(['SALE', 'RETURN'])
kind!: 'SALE' | 'RETURN';
@ApiPropertyOptional()
@IsOptional()
@IsString()
bottlingBatchId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
productId?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Max(10000)
quantity!: number;
@ApiPropertyOptional({
description: 'Bei RETURN: externalId des Verkaufs, der zurückgebucht wird',
})
@IsOptional()
@IsString()
@MaxLength(100)
reversesExternalId?: string;
}
export class ShopMovementsDto {
@ApiProperty({ type: [ShopStockRequestDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ShopStockRequestDto)
requests!: ShopStockRequestDto[];
}
@@ -1,17 +1,51 @@
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { ShopSyncFeed, ShopSyncService } from './shop-sync.service';
import type { ApiKeyPrincipal } from '../api-keys/api-keys.service';
import { StockService, type ShopStockResult } from '../stock/stock.service';
import { ShopMovementsDto } from './dto/shop-movements.dto';
import {
ShopSyncFeed,
ShopSyncService,
ShopSyncStock,
} from './shop-sync.service';
@ApiTags('shop-sync')
@ApiBearerAuth()
@Controller('shop-sync')
export class ShopSyncController {
constructor(private readonly shopSync: ShopSyncService) {}
constructor(
private readonly shopSync: ShopSyncService,
private readonly stock: StockService,
) {}
@RequirePermission('shop-sync:read')
@Get()
feed(): Promise<ShopSyncFeed> {
return this.shopSync.feed();
}
/** Only the remaining stock - for the shop's frequent reconcile. */
@RequirePermission('shop-sync:read')
@Get('stock')
stockLevels(): Promise<ShopSyncStock> {
return this.shopSync.stock();
}
/**
* The shop books its paid sales and its returns. Idempotent per
* externalId; each request is answered on its own (created, duplicate or
* rejected), so one bad entry never blocks the others.
*/
@RequirePermission('shop-sync:write')
@HttpCode(200)
@Post('movements')
movements(
@Body() dto: ShopMovementsDto,
@Req() request: { apiKey?: ApiKeyPrincipal },
): Promise<{ results: ShopStockResult[] }> {
return this.stock
.applyShopRequests(dto.requests, request.apiKey?.id ?? null)
.then((results) => ({ results }));
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ShopSyncController } from './shop-sync.controller';
import { ShopSyncService } from './shop-sync.service';
@Module({
imports: [StockModule],
controllers: [ShopSyncController],
providers: [ShopSyncService],
})
@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ProductStatus } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import {
ProductKind,
ProductStatus,
ShopCategory,
} from '../../generated/prisma';
/**
* Bumped only for breaking changes (a removed or renamed field, a changed
@@ -29,6 +34,26 @@ export interface ShopSyncProduct {
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
kind: ProductKind;
shopCategory: ShopCategory;
/** Sum over the production batches; null for bottled honey (see bottlings). */
remainingCount: number | null;
}
export interface ShopSyncProductionBatch {
id: string;
productId: string;
lotNumber: string;
date: Date;
bestBeforeDate: Date | null;
quantity: number;
remainingCount: number;
}
export interface ShopSyncStock {
generatedAt: Date;
bottlings: { id: string; remainingCount: number }[];
products: { id: string; remainingCount: number }[];
}
export interface ShopSyncBottling {
@@ -39,6 +64,8 @@ export interface ShopSyncBottling {
/** Grams per jar. */
jarSize: number;
jarCount: number;
/** jarCount plus all movements (sales, returns, withdrawals, corrections). */
remainingCount: number;
productId: string | null;
/** Harvests behind this bottling, oldest first. */
harvestIds: string[];
@@ -50,6 +77,7 @@ export interface ShopSyncFeed {
harvests: ShopSyncHarvest[];
products: ShopSyncProduct[];
bottlings: ShopSyncBottling[];
productionBatches: ShopSyncProductionBatch[];
}
/**
@@ -67,10 +95,39 @@ export interface ShopSyncFeed {
*/
@Injectable()
export class ShopSyncService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly stockService: StockService,
) {}
async stock(): Promise<ShopSyncStock> {
const [bottlings, products] = await Promise.all([
this.stockService.remainingByBottling(),
this.stockService.remainingByProduct(),
]);
return {
generatedAt: new Date(),
bottlings: [...bottlings].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
products: [...products].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
};
}
async feed(): Promise<ShopSyncFeed> {
const [harvests, products, bottlings] = await Promise.all([
const [
harvests,
products,
bottlings,
productionBatches,
remainingByBottling,
remainingByBatch,
remainingByProduct,
] = await Promise.all([
this.prisma.harvest.findMany({
select: {
id: true,
@@ -94,6 +151,8 @@ export class ShopSyncService {
weight: true,
salesUnit: true,
status: true,
kind: true,
shopCategory: true,
},
orderBy: { name: 'asc' },
}),
@@ -121,6 +180,20 @@ export class ShopSyncService {
},
orderBy: { date: 'asc' },
}),
this.prisma.productionBatch.findMany({
select: {
id: true,
productId: true,
lotNumber: true,
date: true,
bestBeforeDate: true,
quantity: true,
},
orderBy: { date: 'asc' },
}),
this.stockService.remainingByBottling(),
this.stockService.remainingByProductionBatch(),
this.stockService.remainingByProduct(),
]);
return {
@@ -130,11 +203,23 @@ export class ShopSyncService {
...harvest,
apiaryName: apiary.name,
})),
products,
products: products.map((product) => ({
...product,
remainingCount:
product.kind === 'MANUFACTURED'
? (remainingByProduct.get(product.id) ?? 0)
: null,
})),
bottlings: bottlings.map(({ honeyBatch, ...bottling }) => ({
...bottling,
remainingCount:
remainingByBottling.get(bottling.id) ?? bottling.jarCount,
harvestIds: honeyBatch.extraction.harvests.map((h) => h.harvestId),
})),
productionBatches: productionBatches.map((batch) => ({
...batch,
remainingCount: remainingByBatch.get(batch.id) ?? batch.quantity,
})),
};
}
}