312 lines
9.2 KiB
Markdown
312 lines
9.2 KiB
Markdown
# 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 0–1000' };
|
||
}
|
||
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.
|