Assist_Design/docs/PRODUCT-CATALOG-ARCHITECTURE.md

841 lines
24 KiB
Markdown
Raw Normal View History

2025-08-27 20:01:46 +09:00
# Product Catalog & SKU Architecture - Complete Guide
This document consolidates the complete SKU-based architecture for the catalog and order system, including implementation patterns, edge cases, and migration strategy.
## Core Principles
### 1. **Frontend Responsibility**
2025-08-27 20:01:46 +09:00
- Frontend loads ALL required SKUs from catalog API
- Frontend determines which SKUs to include based on customer selections
- Frontend passes explicit SKU list to backend
### 2. **Backend Simplicity**
2025-08-27 20:01:46 +09:00
- Backend processes provided SKU list without guessing
- No order-type-specific logic for determining products
- Clean validation and error handling
### 3. **Salesforce as Source of Truth**
2025-08-27 20:01:46 +09:00
- All product relationships defined in Salesforce
- Catalog API exposes all necessary product types
- Clear product classification via `Item_Class__c`
## Product Classification
### Item_Class\_\_c Values
2025-08-27 20:01:46 +09:00
```sql
'Service' -- Main customer-selectable products (Internet plans, SIM plans, VPN)
'Activation' -- Required one-time activation fees
'Installation' -- Installation options (one-time or monthly)
'Add-on' -- Optional additional services
```
### Portal Visibility Logic
2025-08-27 20:01:46 +09:00
```sql
Portal_Catalog__c = true -- Visible in main catalog (customer selects)
Portal_Accessible__c = true -- Can be used in orders (includes hidden fees/add-ons)
```
## SKU Naming Strategy
### Detailed SKU Format
2025-08-27 20:01:46 +09:00
Use explicit product variants with all relevant attributes:
**Internet SKUs:**
2025-08-27 20:01:46 +09:00
- `INTERNET-SILVER-HOME-1G`
- `INTERNET-GOLD-APT-1G`
- `INTERNET-PLATINUM-APT-100M`
- `INTERNET-INSTALL-SINGLE`
- `INTERNET-INSTALL-WEEKEND`
- `INTERNET-ADDON-HIKARI-DENWA-INSTALL`
**SIM SKUs:**
2025-08-27 20:01:46 +09:00
- `SIM-DATA-ONLY-5GB`
- `SIM-DATA-VOICE-50GB`
- `SIM-DATA-VOICE-50GB-FAMILY`
- `SIM-ACTIVATION-FEE`
- `SIM-ADDON-VOICE-MAIL`
**VPN SKUs:**
2025-08-27 20:01:46 +09:00
- `VPN-REMOTE-ACCESS-USA-SF`
- `VPN-REMOTE-ACCESS-UK-LONDON`
- `VPN-ACTIVATION-FEE`
## New Catalog API Structure
### Main Catalog Endpoint
2025-08-27 20:01:46 +09:00
```typescript
GET /catalog
// Returns only customer-selectable products (Portal_Catalog__c = true)
{
internet: Array<{
id: string,
name: string,
sku: string, // Detailed format: INTERNET-SILVER-APT-1G
tier: string,
offeringType: string,
monthlyPrice: number
}>,
sim: Array<{
id: string,
name: string,
sku: string, // Detailed format: SIM-DATA-VOICE-50GB
dataSize: string,
planType: string,
hasFamilyDiscount: boolean
}>,
vpn: Array<{
id: string,
name: string,
sku: string // Detailed format: VPN-REMOTE-ACCESS-USA-SF
}>
}
```
### Related Products Endpoints
2025-08-27 20:01:46 +09:00
```typescript
GET /catalog/sim/activation-fees
// Returns SIM activation products (Item_Class__c = 'Activation')
Array<{id: string, name: string, sku: string, price: number, isDefault: boolean}>
GET /catalog/vpn/activation-fees?region=<region>
// Returns VPN activation products (Item_Class__c = 'Activation')
Array<{id: string, name: string, sku: string, price: number, isDefault: boolean}>
GET /catalog/internet/installations
// Returns Internet installation options (Item_Class__c = 'Installation')
Array<{id: string, name: string, sku: string, billingCycle: string, price: number, installmentType: string}>
GET /catalog/internet/addons
// Returns Internet add-ons (Item_Class__c = 'Add-on')
Array<{id: string, name: string, sku: string, price: number, billingCycle: string, autoAdd: boolean}>
GET /catalog/sim/addons
// Returns SIM add-ons (Item_Class__c = 'Add-on')
Array<{id: string, name: string, sku: string, price: number, billingCycle: string}>
```
## Frontend Implementation Patterns
### 1. Data Loading Pattern
2025-08-27 20:01:46 +09:00
```typescript
const ConfigurePage = () => {
const [serviceProducts, setServiceProducts] = useState([]);
const [activationFees, setActivationFees] = useState([]);
const [addons, setAddons] = useState([]);
2025-08-27 20:01:46 +09:00
useEffect(() => {
const loadCatalogData = async () => {
try {
// Load all required data in parallel
const [catalog, activation, addonsData] = await Promise.all([
authenticatedApi.get("/catalog"),
authenticatedApi.get("/catalog/sim/activation-fees"),
authenticatedApi.get("/catalog/sim/addons"),
2025-08-27 20:01:46 +09:00
]);
2025-08-27 20:01:46 +09:00
setServiceProducts(catalog.sim);
setActivationFees(activation);
setAddons(addonsData);
} catch (error) {
console.error("Failed to load catalog data:", error);
// Handle error - show fallback or redirect
}
};
2025-08-27 20:01:46 +09:00
loadCatalogData();
}, []);
};
```
### 2. SKU Building Pattern
2025-08-27 20:01:46 +09:00
```typescript
const buildOrderSKUs = () => {
const skus = [];
2025-08-27 20:01:46 +09:00
// Always add service SKU (detailed format)
if (selectedPlan?.sku) {
skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB"
}
2025-08-27 20:01:46 +09:00
// Add required activation fee
const defaultActivation = activationFees.find(f => f.isDefault || f.autoAdd);
if (defaultActivation) {
skus.push(defaultActivation.sku); // e.g., "SIM-ACTIVATION-FEE"
}
2025-08-27 20:01:46 +09:00
// Add selected add-ons
selectedAddons.forEach(addon => {
skus.push(addon.sku); // e.g., "SIM-ADDON-VOICE-MAIL"
2025-08-27 20:01:46 +09:00
// Add dependent products if needed
if (addon.requiredProducts) {
skus.push(...addon.requiredProducts);
}
});
2025-08-27 20:01:46 +09:00
return [...new Set(skus)]; // Remove duplicates
};
```
### 3. Order Submission Pattern
2025-08-27 20:01:46 +09:00
```typescript
const submitOrder = () => {
const skus = buildOrderSKUs();
2025-08-27 20:01:46 +09:00
const params = new URLSearchParams({
orderType: "SIM",
skus: JSON.stringify(skus), // Pass complete SKU array
simType: selectedSimType,
// ... other customer selections for order header
});
2025-08-27 20:01:46 +09:00
router.push(`/checkout?${params.toString()}`);
};
```
## SIM Configure Page - Complete Implementation
```typescript
// apps/portal/src/app/catalog/sim/configure/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api";
interface SimPlan {
id: string;
name: string;
sku: string; // Detailed format: SIM-DATA-VOICE-50GB
dataSize: string;
planType: string;
hasFamilyDiscount: boolean;
monthlyPrice: number;
}
interface ActivationFee {
id: string;
name: string;
sku: string; // SIM-ACTIVATION-FEE
price: number;
isDefault: boolean;
autoAdd: boolean;
}
interface SimAddon {
id: string;
name: string;
sku: string; // SIM-ADDON-VOICE-MAIL
price: number;
billingCycle: string;
description?: string;
}
export default function SimConfigurePage() {
const router = useRouter();
const params = useSearchParams();
const planSku = params.get("sku"); // Detailed SKU from catalog
2025-08-27 20:01:46 +09:00
// State
const [loading, setLoading] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<SimPlan | null>(null);
const [activationFees, setActivationFees] = useState<ActivationFee[]>([]);
const [addons, setAddons] = useState<SimAddon[]>([]);
2025-08-27 20:01:46 +09:00
// Customer selections
const [simType, setSimType] = useState<"eSIM" | "Physical SIM" | null>(null);
const [selectedAddons, setSelectedAddons] = useState<string[]>([]);
const [eid, setEid] = useState("");
// Load catalog data
useEffect(() => {
let mounted = true;
2025-08-27 20:01:46 +09:00
const loadCatalogData = async () => {
if (!planSku) {
router.push('/catalog/sim');
return;
}
try {
// Load all required data in parallel
const [catalog, activation, addonsData] = await Promise.all([
authenticatedApi.get<{sim: SimPlan[]}>("/catalog"),
authenticatedApi.get<ActivationFee[]>("/catalog/sim/activation-fees"),
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons")
]);
2025-08-27 20:01:46 +09:00
if (mounted) {
const plan = catalog.sim.find(p => p.sku === planSku);
if (plan) {
setSelectedPlan(plan);
setActivationFees(activation);
setAddons(addonsData);
} else {
router.push('/catalog/sim');
}
}
} catch (error) {
console.error("Failed to load catalog data:", error);
if (mounted) router.push('/catalog/sim');
} finally {
if (mounted) setLoading(false);
}
};
loadCatalogData();
return () => { mounted = false; };
}, [planSku, router]);
// Build complete SKU list based on selections
const buildOrderSKUs = (): string[] => {
const skus: string[] = [];
2025-08-27 20:01:46 +09:00
// Always add service SKU (detailed format)
if (selectedPlan?.sku) {
skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB"
}
2025-08-27 20:01:46 +09:00
// Add required activation fee (auto-add or default)
const requiredActivation = activationFees.find(f => f.autoAdd || f.isDefault);
if (requiredActivation) {
skus.push(requiredActivation.sku); // "SIM-ACTIVATION-FEE"
}
2025-08-27 20:01:46 +09:00
// Add selected add-ons
selectedAddons.forEach(addonSku => {
skus.push(addonSku); // e.g., "SIM-ADDON-VOICE-MAIL"
});
2025-08-27 20:01:46 +09:00
// Remove duplicates
return [...new Set(skus)];
};
// Calculate pricing
const calculatePricing = () => {
let monthlyTotal = 0;
let oneTimeTotal = 0;
2025-08-27 20:01:46 +09:00
// Service plan (monthly)
if (selectedPlan) {
monthlyTotal += selectedPlan.monthlyPrice || 0;
}
2025-08-27 20:01:46 +09:00
// Activation fee (one-time)
const requiredActivation = activationFees.find(f => f.autoAdd || f.isDefault);
if (requiredActivation) {
oneTimeTotal += requiredActivation.price;
}
2025-08-27 20:01:46 +09:00
// Add-ons (monthly)
selectedAddons.forEach(addonSku => {
const addon = addons.find(a => a.sku === addonSku);
if (addon && addon.billingCycle === 'Monthly') {
monthlyTotal += addon.price;
}
});
2025-08-27 20:01:46 +09:00
return { monthlyTotal, oneTimeTotal };
};
// Submit order
const handleSubmit = () => {
if (!selectedPlan || !simType) return;
2025-08-27 20:01:46 +09:00
const skus = buildOrderSKUs();
const { monthlyTotal, oneTimeTotal } = calculatePricing();
2025-08-27 20:01:46 +09:00
const params = new URLSearchParams({
orderType: "SIM", // Always "SIM", not "eSIM"
skus: JSON.stringify(skus), // Complete SKU array
2025-08-27 20:01:46 +09:00
// Customer selections (for order header)
planName: selectedPlan.name,
simType: simType,
...(eid && { eid }),
2025-08-27 20:01:46 +09:00
// Pricing (for display)
monthlyPrice: monthlyTotal.toString(),
oneTimePrice: oneTimeTotal.toString(),
});
2025-08-27 20:01:46 +09:00
router.push(`/checkout?${params.toString()}`);
};
if (loading) {
return <div>Loading...</div>;
}
if (!selectedPlan) {
return <div>Plan not found</div>;
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Configure {selectedPlan.name}</h1>
2025-08-27 20:01:46 +09:00
{/* SIM Type Selection */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">SIM Type</h2>
<div className="grid grid-cols-2 gap-4">
<label className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="simType"
value="Physical SIM"
checked={simType === "Physical SIM"}
onChange={(e) => setSimType(e.target.value as "Physical SIM")}
className="mr-3"
/>
<div>
<div className="font-medium">Physical SIM</div>
<div className="text-sm text-gray-600">Traditional SIM card</div>
</div>
</label>
2025-08-27 20:01:46 +09:00
<label className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="simType"
value="eSIM"
checked={simType === "eSIM"}
onChange={(e) => setSimType(e.target.value as "eSIM")}
className="mr-3"
/>
<div>
<div className="font-medium">eSIM</div>
<div className="text-sm text-gray-600">Digital SIM</div>
</div>
</label>
</div>
2025-08-27 20:01:46 +09:00
{/* EID input for eSIM */}
{simType === "eSIM" && (
<div className="mt-4">
<label className="block text-sm font-medium mb-2">
EID (Required for eSIM)
</label>
<input
type="text"
value={eid}
onChange={(e) => setEid(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
placeholder="Enter your device EID"
/>
</div>
)}
</section>
{/* Add-ons Selection */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">Add-ons (Optional)</h2>
<div className="space-y-3">
{addons.map(addon => (
<label key={addon.id} className="flex items-center border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={selectedAddons.includes(addon.sku)}
onChange={(e) => {
if (e.target.checked) {
setSelectedAddons([...selectedAddons, addon.sku]);
} else {
setSelectedAddons(selectedAddons.filter(sku => sku !== addon.sku));
}
}}
className="mr-3"
/>
<div className="flex-1">
<div className="font-medium">{addon.name}</div>
{addon.description && (
<div className="text-sm text-gray-600">{addon.description}</div>
)}
</div>
<div className="text-right">
<div className="font-medium">¥{addon.price.toLocaleString()}</div>
<div className="text-sm text-gray-600">{addon.billingCycle}</div>
</div>
</label>
))}
</div>
</section>
{/* Pricing Summary */}
<section className="mb-8">
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold mb-3">Order Summary</h3>
2025-08-27 20:01:46 +09:00
{/* Service */}
<div className="flex justify-between mb-2">
<span>{selectedPlan.name}</span>
<span>¥{(selectedPlan.monthlyPrice || 0).toLocaleString()}/month</span>
</div>
2025-08-27 20:01:46 +09:00
{/* Activation Fee */}
{activationFees.find(f => f.autoAdd || f.isDefault) && (
<div className="flex justify-between mb-2">
<span>Activation Fee</span>
<span>¥{activationFees.find(f => f.autoAdd || f.isDefault)!.price.toLocaleString()} (one-time)</span>
</div>
)}
2025-08-27 20:01:46 +09:00
{/* Add-ons */}
{selectedAddons.map(addonSku => {
const addon = addons.find(a => a.sku === addonSku);
return addon ? (
<div key={addonSku} className="flex justify-between mb-2">
<span>{addon.name}</span>
<span>¥{addon.price.toLocaleString()}/{addon.billingCycle.toLowerCase()}</span>
</div>
) : null;
})}
2025-08-27 20:01:46 +09:00
{/* Totals */}
<hr className="my-3" />
<div className="flex justify-between font-semibold">
<span>Monthly Total</span>
<span>¥{calculatePricing().monthlyTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between font-semibold">
<span>One-time Total</span>
<span>¥{calculatePricing().oneTimeTotal.toLocaleString()}</span>
</div>
</div>
</section>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={!selectedPlan || !simType || (simType === "eSIM" && !eid)}
className="w-full bg-blue-600 text-white font-bold py-3 px-6 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue to Checkout
</button>
</div>
);
}
```
## Backend Implementation
### Enhanced Catalog Controller
2025-08-27 20:01:46 +09:00
```typescript
@Controller("catalog")
export class CatalogController {
@Get("sim/activation-fees")
2025-08-27 20:01:46 +09:00
@ApiOperation({ summary: "Get SIM activation fee options" })
async getSimActivationFees() {
return this.catalogService.getSimActivationFees();
}
@Get("vpn/activation-fees")
2025-08-27 20:01:46 +09:00
@ApiOperation({ summary: "Get VPN activation fee options" })
async getVpnActivationFees(@Query("region") region?: string) {
2025-08-27 20:01:46 +09:00
return this.catalogService.getVpnActivationFees(region);
}
@Get("internet/installations")
2025-08-27 20:01:46 +09:00
@ApiOperation({ summary: "Get Internet installation options" })
async getInternetInstallations() {
return this.catalogService.getInternetInstallations();
}
@Get("internet/addons")
2025-08-27 20:01:46 +09:00
@ApiOperation({ summary: "Get Internet add-on options" })
async getInternetAddons() {
return this.catalogService.getInternetAddons();
}
@Get("sim/addons")
2025-08-27 20:01:46 +09:00
@ApiOperation({ summary: "Get SIM add-on options" })
async getSimAddons() {
return this.catalogService.getSimAddons();
}
}
```
### Simplified Order Processing
2025-08-27 20:01:46 +09:00
```typescript
private async createOrderItems(orderId: string, body: CreateOrderBody): Promise<void> {
// Parse SKUs from selections (detailed format)
let skus: string[] = [];
2025-08-27 20:01:46 +09:00
if (body.selections.skus && typeof body.selections.skus === 'string') {
try {
skus = JSON.parse(body.selections.skus);
} catch (e) {
this.logger.warn("Failed to parse SKUs JSON, falling back to individual fields");
}
}
2025-08-27 20:01:46 +09:00
// Fallback to individual SKU fields for backward compatibility
if (skus.length === 0) {
if (body.selections.skuService) skus.push(body.selections.skuService as string);
if (body.selections.skuActivation) skus.push(body.selections.skuActivation as string);
if (body.selections.skuInstall) skus.push(body.selections.skuInstall as string);
if (body.selections.skuAddons) {
const addons = Array.isArray(body.selections.skuAddons)
? body.selections.skuAddons
2025-08-27 20:01:46 +09:00
: [body.selections.skuAddons];
skus.push(...addons.map(String));
}
}
2025-08-27 20:01:46 +09:00
// Remove duplicates and empty values
skus = [...new Set(skus.filter(Boolean))];
2025-08-27 20:01:46 +09:00
if (skus.length === 0) {
throw new BadRequestException("No products specified for order");
}
2025-08-27 20:01:46 +09:00
// Create OrderItems for each detailed SKU
const pricebookId = await this.findPortalPricebookId();
2025-08-27 20:01:46 +09:00
for (const sku of skus) {
const meta = await this.getProductMetaBySku(pricebookId, sku);
2025-08-27 20:01:46 +09:00
if (!meta?.pbeId) {
this.logger.error({ sku }, "PricebookEntry not found for SKU");
throw new NotFoundException(`Product not found: ${sku}`);
}
2025-08-27 20:01:46 +09:00
await this.sf.sobject("OrderItem").create({
OrderId: orderId,
PricebookEntryId: meta.pbeId,
Quantity: 1,
UnitPrice: null, // Use pricebook price
});
2025-08-27 20:01:46 +09:00
this.logger.log({ orderId, sku, pbeId: meta.pbeId }, "OrderItem created");
}
}
```
## Edge Case Handling
### 1. Multiple Activation Fees Found
2025-08-27 20:01:46 +09:00
**Solution**: Add `Is_Default__c` field to Product2
```sql
-- Product2 fields for disambiguation
Is_Default__c Checkbox -- Default choice when multiple options exist
Display_Order__c Number -- Ordering for UI selection
Auto_Add__c Checkbox -- Automatically add to orders (required fees)
```
**Implementation**:
2025-08-27 20:01:46 +09:00
```typescript
// Catalog service - return default activation fee
const activationFees = records
.filter(r => r.Item_Class__c === "Activation")
2025-08-27 20:01:46 +09:00
.map(r => ({
id: r.Id,
sku: r.StockKeepingUnit, // Correct field name
name: r.Name,
price: r.PricebookEntries?.records?.[0]?.UnitPrice || 0,
isDefault: r.Is_Default__c === true,
autoAdd: r.Auto_Add__c === true,
2025-08-27 20:01:46 +09:00
}))
.sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0));
```
### 2. Missing Required Products
2025-08-27 20:01:46 +09:00
**Solution**: Validation in catalog service with fallback
```typescript
async getSimActivationFees(): Promise<ActivationFee[]> {
const fees = await this.queryActivationFees('SIM');
2025-08-27 20:01:46 +09:00
if (fees.length === 0) {
this.logger.warn("No SIM activation fees found in catalog");
return [];
}
2025-08-27 20:01:46 +09:00
if (!fees.some(f => f.isDefault)) {
this.logger.warn("No default SIM activation fee, marking first as default");
fees[0].isDefault = true;
}
2025-08-27 20:01:46 +09:00
return fees;
}
```
### 3. Product Relationships
2025-08-27 20:01:46 +09:00
**Solution**: Add relationship fields to Product2
```sql
-- Product2 relationship fields
Required_Products__c Text(1000) -- JSON array of required SKUs
Dependent_Products__c Text(1000) -- JSON array of auto-added SKUs
2025-08-27 20:01:46 +09:00
Mutually_Exclusive__c Text(1000) -- JSON array of conflicting SKUs
```
**Example Usage**:
2025-08-27 20:01:46 +09:00
```sql
-- Hikari Denwa service
Required_Products__c = '["INTERNET-ADDON-HIKARI-DENWA-INSTALL"]'
-- Weekend installation
2025-08-27 20:01:46 +09:00
Mutually_Exclusive__c = '["INTERNET-INSTALL-WEEKDAY"]'
```
### 4. Regional/Conditional Products
2025-08-27 20:01:46 +09:00
**Solution**: Use personalization logic
```typescript
GET /catalog/personalized
// Already filters by account eligibility
// Extend to include conditional activation fees
GET /catalog/vpn/activation-fees?region=USA-SF
// Filter activation fees by region if needed
```
## Key Changes Summary
### 1. Field Name Corrections
2025-08-27 20:01:46 +09:00
```typescript
// OLD: Inconsistent field names
(Portal_Catalog__c, StockKeepingUnit, Product2Categories1__c);
2025-08-27 20:01:46 +09:00
// NEW: Correct Salesforce field names
(Portal_Catalog__c, Portal_Accessible__c, StockKeepingUnit, Product2Categories1__c);
2025-08-27 20:01:46 +09:00
```
### 2. SKU Strategy Change
2025-08-27 20:01:46 +09:00
```typescript
// OLD: Simple SKUs
("SIM-DATA-VOICE-50GB", "INTERNET-SILVER");
2025-08-27 20:01:46 +09:00
// NEW: Detailed SKUs with all attributes
("SIM-DATA-VOICE-50GB-FAMILY", "INTERNET-SILVER-APT-1G");
2025-08-27 20:01:46 +09:00
```
### 3. Order Type Normalization
2025-08-27 20:01:46 +09:00
```typescript
// OLD: Separate eSIM and SIM types
orderType: simType === "eSIM" ? "eSIM" : "SIM"
// NEW: Always use "SIM", differentiate with simType field
orderType: "SIM",
simType: selectedSimType, // "eSIM" or "Physical SIM"
```
### 4. Complete SKU Control
2025-08-27 20:01:46 +09:00
```typescript
// OLD: Incomplete product coverage
const params = new URLSearchParams({
orderType: "SIM",
skuService: plan.sku,
// Missing activation fee!
});
// NEW: Complete SKU coverage
const skus = [
selectedPlan.sku, // Service: "SIM-DATA-VOICE-50GB"
2025-08-27 20:01:46 +09:00
activationFees.find(f => f.isDefault).sku, // Activation: "SIM-ACTIVATION-FEE"
...selectedAddons, // Add-ons: ["SIM-ADDON-VOICE-MAIL"]
2025-08-27 20:01:46 +09:00
];
const params = new URLSearchParams({
orderType: "SIM",
skus: JSON.stringify(skus),
simType: selectedSimType,
});
```
## Migration Strategy
### Phase 1: ✅ Backend Infrastructure (Completed)
2025-08-27 20:01:46 +09:00
- ✅ New catalog endpoints
- ✅ Enhanced order processing with backward compatibility
- ✅ Comprehensive validation and error handling
### Phase 2: 🔄 Salesforce Configuration (Next)
2025-08-27 20:01:46 +09:00
```sql
-- Add edge case handling fields to Product2
ALTER TABLE Product2 ADD COLUMN Is_Default__c BOOLEAN DEFAULT false;
ALTER TABLE Product2 ADD COLUMN Display_Order__c NUMBER(18,0);
ALTER TABLE Product2 ADD COLUMN Auto_Add__c BOOLEAN DEFAULT false;
-- Mark existing activation fees as default
UPDATE Product2
2025-08-27 20:01:46 +09:00
SET Is_Default__c = true, Auto_Add__c = true
WHERE Item_Class__c = 'Activation'
2025-08-27 20:01:46 +09:00
AND StockKeepingUnit IN ('SIM-ACTIVATION-FEE', 'VPN-ACTIVATION-FEE');
```
### Phase 3: 🔄 Frontend Updates (Next)
2025-08-27 20:01:46 +09:00
- Update SIM configure page to use new endpoints
- Update VPN page to load activation fees
- Update Internet page to use installation/addon endpoints
- Test with both old and new SKU formats
### Phase 4: 🔄 Cleanup (Final)
2025-08-27 20:01:46 +09:00
- Remove legacy individual SKU field support
- Remove order-type-specific validation
- Clean up deprecated frontend code
## Benefits Achieved
### 1. **Eliminated Guessing Logic**
2025-08-27 20:01:46 +09:00
- ❌ No more hint-based product resolution
- ✅ Explicit SKU arrays from frontend
- ✅ Clear validation and error messages
### 2. **Fixed Missing Activation Fees**
2025-08-27 20:01:46 +09:00
- ❌ SIM orders were missing activation fees
- ✅ All order types now include required fees
- ✅ Configurable via Salesforce Product2 fields
### 3. **Improved Maintainability**
2025-08-27 20:01:46 +09:00
- ❌ Complex order-type-specific logic
- ✅ Generic SKU processing
- ✅ Easy to add new product types
### 4. **Enhanced Flexibility**
2025-08-27 20:01:46 +09:00
- ❌ Hard-coded product relationships
- ✅ Configurable product dependencies
- ✅ Regional and conditional products
### 5. **Better Error Handling**
2025-08-27 20:01:46 +09:00
- ❌ Cryptic "product not found" errors
- ✅ Specific SKU validation messages
- ✅ Business rule validation
This architecture provides **complete SKU control**, **predictable behavior**, and **easy debugging** while maintaining **backward compatibility** during the migration period.