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:
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user