feat(ADM-018): completed feature
This commit is contained in:
311
project/specs/bulk-update/DESIGN.md
Normal file
311
project/specs/bulk-update/DESIGN.md
Normal 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 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.
|
||||
Reference in New Issue
Block a user