feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,311 @@
# ADMIN BULK UPDATE — DESIGN.md
## 1. Module Structure
```
src/modules/bulk/
├── domain/
│ ├── bulk-operation.ts # Types, enums
│ └── ports.ts # BulkOperationHandler interface
├── application/
│ ├── bulk-service.ts # Orchestrator: validate → preview → execute
│ └── handlers/
│ ├── price-handlers.ts # SET, INCREASE/DECREASE (percent + fixed)
│ └── category-handlers.ts # ADD, REMOVE, REPLACE
├── infrastructure/
│ └── pg-bulk-repository.ts # Batch product reads + price updates
└── api/
└── bulk.routes.ts # POST /admin/bulk/preview, POST /admin/bulk/execute
```
## 2. Domain Types
```typescript
// src/modules/bulk/domain/bulk-operation.ts
export type BulkOperationType =
| 'SET_PRICE'
| 'INCREASE_PERCENT'
| 'DECREASE_PERCENT'
| 'INCREASE_FIXED'
| 'DECREASE_FIXED'
| 'ADD_CATEGORY'
| 'REMOVE_CATEGORY'
| 'REPLACE_CATEGORIES';
export interface BulkPriceParams {
netUnitAmountCents?: number; // for SET_PRICE
percent?: number; // for INCREASE/DECREASE_PERCENT
fixedCents?: number; // for INCREASE/DECREASE_FIXED
vatRate?: VatRate; // vatRate must be specified on price changes
}
export interface BulkCategoryParams {
categoryId?: string; // for ADD/REMOVE
categoryIds?: string[]; // for REPLACE
}
export type BulkOperationParams = BulkPriceParams | BulkCategoryParams;
export interface BulkOperationRequest {
productIds: string[];
operation: BulkOperationType;
parameters: BulkOperationParams;
}
export interface PreviewItem {
productId: string;
currentPriceCents: number | null;
proposedPriceCents: number | null;
currentCategoryIds: string[];
proposedCategoryIds: string[] | null;
status: 'valid' | 'failed';
error: string | null;
}
export interface PreviewResult {
items: PreviewItem[];
summary: {
total: number;
valid: number;
failed: number;
};
}
export interface ExecuteResult {
operationId: string;
status: 'COMPLETED' | 'COMPLETED_WITH_ERRORS' | 'FAILED';
results: {
total: number;
successful: number;
failed: number;
errors: { productId: string; error: string }[];
};
}
```
## 3. Handler Interface
```typescript
// src/modules/bulk/domain/ports.ts
export interface ValidationResult {
valid: boolean;
error?: string;
}
export interface BulkOperationHandler {
readonly type: BulkOperationType;
readonly description: string; // e.g., "Increase price by %"
validate(params: unknown): ValidationResult;
// Returns the proposed change for one product (dry-run)
preview(product: ProductWithPrice, params: unknown): PreviewItem;
// Returns the domain patch to apply (actual write)
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch;
}
```
## 4. Price Handlers
### 4.1 IncreasePercentHandler
```typescript
class IncreasePercentHandler implements BulkOperationHandler {
readonly type = 'INCREASE_PERCENT';
validate(params: unknown): ValidationResult {
const p = params as BulkPriceParams;
if (!p.percent || p.percent <= 0 || p.percent > 1000) {
return { valid: false, error: 'PERCENT_INVALID: must be 01000' };
}
if (!p.vatRate) return { valid: false, error: 'VAT_RATE_REQUIRED' };
return { valid: true };
}
preview(product: ProductWithPrice, params: unknown): PreviewItem {
if (!product.price) {
return { productId: product.id, currentPriceCents: null, proposedPriceCents: null,
currentCategoryIds: [], proposedCategoryIds: null, status: 'failed', error: 'PRICE_NOT_FOUND' };
}
const proposed = Math.round(product.price.netUnitAmountCents * (1 + p.percent / 100));
return { ... };
}
buildPatch(product: ProductWithPrice, params: unknown): ProductPatch {
// Returns patch for PATCH /catalog/products/:id
return { pricing: { netUnitAmountCents: proposed, vatRate: p.vatRate } };
}
}
```
**Rounding**: Use `Math.round()` — standard currency rounding. Backend pricing service may apply additional rounding; preview uses the same calculation.
**VAT**: VAT is stored as net price. The proposed change is applied to the net price. Gross price is computed by the pricing domain (not by this handler).
### 4.2 DecreasePercentHandler
Same as IncreasePercentHandler but `1 - p.percent / 100`. Floor at 0 (no negative prices).
### 4.3 IncreaseFixedHandler / DecreaseFixedHandler
Apply `current + fixedCents` or `current - fixedCents`. Floor at 0.
### 4.4 SetPriceHandler
Validate `netUnitAmountCents >= 0`. Apply exact value.
## 5. Category Handlers
### 5.1 AddCategoryHandler
```typescript
class AddCategoryHandler implements BulkOperationHandler {
readonly type = 'ADD_CATEGORY';
validate(params): ValidationResult {
const p = params as BulkCategoryParams;
if (!p.categoryId) return { valid: false, error: 'CATEGORY_ID_REQUIRED' };
return { valid: true };
}
buildPatch(product: Product, params): ProductPatch {
const newIds = [...new Set([...product.categoryIds, p.categoryId])];
return { categoryIds: newIds };
}
}
```
### 5.2 RemoveCategoryHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: product.categoryIds.filter(id => id !== p.categoryId) };
}
```
### 5.3 ReplaceCategoriesHandler
```typescript
buildPatch(product: Product, params): ProductPatch {
return { categoryIds: p.categoryIds ?? [] };
}
```
## 6. BulkService Orchestrator
```typescript
export class BulkService {
constructor(
private readonly products: BulkProductRepository, // read-only with price
private readonly productRepo: ProductRepository, // write
private readonly audit: AuditLogger,
) {}
async preview(req: BulkOperationRequest): Promise<PreviewResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const items = products.map(p => handler.preview(p, req.parameters));
return {
items,
summary: { total: items.length, valid: items.filter(i => i.status === 'valid').length, failed: ... }
};
}
async execute(req: BulkOperationRequest): Promise<ExecuteResult> {
const handler = HANDLERS.get(req.operation);
if (!handler) throw new AppError(400, 'UNKNOWN_OPERATION', `No handler for ${req.operation}`);
const validation = handler.validate(req.parameters);
if (!validation.valid) throw new AppError(422, 'VALIDATION_ERROR', validation.error);
const products = await this.products.findByIds(req.productIds);
const operationId = generateId();
const { successful, failed } = await this.applyInTransaction(products, handler, req.parameters);
await this.audit.log({
actorId: req.actorId,
action: 'bulk.execute',
target: `bulk:${handler.type.toLowerCase()}`,
metadata: { operationId, operation: req.operation, parameters: req.parameters, results: { total: products.length, successful, failed } },
});
return {
operationId,
status: failed === 0 ? 'COMPLETED' : 'COMPLETED_WITH_ERRORS',
results: { total: products.length, successful, failed, errors: failedItems },
};
}
}
```
## 7. Transaction Strategy
```typescript
private async applyInTransaction(products, handler, params) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// ... apply each patch
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
```
On rollback, no product is partially modified.
## 8. API Routes
```
POST /admin/bulk/preview
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 PreviewResult
POST /admin/bulk/execute
auth: admin
body: { productIds: string[], operation: string, parameters: object }
→ 200 ExecuteResult
```
## 9. Admin UI
```
/admin/bulk-update (new route)
└── Step 1: Select Products
├── Product list with checkboxes
├── Search/filter bar (reuse existing product list)
└── Selected count display: "18 products selected"
└── Step 2: Choose Operation
├── [Price] → sub-options appear
└── [Categories] → sub-options appear
└── Step 3: Configure
├── Price: operation selector + parameter input
└── Category: operation selector + category picker
└── Step 4: Preview
└── Table: Product | Current | Proposed | Status
└── Step 5: Confirm
└── Warning: "18 changes will be applied. This cannot be undone."
└── Step 6: Results
└── Summary: X successful, Y failed
```
Each step is a separate view in the wizard. No animations. Simple form state.
## 10. No Second Page for Bulk Update
Adding `/admin/bulk-update` as a separate route is cleaner than embedding in the products list. It keeps the product list focused on browsing and editing, and the bulk wizard has its own clear lifecycle.