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.
|
||||
225
project/specs/bulk-update/SPEC.md
Normal file
225
project/specs/bulk-update/SPEC.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# ADMIN BULK UPDATE — SPEC.md
|
||||
|
||||
## 1. Concept & Vision
|
||||
|
||||
A safe, auditable bulk update module for MercadoDeVida Admin that lets operators change prices and categories across multiple products at once. Every bulk operation requires a **preview** before execution, confirms before committing, and logs every change to the audit trail.
|
||||
|
||||
**Rule: PREVIEW != WRITE. SELECT → PREVIEW → CONFIRM → EXECUTE. NEVER SILENT MUTATION.**
|
||||
|
||||
## 2. Initial Capabilities
|
||||
|
||||
- **Bulk Price Update**: SET, INCREASE BY %, DECREASE BY %, INCREASE BY FIXED, DECREASE BY FIXED
|
||||
- **Bulk Category Update**: ADD CATEGORY, REMOVE CATEGORY, REPLACE CATEGORIES
|
||||
|
||||
The architecture must allow adding future operations (stock, visibility, brands, etc.) without redesign.
|
||||
|
||||
## 3. Why Not PATCH in a Loop?
|
||||
|
||||
Making N individual `PATCH /products/:id` calls from the browser for bulk operations causes:
|
||||
- N network requests
|
||||
- N separate audit entries (not grouped)
|
||||
- Partial failures with no atomicity
|
||||
- Race conditions if prices change between calls
|
||||
- Poor observability
|
||||
|
||||
A single backend bulk operation groups these concerns: atomic or near-atomic execution, single audit entry, transactional semantics.
|
||||
|
||||
## 4. Bulk Operation Types
|
||||
|
||||
### 4.1 Price Operations
|
||||
|
||||
```
|
||||
SET_PRICE → Set netUnitAmountCents to exact value
|
||||
INCREASE_PERCENT → netUnitAmountCents = current * (1 + pct/100), round to nearest cent
|
||||
DECREASE_PERCENT → netUnitAmountCents = current * (1 - pct/100), round to nearest cent
|
||||
INCREASE_FIXED → netUnitAmountCents = current + fixedCents
|
||||
DECREASE_FIXED → netUnitAmountCents = current - fixedCents
|
||||
```
|
||||
|
||||
### 4.2 Category Operations
|
||||
|
||||
```
|
||||
ADD_CATEGORY → Append categoryId to product's categoryIds (deduplicated)
|
||||
REMOVE_CATEGORY → Remove categoryId from product's categoryIds
|
||||
REPLACE_CATEGORIES → Replace product's categoryIds entirely with provided list
|
||||
```
|
||||
|
||||
**Critical**: "UPDATE CATEGORY" does not exist. The three explicit operations above have very different semantics.
|
||||
|
||||
## 5. Selection Strategy
|
||||
|
||||
Admin can select products through:
|
||||
- **Checkbox row selection**: tick individual products in the list
|
||||
- **Search/filter**: apply filters to the list, implicitly selecting the filtered set
|
||||
|
||||
The UI must be explicit about what "Selected products" means:
|
||||
- "18 products selected" = 18 explicitly checked rows
|
||||
- "All 42 filtered products" = entire filtered result set (requires separate confirmation)
|
||||
|
||||
Do NOT mix these semantics.
|
||||
|
||||
## 6. Preview
|
||||
|
||||
Preview is a **dry-run**. No product is modified.
|
||||
|
||||
Preview is powered by a backend endpoint that returns proposed changes without applying them:
|
||||
|
||||
```
|
||||
POST /admin/bulk/preview
|
||||
{
|
||||
"productIds": ["uuid1", "uuid2", ...],
|
||||
"operation": "INCREASE_PERCENT",
|
||||
"parameters": { "percent": 5 }
|
||||
}
|
||||
→ 200
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"productId": "uuid1",
|
||||
"currentPriceCents": 350,
|
||||
"proposedPriceCents": 368, ← calculated by backend
|
||||
"currentCategoryIds": ["cat-a"],
|
||||
"proposedCategoryIds": null, ← null if not a category op
|
||||
"status": "valid",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"productId": "uuid2",
|
||||
"currentPriceCents": 290,
|
||||
"proposedPriceCents": null,
|
||||
"status": "failed",
|
||||
"error": "PRICE_NOT_FOUND" ← variant has no price
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 20,
|
||||
"valid": 18,
|
||||
"failed": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The preview response is **not stored**. It is regenerated on each request. Backend calculates prices using existing pricing rules (VAT, rounding, etc.) — NOT frontend JavaScript.
|
||||
|
||||
## 7. Confirmation
|
||||
|
||||
After preview, admin sees:
|
||||
- What will change (product, old value, new value)
|
||||
- What will fail (product, reason)
|
||||
- Summary: "18 products will be updated, 2 skipped"
|
||||
|
||||
Admin must explicitly confirm:
|
||||
```
|
||||
Are you sure you want to apply these 18 changes?
|
||||
This action cannot be undone.
|
||||
[Cancel] [Apply Changes]
|
||||
```
|
||||
|
||||
## 8. Execution
|
||||
|
||||
Confirmed preview is executed via:
|
||||
|
||||
```
|
||||
POST /admin/bulk/execute
|
||||
{
|
||||
"productIds": ["uuid1", "uuid2", ...],
|
||||
"operation": "INCREASE_PERCENT",
|
||||
"parameters": { "percent": 5 }
|
||||
}
|
||||
→ 200
|
||||
{
|
||||
"operationId": "bulk-uuid",
|
||||
"status": "COMPLETED",
|
||||
"results": {
|
||||
"total": 20,
|
||||
"successful": 18,
|
||||
"failed": 2,
|
||||
"errors": [
|
||||
{ "productId": "uuid2", "error": "PRICE_NOT_FOUND" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Backend applies changes in a single transaction per operation. If the operation fails partway, the transaction rolls back (no partial mutations on error).
|
||||
|
||||
## 9. Audit
|
||||
|
||||
Every bulk execution logs ONE audit entry via existing AuditLogger:
|
||||
|
||||
```json
|
||||
{
|
||||
"actorId": "admin-uuid",
|
||||
"action": "bulk.execute",
|
||||
"target": "bulk:price.increase_percent",
|
||||
"metadata": {
|
||||
"operationId": "bulk-uuid",
|
||||
"operation": "INCREASE_PERCENT",
|
||||
"parameters": { "percent": 5 },
|
||||
"productIds": ["uuid1", "uuid2"],
|
||||
"results": {
|
||||
"total": 20,
|
||||
"successful": 18,
|
||||
"failed": 2,
|
||||
"failures": [{ "productId": "uuid2", "error": "PRICE_NOT_FOUND" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each individual product mutation is NOT logged separately for bulk operations (that would create N audit entries for one admin action).
|
||||
|
||||
## 10. RBAC
|
||||
|
||||
Bulk operations require dedicated permission:
|
||||
|
||||
```
|
||||
products.bulk_update
|
||||
```
|
||||
|
||||
In the current `permissions.ts`, `admin` role gets all permissions via `can(role, _)`. Future granular permissions can add `products.bulk_update` to the `Permission` type.
|
||||
|
||||
## 11. Extension Points
|
||||
|
||||
The BulkUpdateService uses a handler registry:
|
||||
|
||||
```typescript
|
||||
interface BulkOperationHandler {
|
||||
readonly type: string; // e.g., 'INCREASE_PERCENT'
|
||||
readonly domain: string; // e.g., 'price', 'category'
|
||||
|
||||
validate(params: unknown): ValidationResult;
|
||||
buildPreview(product: Product, params: unknown): PreviewItem;
|
||||
apply(product: Product, params: unknown): ProductPatch;
|
||||
}
|
||||
|
||||
const HANDLERS: ReadonlyMap<string, BulkOperationHandler> = new Map([
|
||||
['INCREASE_PERCENT', new IncreasePercentHandler()],
|
||||
['ADD_CATEGORY', new AddCategoryHandler()],
|
||||
// Future: ['STOCK_SET', new StockSetHandler()]
|
||||
]);
|
||||
```
|
||||
|
||||
Adding a new operation = adding one new handler class + registering it. No changes to routing or execution engine.
|
||||
|
||||
## 12. Out of Scope
|
||||
|
||||
- Stock bulk update (future)
|
||||
- Visibility bulk update (future)
|
||||
- Brand bulk update (future)
|
||||
- Asynchronous background jobs (not needed at expected data volume)
|
||||
- Operation cancellation (not needed at expected data volume)
|
||||
- Bulk export (different feature)
|
||||
|
||||
## 13. Feature Flag
|
||||
|
||||
```
|
||||
admin_bulk_update
|
||||
default: false
|
||||
```
|
||||
|
||||
Flip to true after MVP is tested.
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
See `TESTS.md`.
|
||||
306
project/specs/bulk-update/TASKS.md
Normal file
306
project/specs/bulk-update/TASKS.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# ADMIN BULK UPDATE — TASKS.md
|
||||
|
||||
## Backend
|
||||
|
||||
### BULK-BE-001
|
||||
**ID**: BULK-BE-001
|
||||
**Title**: BulkOperation domain types
|
||||
**Goal**: Define all types for bulk operations
|
||||
**Why**: Shared contracts for handlers, orchestrator, and API
|
||||
**Dependencies**: None
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/domain/bulk-operation.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: Shapes of request/response objects
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Define BulkOperationType enum, BulkOperationParams union, PreviewItem, PreviewResult, ExecuteResult interfaces; ValidationResult
|
||||
**Tests**: Type tests (TypeScript compilation)
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: All types defined; TypeScript compiles without errors
|
||||
|
||||
### BULK-BE-002
|
||||
**ID**: BULK-BE-002
|
||||
**Title**: BulkOperationHandler port + handler registry
|
||||
**Goal**: Define the extension interface for bulk operations
|
||||
**Why**: Allows adding future operations without touching core orchestrator
|
||||
**Dependencies**: BULK-BE-001
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/domain/ports.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: BulkOperationHandler interface
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Define `BulkOperationHandler` interface; create `HANDLERS` registry map; export from bulk/index.ts
|
||||
**Tests**: Handler lookup by type works; unknown type returns undefined
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Interface exists; registry is extensible; adding a handler requires only adding to registry
|
||||
|
||||
### BULK-BE-003
|
||||
**ID**: BULK-BE-003
|
||||
**Title**: Price bulk operation handlers
|
||||
**Goal**: Implement SET, INCREASE_PERCENT, DECREASE_PERCENT, INCREASE_FIXED, DECREASE_FIXED
|
||||
**Why**: Initial price operations
|
||||
**Dependencies**: BULK-BE-002
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/application/handlers/price-handlers.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None (domain)
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Five handler classes; each implements validate(), preview(), buildPatch(); rounding uses Math.round(); VAT is stored as net price
|
||||
**Tests**:
|
||||
- BULK-BE-UT-001: INCREASE_PERCENT 5% on 350 cents → 368 cents
|
||||
- BULK-BE-UT-002: DECREASE_PERCENT 5% on 350 cents → 333 cents
|
||||
- BULK-BE-UT-003: INCREASE_FIXED 50 on 350 cents → 400 cents
|
||||
- BULK-BE-UT-004: DECREASE_FIXED 50 on 350 cents → 300 cents
|
||||
- BULK-BE-UT-005: DECREASE_FIXED below 0 → floor at 0
|
||||
- BULK-BE-UT-006: SET_PRICE 500 → 500
|
||||
- BULK-BE-UT-007: invalid percent (0, negative, >1000) → ValidationResult.valid=false
|
||||
- BULK-BE-UT-008: missing VAT rate → ValidationResult.valid=false
|
||||
- BULK-BE-UT-009: product without price → PreviewItem.status=failed, error=PRICE_NOT_FOUND
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: All five handlers produce correct price calculations; invalid params rejected at validate()
|
||||
|
||||
### BULK-BE-004
|
||||
**ID**: BULK-BE-004
|
||||
**Title**: Category bulk operation handlers
|
||||
**Goal**: Implement ADD_CATEGORY, REMOVE_CATEGORY, REPLACE_CATEGORIES
|
||||
**Why**: Initial category operations
|
||||
**Dependencies**: BULK-BE-002
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/application/handlers/category-handlers.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None (domain)
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Three handler classes; ADD deduplicates; REMOVE filters; REPLACE replaces entirely; validates categoryId exists (optional — can be deferred)
|
||||
**Tests**:
|
||||
- BULK-BE-UT-010: ADD_CATEGORY to product with 2 categories → 3 categories
|
||||
- BULK-BE-UT-011: ADD_CATEGORY with duplicate → no-op (deduplicated)
|
||||
- BULK-BE-UT-012: REMOVE_CATEGORY from product with 3 categories → 2 categories
|
||||
- BULK-BE-UT-013: REMOVE_CATEGORY not present → no change
|
||||
- BULK-BE-UT-014: REPLACE_CATEGORIES with 2 new categories → exactly those 2
|
||||
- BULK-BE-UT-015: ADD without categoryId → ValidationResult.valid=false
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: All three handlers produce correct category patches; validation rejects invalid input
|
||||
|
||||
### BULK-BE-005
|
||||
**ID**: BULK-BE-005
|
||||
**Title**: BulkService orchestrator
|
||||
**Goal**: Orchestrate preview + execute; integrate with audit
|
||||
**Why**: Core business logic tying handlers, repository, and audit together
|
||||
**Dependencies**: BULK-BE-001, BULK-BE-002, BULK-BE-003, BULK-BE-004
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/application/bulk-service.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None (service)
|
||||
**Permissions**: N/A
|
||||
**Implementation**: BulkService with preview() and execute(); execute() uses transaction; logs audit entry on execution; rollback on error; generate operationId
|
||||
**Tests**:
|
||||
- BULK-BE-UT-020: preview() calls handler.validate() and handler.preview() for each product
|
||||
- BULK-BE-UT-021: execute() calls handler.buildPatch() for each valid product
|
||||
- BULK-BE-UT-022: execute() logs one audit entry per bulk operation
|
||||
- BULK-BE-UT-023: execute() rollback on error → no product modified
|
||||
- BULK-BE-UT-024: unknown operation type → AppError(400)
|
||||
**Expected blast radius**: Medium — changes persistence
|
||||
**Definition of Done**: BulkService correctly orchestrates all operations; audit entry is complete; transaction is atomic
|
||||
|
||||
### BULK-BE-006
|
||||
**ID**: BULK-BE-006
|
||||
**Title**: BulkProductRepository — read products with pricing in batch
|
||||
**Goal**: Efficiently load product+price data for bulk operations
|
||||
**Why**: Avoids N queries for N products
|
||||
**Dependencies**: BULK-BE-001
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/infrastructure/pg-bulk-repository.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Single SQL query to JOIN catalog_products + pricing_variants; returns ProductWithPrice[] (internal type); handles products with no price gracefully
|
||||
**Tests**: BULK-BE-IT-001: batch load of 20 products returns correct data; BULK-BE-IT-002: product without price returns null price field
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Products loaded in one query; missing price handled gracefully
|
||||
|
||||
### BULK-BE-007
|
||||
**ID**: BULK-BE-007
|
||||
**Title**: Bulk API routes — preview and execute
|
||||
**Goal**: Expose bulk operations via REST API
|
||||
**Why**: Admin UI needs these endpoints
|
||||
**Dependencies**: BULK-BE-005, BULK-BE-006
|
||||
**Applications**: Backend
|
||||
**Modules**: bulk/api/bulk.routes.ts
|
||||
**Database impact**: None (read for preview; write for execute)
|
||||
**API contracts**: POST /admin/bulk/preview, POST /admin/bulk/execute
|
||||
**Permissions**: admin role required on both
|
||||
**Implementation**: Register routes in build-app.ts; parse request body; call BulkService; serialize response; handle errors with AppError
|
||||
**Tests**:
|
||||
- BULK-BE-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
|
||||
- BULK-BE-IT-011: POST /admin/bulk/preview with invalid params → 422
|
||||
- BULK-BE-IT-012: POST /admin/bulk/preview without auth → 401
|
||||
- BULK-BE-IT-013: POST /admin/bulk/execute with valid params → 200 + ExecuteResult
|
||||
- BULK-BE-IT-014: POST /admin/bulk/execute → applies changes to DB; products updated
|
||||
- BULK-BE-IT-015: audit log entry created after execute
|
||||
**Expected blast radius**: Low — new routes
|
||||
**Definition of Done**: Both routes respond correctly; auth enforced; changes persisted
|
||||
|
||||
### BULK-BE-008
|
||||
**ID**: BULK-BE-008
|
||||
**Title**: Wire bulk module into build-app.ts
|
||||
**Goal**: Register bulk routes with Fastify
|
||||
**Why**: Module must be mounted in the application
|
||||
**Dependencies**: BULK-BE-007
|
||||
**Applications**: Backend
|
||||
**Modules**: app/build-app.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None
|
||||
**Permissions**: N/A
|
||||
**Implementation**: Import registerBulkRoutes; call in build-app.ts alongside other modules; pass pool, authenticate, audit
|
||||
**Tests**: Integration test that /admin/bulk/preview and /admin/bulk/execute respond
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Bulk routes accessible at /admin/bulk/*
|
||||
|
||||
## Admin
|
||||
|
||||
### BULK-ADM-001
|
||||
**ID**: BULK-ADM-001
|
||||
**Title**: Bulk Update page shell + product selection
|
||||
**Goal**: Product selection step of the wizard
|
||||
**Why**: Admin needs to pick which products to update
|
||||
**Dependencies**: BULK-BE-007
|
||||
**Applications**: Admin
|
||||
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
|
||||
**Database impact**: None
|
||||
**API contracts**: GET /catalog/products (existing)
|
||||
**Permissions**: products.bulk_update
|
||||
**Implementation**: Full-width page with product table; checkbox column; search/filter bar; selected count badge; "Continue to Configure" button
|
||||
**Tests**: Products load; checkboxes select/deselect; selected count accurate; filter narrows selection
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Admin can select products and see selection count
|
||||
|
||||
### BULK-ADM-002
|
||||
**ID**: BULK-ADM-002
|
||||
**Title**: Operation selector + configuration step
|
||||
**Goal**: Choose and configure the bulk operation type
|
||||
**Why**: Second step of the wizard
|
||||
**Dependencies**: BULK-ADM-001
|
||||
**Applications**: Admin
|
||||
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
|
||||
**Database impact**: None
|
||||
**API contracts**: None yet
|
||||
**Permissions**: products.bulk_update
|
||||
**Implementation**: Two cards: Price / Categories; selecting Price shows sub-options (SET, +%, -%, +FIXED, -FIXED) with parameter inputs; selecting Categories shows (ADD, REMOVE, REPLACE) with category picker; Back button returns to selection
|
||||
**Tests**: Selecting each operation shows correct sub-options; invalid input shows error state
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Admin can select operation type and enter parameters
|
||||
|
||||
### BULK-ADM-003
|
||||
**ID**: BULK-ADM-003
|
||||
**Title**: Preview step
|
||||
**Goal**: Show proposed changes before committing
|
||||
**Why**: Critical safety step — see what will change before writing
|
||||
**Dependencies**: BULK-BE-007, BULK-ADM-002
|
||||
**Applications**: Admin
|
||||
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
|
||||
**Database impact**: None (dry-run)
|
||||
**API contracts**: POST /admin/bulk/preview
|
||||
**Permissions**: products.bulk_update
|
||||
**Implementation**: Call preview API with selected productIds, operation, parameters; display table (Product | Current | Proposed | Status); show summary bar ("18 valid, 2 failed"); Back button to Configure
|
||||
**Tests**: Preview loads; shows correct current→proposed values; failed rows highlighted with error reason
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Preview accurately reflects what execute() would do; no product modified
|
||||
|
||||
### BULK-ADM-004
|
||||
**ID**: BULK-ADM-004
|
||||
**Title**: Confirmation + execution step
|
||||
**Goal**: Require explicit confirmation before executing bulk operation
|
||||
**Why**: Safety barrier — prevent accidental bulk changes
|
||||
**Dependencies**: BULK-ADM-003, BULK-BE-007
|
||||
**Applications**: Admin
|
||||
**Modules**: apps/admin/src/app/(dashboard)/bulk-update/page.tsx
|
||||
**Database impact**: MODIFIES PRODUCTS
|
||||
**API contracts**: POST /admin/bulk/execute
|
||||
**Permissions**: products.bulk_update
|
||||
**Implementation**: Show warning message ("X changes will be applied. This cannot be undone."); [Cancel] [Apply Changes] buttons; on Apply: call execute API; on success: show result summary; on error: show error + retry option
|
||||
**Tests**: Confirmation dialog shows before any write; cancel returns to preview; apply calls execute; result shows success/failure counts
|
||||
**Expected blast radius**: HIGH — modifies products
|
||||
**Definition of Done**: Confirmation required; execute called only after explicit confirmation; result displayed accurately
|
||||
|
||||
### BULK-ADM-005
|
||||
**ID**: BULK-ADM-005
|
||||
**Title**: Navigation sidebar — add Bulk Update link
|
||||
**Goal**: Make bulk update accessible from admin navigation
|
||||
**Why**: Discoverability
|
||||
**Dependencies**: BULK-ADM-001
|
||||
**Applications**: Admin
|
||||
**Modules**: apps/admin/src/lib/permissions.ts
|
||||
**Database impact**: None
|
||||
**API contracts**: None
|
||||
**Permissions**: products.bulk_update
|
||||
**Implementation**: Add to NAV_ITEMS: { href: '/bulk-update', label: 'Actualización masiva', icon: '📦', permission: 'products.bulk_update' }
|
||||
**Tests**: Bulk Update appears in sidebar for admin users
|
||||
**Expected blast radius**: Low
|
||||
**Definition of Done**: Nav item visible in admin sidebar
|
||||
|
||||
## QA
|
||||
|
||||
### BULK-QA-001
|
||||
**ID**: BULK-QA-001
|
||||
**Title**: Bulk update E2E tests
|
||||
**Goal**: End-to-end validation of complete bulk update flow
|
||||
**Why**: Critical safety feature — full flow must work
|
||||
**Dependencies**: BULK-BE-007, BULK-ADM-004
|
||||
**Applications**: QA
|
||||
**Modules**: E2E tests
|
||||
**Tests**:
|
||||
- BULK-QA-UT-001: Select 5 products → preview shows correct proposed prices
|
||||
- BULK-QA-UT-002: Preview does NOT modify any product
|
||||
- BULK-QA-UT-003: Confirm → products actually updated in DB
|
||||
- BULK-QA-UT-004: Audit log entry exists for bulk operation
|
||||
- BULK-QA-UT-005: 5% increase on products with VAT general → correct net price applied
|
||||
- BULK-QA-UT-006: ADD_CATEGORY → categories added without removing existing
|
||||
- BULK-QA-UT-007: REMOVE_CATEGORY → category removed, others preserved
|
||||
- BULK-QA-UT-008: REPLACE_CATEGORIES → only new categories remain
|
||||
- BULK-QA-UT-009: Product without price → failed in preview and in execute (not silently skipped)
|
||||
- BULK-QA-UT-010: Non-admin user → 401 on /admin/bulk/execute
|
||||
**Expected blast radius**: N/A
|
||||
**Definition of Done**: All tests pass; QA sign-off obtained
|
||||
|
||||
---
|
||||
|
||||
## Task Summary Table
|
||||
|
||||
| Task | Layer | Feature | Depends On | Risk | Parallel |
|
||||
|------|-------|---------|-----------|------|---------|
|
||||
| BULK-BE-001 | Backend | Domain types | — | Low | * |
|
||||
| BULK-BE-002 | Backend | Handler interface | BULK-BE-001 | Low | * |
|
||||
| BULK-BE-003 | Backend | Price handlers | BULK-BE-002 | Low | * |
|
||||
| BULK-BE-004 | Backend | Category handlers | BULK-BE-002 | Low | * |
|
||||
| BULK-BE-005 | Backend | BulkService orchestrator | BULK-BE-001..004 | Medium | * |
|
||||
| BULK-BE-006 | Backend | BulkProductRepository | BULK-BE-001 | Low | * |
|
||||
| BULK-BE-007 | Backend | API routes | BULK-BE-005, BULK-BE-006 | Medium | * |
|
||||
| BULK-BE-008 | Backend | Wire into build-app | BULK-BE-007 | Low | * |
|
||||
| BULK-ADM-001 | Admin | Product selection UI | BULK-BE-007 | Low | * |
|
||||
| BULK-ADM-002 | Admin | Operation config step | BULK-ADM-001 | Low | * |
|
||||
| BULK-ADM-003 | Admin | Preview step | BULK-ADM-002, BULK-BE-007 | Low | * |
|
||||
| BULK-ADM-004 | Admin | Confirm + execute step | BULK-ADM-003, BULK-BE-007 | Medium | * |
|
||||
| BULK-ADM-005 | Admin | Sidebar nav link | BULK-ADM-001 | Low | * |
|
||||
| BULK-QA-001 | QA | E2E tests | BULK-BE-007, BULK-ADM-004 | Medium | After all |
|
||||
|
||||
**Parallel group**: BULK-BE-001 and BULK-BE-002 are independent; BULK-BE-003 and BULK-BE-004 are parallel (both depend on BE-002); BULK-BE-005 depends on all four handlers; BULK-BE-006 independent; BULK-BE-007 depends on BE-005 + BE-006; Admin tasks BULK-ADM-001 through BULK-ADM-005 are sequential (wizard flow).
|
||||
|
||||
**Recommended order**:
|
||||
1. BULK-BE-001 + BULK-BE-002 + BULK-BE-006 (parallel, no deps)
|
||||
2. BULK-BE-003 + BULK-BE-004 (parallel, both depend on BE-002)
|
||||
3. BULK-BE-005 (depends on BE-001..004)
|
||||
4. BULK-BE-007 (depends on BE-005, BE-006)
|
||||
5. BULK-BE-008 (depends on BE-007)
|
||||
6. BULK-ADM-001 (depends on BE-007)
|
||||
7. BULK-ADM-002 through BULK-ADM-004 (sequential wizard)
|
||||
8. BULK-ADM-005 (parallel with any ADM task)
|
||||
9. BULK-QA-001 (after everything)
|
||||
|
||||
**High-risk tasks**:
|
||||
- BULK-BE-005: Transaction logic must be correct — wrong rollback = data corruption
|
||||
- BULK-ADM-004: Executes actual DB writes — requires confirmation UI; must not auto-submit
|
||||
- BULK-BE-007: New API endpoints with admin auth — verify auth enforced
|
||||
|
||||
**MVP boundary**: BULK-BE-001 through BULK-BE-008 (backend) + BULK-ADM-001 through BULK-ADM-005. BULK-QA-001 is required before go-live.
|
||||
|
||||
**Migration risks**: None — bulk update is purely additive; no existing data migration required.
|
||||
166
project/specs/bulk-update/TESTS.md
Normal file
166
project/specs/bulk-update/TESTS.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# ADMIN BULK UPDATE — TESTS.md
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### Price Handlers
|
||||
|
||||
```
|
||||
BULK-UT-001: INCREASE_PERCENT 5% on 350 cents → proposed = 368 cents
|
||||
BULK-UT-002: INCREASE_PERCENT 100% on 200 cents → proposed = 400 cents
|
||||
BULK-UT-003: DECREASE_PERCENT 5% on 350 cents → proposed = 333 cents
|
||||
BULK-UT-004: DECREASE_PERCENT 50% on 200 cents → proposed = 100 cents
|
||||
BULK-UT-005: DECREASE_FIXED 50 on 350 cents → proposed = 300 cents
|
||||
BULK-UT-006: INCREASE_FIXED 50 on 350 cents → proposed = 400 cents
|
||||
BULK-UT-007: DECREASE_FIXED 500 on 350 cents → proposed = 0 (floor at 0)
|
||||
BULK-UT-008: SET_PRICE 500 → proposed = 500
|
||||
BULK-UT-009: INCREASE_PERCENT with percent=0 → ValidationResult.valid=false
|
||||
BULK-UT-010: INCREASE_PERCENT with percent=-5 → ValidationResult.valid=false
|
||||
BULK-UT-011: INCREASE_PERCENT with percent=1001 → ValidationResult.valid=false
|
||||
BULK-UT-012: INCREASE_PERCENT without vatRate → ValidationResult.valid=false
|
||||
BULK-UT-013: DECREASE_PERCENT with vatRate=general → validation passes
|
||||
BULK-UT-014: preview() for product without price → status=failed, error=PRICE_NOT_FOUND
|
||||
BULK-UT-015: preview() for product with price → status=valid, proposedPriceCents calculated
|
||||
BULK-UT-016: buildPatch() returns correct { pricing: { netUnitAmountCents, vatRate } }
|
||||
```
|
||||
|
||||
### Category Handlers
|
||||
|
||||
```
|
||||
BULK-UT-020: ADD_CATEGORY to product with [A, B] → proposedCategoryIds = [A, B, C] (C added)
|
||||
BULK-UT-021: ADD_CATEGORY where C already present → no duplicate (deduplicated)
|
||||
BULK-UT-022: REMOVE_CATEGORY from product with [A, B, C] → [A, B]
|
||||
BULK-UT-023: REMOVE_CATEGORY where not present → no change
|
||||
BULK-UT-024: REPLACE_CATEGORIES with [X, Y] → proposedCategoryIds = [X, Y]
|
||||
BULK-UT-025: REPLACE_CATEGORIES with empty array → proposedCategoryIds = []
|
||||
BULK-UT-026: ADD_CATEGORY without categoryId → ValidationResult.valid=false
|
||||
BULK-UT-027: REMOVE_CATEGORY without categoryId → ValidationResult.valid=false
|
||||
BULK-UT-028: buildPatch() returns correct { categoryIds: [...] }
|
||||
```
|
||||
|
||||
### BulkService
|
||||
|
||||
```
|
||||
BULK-UT-030: preview() returns items for all provided productIds
|
||||
BULK-UT-031: preview() returns summary with correct valid/failed counts
|
||||
BULK-UT-032: preview() throws AppError(400) for unknown operation type
|
||||
BULK-UT-033: preview() throws AppError(422) for invalid parameters
|
||||
BULK-UT-034: execute() returns operationId
|
||||
BULK-UT-035: execute() returns COMPLETED when all succeed
|
||||
BULK-UT-036: execute() returns COMPLETED_WITH_ERRORS when some fail
|
||||
BULK-UT-037: execute() calls audit.log() with correct metadata
|
||||
BULK-UT-038: execute() rollback on error → no product modified (verify by checking DB)
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### Database
|
||||
|
||||
```
|
||||
BULK-IT-001: catalog_products.updated_at updated after bulk price change
|
||||
BULK-IT-002: pricing_variants.net_unit_amount_cents updated after SET_PRICE
|
||||
BULK-IT-003: catalog_product_categories updated correctly for ADD_CATEGORY
|
||||
BULK-IT-004: catalog_product_categories updated correctly for REMOVE_CATEGORY
|
||||
BULK-IT-005: catalog_product_categories updated correctly for REPLACE_CATEGORIES
|
||||
BULK-IT-006: Concurrent bulk operations on overlapping product sets → no race condition
|
||||
BULK-IT-007: Price changed by bulk update → subsequent checkout uses new price
|
||||
```
|
||||
|
||||
### API Routes
|
||||
|
||||
```
|
||||
BULK-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
|
||||
BULK-IT-011: POST /admin/bulk/preview with DECREASE_PERCENT → 200 + PreviewResult
|
||||
BULK-IT-012: POST /admin/bulk/preview with ADD_CATEGORY → 200 + PreviewResult
|
||||
BULK-IT-013: POST /admin/bulk/preview with REMOVE_CATEGORY → 200 + PreviewResult
|
||||
BULK-IT-014: POST /admin/bulk/preview with REPLACE_CATEGORIES → 200 + PreviewResult
|
||||
BULK-IT-015: POST /admin/bulk/preview with invalid percent → 422 + error message
|
||||
BULK-IT-016: POST /admin/bulk/preview without session cookie → 401
|
||||
BULK-IT-017: POST /admin/bulk/preview with customer role → 403
|
||||
|
||||
BULK-IT-020: POST /admin/bulk/execute with valid SET_PRICE → 200 + ExecuteResult + DB updated
|
||||
BULK-IT-021: POST /admin/bulk/execute with valid INCREASE_PERCENT → 200 + DB updated
|
||||
BULK-IT-022: POST /admin/bulk/execute with valid ADD_CATEGORY → 200 + DB updated
|
||||
BULK-IT-023: POST /admin/bulk/execute → security_audit_log row created
|
||||
BULK-IT-024: POST /admin/bulk/execute without auth → 401
|
||||
BULK-IT-025: POST /admin/bulk/execute with customer role → 403
|
||||
BULK-IT-026: POST /admin/bulk/execute with partial failures → 200 + COMPLETED_WITH_ERRORS
|
||||
```
|
||||
|
||||
## Admin UI Tests
|
||||
|
||||
```
|
||||
BULK-ADM-UT-001: Page loads with product table and checkboxes
|
||||
BULK-ADM-UT-002: Selecting 3 products updates "3 products selected" counter
|
||||
BULK-ADM-UT-003: Deselecting all shows "0 products selected"
|
||||
BULK-ADM-UT-004: Search filter narrows product list
|
||||
BULK-ADM-UT-005: "Continue" button disabled when 0 products selected
|
||||
|
||||
BULK-ADM-UT-010: Clicking Price card shows SET, +%, -%, +FIXED, -FIXED options
|
||||
BULK-ADM-UT-011: Clicking Categories card shows ADD, REMOVE, REPLACE options
|
||||
BULK-ADM-UT-012: INCREASE_PERCENT selected → percentage input appears
|
||||
BULK-ADM-UT-013: INCREASE_PERCENT with invalid input → error state on field
|
||||
BULK-ADM-UT-014: ADD_CATEGORY selected → category picker appears
|
||||
|
||||
BULK-ADM-UT-020: Preview shows product rows with current → proposed values
|
||||
BULK-ADM-UT-021: Failed rows highlighted in red with error reason
|
||||
BULK-ADM-UT-022: Summary bar shows "X valid, Y failed"
|
||||
BULK-ADM-UT-023: "Back to Configure" navigates back
|
||||
BULK-ADM-UT-024: Preview does NOT show [Apply Changes] button
|
||||
|
||||
BULK-ADM-UT-030: Confirmation dialog shows warning text with correct count
|
||||
BULK-ADM-UT-031: "Cancel" returns to preview without calling execute
|
||||
BULK-ADM-UT-032: "Apply Changes" calls execute API once
|
||||
BULK-ADM-UT-033: Result shows success count and failure count
|
||||
BULK-ADM-UT-034: "New Bulk Update" button resets wizard to step 1
|
||||
```
|
||||
|
||||
## Given/When/Then Acceptance Criteria
|
||||
|
||||
```
|
||||
GIVEN 20 selected products with valid prices
|
||||
WHEN operator previews a 5% price increase
|
||||
THEN no product is modified
|
||||
AND all 20 proposed prices are calculated by the backend
|
||||
AND the preview shows current and proposed values
|
||||
|
||||
GIVEN a product without a price variant
|
||||
WHEN operator previews any price operation on that product
|
||||
THEN the preview item has status=failed and error=PRICE_NOT_FOUND
|
||||
AND the summary shows failed=1
|
||||
|
||||
GIVEN 20 selected products
|
||||
WHEN operator clicks "Apply Changes" on the confirmation step
|
||||
THEN the backend applies the changes in a single transaction
|
||||
AND the security_audit_log receives one entry for the bulk operation
|
||||
AND each affected product's updated_at is updated
|
||||
AND the result shows successful=20, failed=0
|
||||
|
||||
GIVEN a bulk execute with 2 products invalid and 18 valid
|
||||
WHEN the operation runs
|
||||
THEN the 18 valid products are updated
|
||||
AND the 2 invalid products are not modified
|
||||
AND the result shows COMPLETED_WITH_ERRORS with failure details
|
||||
AND the audit log entry captures the partial failure
|
||||
|
||||
GIVEN a non-admin user
|
||||
WHEN they call POST /admin/bulk/execute
|
||||
THEN the backend returns 403 Forbidden
|
||||
AND no audit entry is created
|
||||
AND no product is modified
|
||||
|
||||
GIVEN a preview shows 18 valid changes
|
||||
WHEN a second operator applies a price change to one of those products before execute
|
||||
THEN the execute still applies the original preview's proposed price
|
||||
AND no optimistic concurrency check is silently bypassed (document this behavior)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preview Staleness Policy
|
||||
|
||||
Preview data may be stale when execute runs (another admin changed a price). Current design: execute uses the same calculation on current DB state, which is correct behavior for bulk updates (we want to apply the percentage to whatever the current price is, not the stale preview price).
|
||||
|
||||
If stricter concurrency control is needed (e.g., "apply only if price hasn't changed since preview"), this requires:
|
||||
- `expected_version` or `updated_at` in preview response
|
||||
- Compare at execute time
|
||||
- This is a future enhancement, not MVP scope.
|
||||
Reference in New Issue
Block a user