Assist_Design/docs/PRODUCT-CATALOG-ARCHITECTURE.md
2025-08-27 20:01:46 +09:00

24 KiB

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

  • 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

  • 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

  • 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

'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

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

Use explicit product variants with all relevant attributes:

Internet SKUs:

  • 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:

  • SIM-DATA-ONLY-5GB
  • SIM-DATA-VOICE-50GB
  • SIM-DATA-VOICE-50GB-FAMILY
  • SIM-ACTIVATION-FEE
  • SIM-ADDON-VOICE-MAIL

VPN SKUs:

  • VPN-REMOTE-ACCESS-USA-SF
  • VPN-REMOTE-ACCESS-UK-LONDON
  • VPN-ACTIVATION-FEE

New Catalog API Structure

Main Catalog Endpoint

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
  }>
}
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

const ConfigurePage = () => {
  const [serviceProducts, setServiceProducts] = useState([]);
  const [activationFees, setActivationFees] = useState([]);
  const [addons, setAddons] = useState([]);
  
  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")
        ]);
        
        setServiceProducts(catalog.sim);
        setActivationFees(activation);
        setAddons(addonsData);
      } catch (error) {
        console.error("Failed to load catalog data:", error);
        // Handle error - show fallback or redirect
      }
    };
    
    loadCatalogData();
  }, []);
};

2. SKU Building Pattern

const buildOrderSKUs = () => {
  const skus = [];
  
  // Always add service SKU (detailed format)
  if (selectedPlan?.sku) {
    skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB"
  }
  
  // Add required activation fee
  const defaultActivation = activationFees.find(f => f.isDefault || f.autoAdd);
  if (defaultActivation) {
    skus.push(defaultActivation.sku); // e.g., "SIM-ACTIVATION-FEE"
  }
  
  // Add selected add-ons
  selectedAddons.forEach(addon => {
    skus.push(addon.sku); // e.g., "SIM-ADDON-VOICE-MAIL"
    
    // Add dependent products if needed
    if (addon.requiredProducts) {
      skus.push(...addon.requiredProducts);
    }
  });
  
  return [...new Set(skus)]; // Remove duplicates
};

3. Order Submission Pattern

const submitOrder = () => {
  const skus = buildOrderSKUs();
  
  const params = new URLSearchParams({
    orderType: "SIM",
    skus: JSON.stringify(skus), // Pass complete SKU array
    simType: selectedSimType,
    // ... other customer selections for order header
  });
  
  router.push(`/checkout?${params.toString()}`);
};

SIM Configure Page - Complete Implementation

// 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
  
  // State
  const [loading, setLoading] = useState(true);
  const [selectedPlan, setSelectedPlan] = useState<SimPlan | null>(null);
  const [activationFees, setActivationFees] = useState<ActivationFee[]>([]);
  const [addons, setAddons] = useState<SimAddon[]>([]);
  
  // 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;
    
    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")
        ]);
        
        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[] = [];
    
    // Always add service SKU (detailed format)
    if (selectedPlan?.sku) {
      skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB"
    }
    
    // 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"
    }
    
    // Add selected add-ons
    selectedAddons.forEach(addonSku => {
      skus.push(addonSku); // e.g., "SIM-ADDON-VOICE-MAIL"
    });
    
    // Remove duplicates
    return [...new Set(skus)];
  };

  // Calculate pricing
  const calculatePricing = () => {
    let monthlyTotal = 0;
    let oneTimeTotal = 0;
    
    // Service plan (monthly)
    if (selectedPlan) {
      monthlyTotal += selectedPlan.monthlyPrice || 0;
    }
    
    // Activation fee (one-time)
    const requiredActivation = activationFees.find(f => f.autoAdd || f.isDefault);
    if (requiredActivation) {
      oneTimeTotal += requiredActivation.price;
    }
    
    // Add-ons (monthly)
    selectedAddons.forEach(addonSku => {
      const addon = addons.find(a => a.sku === addonSku);
      if (addon && addon.billingCycle === 'Monthly') {
        monthlyTotal += addon.price;
      }
    });
    
    return { monthlyTotal, oneTimeTotal };
  };

  // Submit order
  const handleSubmit = () => {
    if (!selectedPlan || !simType) return;
    
    const skus = buildOrderSKUs();
    const { monthlyTotal, oneTimeTotal } = calculatePricing();
    
    const params = new URLSearchParams({
      orderType: "SIM", // Always "SIM", not "eSIM"
      skus: JSON.stringify(skus), // Complete SKU array
      
      // Customer selections (for order header)
      planName: selectedPlan.name,
      simType: simType,
      ...(eid && { eid }),
      
      // Pricing (for display)
      monthlyPrice: monthlyTotal.toString(),
      oneTimePrice: oneTimeTotal.toString(),
    });
    
    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>
      
      {/* 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>
          
          <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>
        
        {/* 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>
          
          {/* Service */}
          <div className="flex justify-between mb-2">
            <span>{selectedPlan.name}</span>
            <span>¥{(selectedPlan.monthlyPrice || 0).toLocaleString()}/month</span>
          </div>
          
          {/* 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>
          )}
          
          {/* 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;
          })}
          
          {/* 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

@Controller("catalog")
export class CatalogController {
  
  @Get('sim/activation-fees')
  @ApiOperation({ summary: "Get SIM activation fee options" })
  async getSimActivationFees() {
    return this.catalogService.getSimActivationFees();
  }
  
  @Get('vpn/activation-fees')
  @ApiOperation({ summary: "Get VPN activation fee options" })
  async getVpnActivationFees(@Query('region') region?: string) {
    return this.catalogService.getVpnActivationFees(region);
  }
  
  @Get('internet/installations')
  @ApiOperation({ summary: "Get Internet installation options" })
  async getInternetInstallations() {
    return this.catalogService.getInternetInstallations();
  }
  
  @Get('internet/addons')
  @ApiOperation({ summary: "Get Internet add-on options" })
  async getInternetAddons() {
    return this.catalogService.getInternetAddons();
  }
  
  @Get('sim/addons')
  @ApiOperation({ summary: "Get SIM add-on options" })
  async getSimAddons() {
    return this.catalogService.getSimAddons();
  }
}

Simplified Order Processing

private async createOrderItems(orderId: string, body: CreateOrderBody): Promise<void> {
  // Parse SKUs from selections (detailed format)
  let skus: string[] = [];
  
  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");
    }
  }
  
  // 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 
        : [body.selections.skuAddons];
      skus.push(...addons.map(String));
    }
  }
  
  // Remove duplicates and empty values
  skus = [...new Set(skus.filter(Boolean))];
  
  if (skus.length === 0) {
    throw new BadRequestException("No products specified for order");
  }
  
  // Create OrderItems for each detailed SKU
  const pricebookId = await this.findPortalPricebookId();
  
  for (const sku of skus) {
    const meta = await this.getProductMetaBySku(pricebookId, sku);
    
    if (!meta?.pbeId) {
      this.logger.error({ sku }, "PricebookEntry not found for SKU");
      throw new NotFoundException(`Product not found: ${sku}`);
    }
    
    await this.sf.sobject("OrderItem").create({
      OrderId: orderId,
      PricebookEntryId: meta.pbeId,
      Quantity: 1,
      UnitPrice: null, // Use pricebook price
    });
    
    this.logger.log({ orderId, sku, pbeId: meta.pbeId }, "OrderItem created");
  }
}

Edge Case Handling

1. Multiple Activation Fees Found

Solution: Add Is_Default__c field to Product2

-- 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:

// Catalog service - return default activation fee
const activationFees = records
  .filter(r => r.Item_Class__c === 'Activation')
  .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
  }))
  .sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0));

2. Missing Required Products

Solution: Validation in catalog service with fallback

async getSimActivationFees(): Promise<ActivationFee[]> {
  const fees = await this.queryActivationFees('SIM');
  
  if (fees.length === 0) {
    this.logger.warn("No SIM activation fees found in catalog");
    return [];
  }
  
  if (!fees.some(f => f.isDefault)) {
    this.logger.warn("No default SIM activation fee, marking first as default");
    fees[0].isDefault = true;
  }
  
  return fees;
}

3. Product Relationships

Solution: Add relationship fields to Product2

-- Product2 relationship fields
Required_Products__c    Text(1000)  -- JSON array of required SKUs
Dependent_Products__c   Text(1000)  -- JSON array of auto-added SKUs  
Mutually_Exclusive__c   Text(1000)  -- JSON array of conflicting SKUs

Example Usage:

-- Hikari Denwa service
Required_Products__c = '["INTERNET-ADDON-HIKARI-DENWA-INSTALL"]'

-- Weekend installation  
Mutually_Exclusive__c = '["INTERNET-INSTALL-WEEKDAY"]'

4. Regional/Conditional Products

Solution: Use personalization logic

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

// OLD: Inconsistent field names
Portal_Catalog__c, StockKeepingUnit, Product2Categories1__c

// NEW: Correct Salesforce field names
Portal_Catalog__c, Portal_Accessible__c, StockKeepingUnit, Product2Categories1__c

2. SKU Strategy Change

// OLD: Simple SKUs
"SIM-DATA-VOICE-50GB", "INTERNET-SILVER"

// NEW: Detailed SKUs with all attributes
"SIM-DATA-VOICE-50GB-FAMILY", "INTERNET-SILVER-APT-1G"

3. Order Type Normalization

// 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

// 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"
  activationFees.find(f => f.isDefault).sku, // Activation: "SIM-ACTIVATION-FEE"
  ...selectedAddons                           // Add-ons: ["SIM-ADDON-VOICE-MAIL"]
];

const params = new URLSearchParams({
  orderType: "SIM",
  skus: JSON.stringify(skus),
  simType: selectedSimType,
});

Migration Strategy

Phase 1: Backend Infrastructure (Completed)

  • New catalog endpoints
  • Enhanced order processing with backward compatibility
  • Comprehensive validation and error handling

Phase 2: 🔄 Salesforce Configuration (Next)

-- 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 
SET Is_Default__c = true, Auto_Add__c = true
WHERE Item_Class__c = 'Activation' 
AND StockKeepingUnit IN ('SIM-ACTIVATION-FEE', 'VPN-ACTIVATION-FEE');

Phase 3: 🔄 Frontend Updates (Next)

  • 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)

  • Remove legacy individual SKU field support
  • Remove order-type-specific validation
  • Clean up deprecated frontend code

Benefits Achieved

1. Eliminated Guessing Logic

  • No more hint-based product resolution
  • Explicit SKU arrays from frontend
  • Clear validation and error messages

2. Fixed Missing Activation Fees

  • SIM orders were missing activation fees
  • All order types now include required fees
  • Configurable via Salesforce Product2 fields

3. Improved Maintainability

  • Complex order-type-specific logic
  • Generic SKU processing
  • Easy to add new product types

4. Enhanced Flexibility

  • Hard-coded product relationships
  • Configurable product dependencies
  • Regional and conditional products

5. Better Error Handling

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