Files
mercadodevida/project/specs/bulk-update/DESIGN.md
2026-08-17 22:23:10 +02:00

312 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.