AgentSkillsCN

tsh-stock-realtime

为TSH客户端控制台实现实时库存同步。适用于以下场景: (1) 排查库存同步问题(Webhook未触发、库存未更新); (2) 深入理解Webhook事件的处理机制; (3) 实现受库存变动影响的功能; (4) 排查Zoho与应用之间库存数据的差异; (5) 新增库存变动相关的交易处理器; (6) 监控库存同步的健康状况; (7) 深入理解多层次同步架构。

SKILL.md
--- frontmatter
name: tsh-stock-realtime
description: |
  Real-time stock synchronization for TSH Clients Console. Use when:
  (1) Debugging stock sync issues (webhooks not firing, stock not updating)
  (2) Understanding webhook event handling
  (3) Implementing stock-affecting features
  (4) Troubleshooting stock discrepancies between Zoho and app
  (5) Adding new stock-affecting transaction handlers
  (6) Monitoring stock sync health
  (7) Understanding the multi-layer sync architecture

TSH Stock Real-Time Synchronization

Architecture Overview

code
┌─────────────────────────────────────────────────────────────────────┐
│                   TSH STOCK SYNC ARCHITECTURE                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  LAYER 1: WEBHOOKS (Instant, < 5 seconds)                          │
│  ├── Zoho transaction occurs                                        │
│  ├── POST /api/webhooks/zoho                                        │
│  ├── quickSyncStock(itemIds)                                        │
│  └── Update Redis + Revalidate ISR                                  │
│                                                                     │
│  LAYER 2: PERIODIC SYNC (Every 15 minutes)                         │
│  ├── Vercel Cron job                                                │
│  ├── POST /api/sync/stock                                           │
│  ├── syncStockFromBooks()                                           │
│  └── Full cache refresh                                             │
│                                                                     │
│  LAYER 3: ON-DEMAND SYNC (Manual trigger)                          │
│  ├── Admin triggers via API                                         │
│  ├── GET /api/sync/stock?action=sync&force=true                     │
│  └── Immediate full refresh                                         │
│                                                                     │
│  LAYER 4: HEALTH MONITORING (Continuous)                           │
│  ├── GET /api/sync/stock?action=status                              │
│  ├── Check: itemCount > 400                                         │
│  ├── Check: cache age < 4 hours                                     │
│  └── Alert if unhealthy                                             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Webhook Event Reference

Stock-Affecting Transactions

TransactionStock EffectWebhook EventHandler
Invoice CreatedDecreasesinvoice.createdrevalidateProducts()
Invoice UpdatedMay changeinvoice.updatedrevalidateProducts()
Bill CreatedIncreasesbill.createdrevalidateProducts()
Bill UpdatedMay changebill.updatedrevalidateProducts()
Sales Order CreatedCommits stocksalesorder.createdrevalidateProducts()
Sales Order UpdatedMay releasesalesorder.updatedrevalidateProducts()
Credit Note CreatedMay increasecreditnote.createdrevalidateProducts()
Inventory AdjustmentChangesinventoryadjustment.createdrevalidateProducts()
Sales Return ReceivedIncreasessalesreturnreceive.createdrevalidateProducts()
Package ShippedPhysical decreasepackage.shippedrevalidateProducts()
Item UpdatedMay changeitem.updatedrevalidateProducts()

Webhook Handler Location

typescript
// src/app/api/webhooks/zoho/route.ts

export async function POST(request: Request) {
  const payload = await request.json();
  const { event_type, resource_name, resource_id } = payload;

  // Extract affected item IDs from payload
  const itemIds = extractItemIds(payload);

  switch (resource_name) {
    case "invoice":
    case "bill":
    case "salesorder":
    case "creditnote":
    case "inventoryadjustment":
    case "salesreturnreceive":
    case "package":
      // Sync stock for affected items
      await quickSyncStock(itemIds);
      await revalidateTag('products');
      break;

    case "item":
      // Direct item update
      await quickSyncStock([resource_id]);
      await revalidateTag('products');
      break;
  }
}

Quick Sync Function

typescript
// src/lib/zoho/stock-cache.ts

export async function quickSyncStock(
  itemIds: string[],
  reason: string
): Promise<void> {
  const WHOLESALE_LOCATION_NAME = 'Main WareHouse';

  for (const itemId of itemIds) {
    try {
      // Fetch item with locations from Zoho Books
      const item = await getItemWithLocations(itemId);

      // Extract warehouse-specific stock
      const location = item.locations?.find(
        loc => loc.location_name === WHOLESALE_LOCATION_NAME
      );
      const stock = location?.location_available_for_sale_stock || 0;

      // Update Redis cache
      await redis.set(`stock:${itemId}`, stock, { ex: STOCK_CACHE_TTL });

      console.log(`[quickSyncStock] ${itemId}: ${stock} (${reason})`);
    } catch (error) {
      console.error(`[quickSyncStock] Failed for ${itemId}:`, error);
    }
  }
}

Unified Stock Functions

ALWAYS Use These Functions

typescript
// For single item (product detail page)
import { getUnifiedStock } from '@/lib/zoho/stock-cache';

const { stock, source } = await getUnifiedStock(itemId, {
  fetchOnMiss: true,      // Fetch from API if not in cache
  context: 'product-detail',
});

// source: 'cache' | 'api' | 'unavailable'
typescript
// For multiple items (shop list page)
import { getUnifiedStockBulk } from '@/lib/zoho/stock-cache';

const stockMap = await getUnifiedStockBulk(itemIds, {
  context: 'shop-list',
});

// Returns: Map<itemId, stock>

Why Unified Functions?

yaml
PROBLEM SOLVED:
  - Shop list showed different stock than product detail page
  - Root cause: Different fallback sources when Redis cache missed
  - List fell back to Books API (total across ALL warehouses)
  - Detail fell back to Inventory API (warehouse-specific)

SOLUTION:
  - Unified functions use ONLY Redis cache as stock source
  - On cache miss, detail page fetches from API and caches result
  - Next list page load sees the cached value
  - NEVER fall back to Books item.available_stock

Stock Data Flow

code
┌──────────────────────────────────────────────────────────────────┐
│                     STOCK DATA FLOW                              │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ZOHO BOOKS/INVENTORY                                            │
│  │                                                               │
│  ├── Transaction occurs (Invoice, Bill, etc.)                    │
│  │   │                                                           │
│  │   └──► Webhook fires to /api/webhooks/zoho                    │
│  │        │                                                      │
│  │        ├── Extract item IDs from payload                      │
│  │        │                                                      │
│  │        └──► quickSyncStock(itemIds)                           │
│  │             │                                                 │
│  │             ├── Fetch item with locations from API            │
│  │             │                                                 │
│  │             ├── Extract Main WareHouse stock                  │
│  │             │   └── location_available_for_sale_stock         │
│  │             │                                                 │
│  │             └──► Update Redis cache                           │
│  │                  │                                            │
│  │                  └──► Revalidate Next.js ISR cache            │
│  │                                                               │
│  └── Periodic sync (every 15 min)                                │
│      │                                                           │
│      └──► Full cache refresh from Books API                      │
│                                                                  │
│  REDIS CACHE (Upstash)                                           │
│  │                                                               │
│  ├── Key: stock:{itemId}                                         │
│  ├── Value: number (warehouse-specific stock)                    │
│  ├── TTL: 4 hours (14400 seconds)                                │
│  │                                                               │
│  └──► Read by: getUnifiedStock(), getUnifiedStockBulk()          │
│                                                                  │
│  NEXT.JS APPLICATION                                             │
│  │                                                               │
│  ├── Shop List Page                                              │
│  │   └── getUnifiedStockBulk(itemIds) → Redis                    │
│  │                                                               │
│  └── Product Detail Page                                         │
│      └── getUnifiedStock(itemId) → Redis → API (if miss)         │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

Cache Configuration

SettingValueNotes
Stock Cache TTL4 hours (14400s)Reduced from 24h for freshness
Redis ProviderUpstashhttps://fine-mole-41883.upstash.io
Key Patternstock:{itemId}One key per item
Freshness Check10 minutesFor periodic sync trigger
Sync FrequencyEvery 15 minutesVercel Cron

Debugging Stock Sync

Check Cache Status

bash
# Check cache health
curl "https://www.tsh.sale/api/sync/stock?action=status"

# Expected response:
{
  "status": "healthy",
  "itemCount": 450,
  "lastSync": "2025-12-11T10:30:00Z",
  "cacheAge": "2 hours"
}

Force Full Sync

bash
# Trigger full sync
curl "https://www.tsh.sale/api/sync/stock?action=sync&secret=tsh-stock-sync-2024&force=true"

# Expected response:
{
  "success": true,
  "itemsSynced": 450,
  "duration": "45s"
}

Revalidate Caches

bash
# Revalidate all caches
curl "https://www.tsh.sale/api/revalidate?tag=all&secret=tsh-revalidate-2024"

# Revalidate products only
curl "https://www.tsh.sale/api/revalidate?tag=products&secret=tsh-revalidate-2024"

Check Specific Item Stock

bash
# Debug specific item
curl "https://www.tsh.sale/api/debug/stock?itemId=2646610000109854052"

# Expected response:
{
  "itemId": "2646610000109854052",
  "cachedStock": 15,
  "apiStock": 15,
  "source": "cache",
  "warehouse": "Main WareHouse"
}

Troubleshooting

Stock Shows 0 When It Shouldn't

CheckCommandFix
Cache empty??action=statusRun full sync
Wrong warehouse?Check location_nameMust be "Main WareHouse"
Stale cache?Check lastSyncForce sync if > 4h old
Webhook not firing?Check Zoho webhook logsReconfigure webhook

Stock Discrepancy Between List/Detail

CauseSymptomsFix
Cache missDetail shows differentRun sync, check TTL
Wrong functionUsing deprecated fnUse getUnifiedStock()
Books fallbackList shows ALL warehouseNever use item.available_stock

Webhook Not Updating Stock

  1. Check Zoho Webhook Configuration

    • Go to Zoho Settings → Webhooks
    • Verify URL: https://www.tsh.sale/api/webhooks/zoho
    • Verify events are enabled
  2. Check Vercel Logs

    bash
    vercel logs --follow | grep webhook
    
  3. Verify Webhook Payload

    • Add logging to webhook handler
    • Check if resource_name matches expected
  4. Test Webhook Manually

    bash
    curl -X POST https://www.tsh.sale/api/webhooks/zoho \
      -H "Content-Type: application/json" \
      -d '{"resource_name":"item","event_type":"updated","resource_id":"123"}'
    

Adding New Stock-Affecting Feature

When adding features that affect stock:

1. Identify Webhook Events

yaml
Example: Adding "Stock Transfer" feature
Events needed:
  - inventorytransfer.created
  - inventorytransfer.updated

2. Update Webhook Handler

typescript
// src/app/api/webhooks/zoho/route.ts

case "inventorytransfer":
  const itemIds = extractItemIdsFromTransfer(payload);
  await quickSyncStock(itemIds, `stock transfer: ${eventType}`);
  await revalidateProducts(`stock transfer: ${eventType}`);
  break;

3. Test the Flow

bash
# 1. Create test transaction in Zoho
# 2. Check webhook received
curl "https://www.tsh.sale/api/debug/webhook-log"

# 3. Verify stock updated
curl "https://www.tsh.sale/api/debug/stock?itemId=<ITEM_ID>"

Golden Rules

  1. Single Source of Truth: ALWAYS use getUnifiedStock() or getUnifiedStockBulk()

  2. Never Use Books Fallback: NEVER use item.available_stock (combines ALL warehouses)

  3. Warehouse Isolation: ONLY show stock from "Main WareHouse" (ID: 2646610000000077024)

  4. Cache Before Display: Ensure Redis cache is populated before displaying stock

  5. Graceful Degradation: Show "Check availability" if stock unavailable, NOT zero

  6. Sync Before Deploy: Run full stock sync before deploying stock-related changes

Related Files

FilePurpose
src/lib/zoho/stock-cache.tsStock caching, unified functions
src/lib/zoho/products.tsProduct fetching, stock extraction
src/app/api/webhooks/zoho/route.tsWebhook handler
src/app/api/sync/stock/route.tsSync endpoint
.claude/STOCK_RULES.mdStock display rules
.claude/skills/tsh-stock/SKILL.mdBasic stock skill

Checklist for Stock Changes

Before modifying stock-related code:

  • Read .claude/STOCK_RULES.md
  • Check Redis cache status
  • Understand which webhook events affect the change
  • Use unified stock functions
  • Test on staging first
  • Run full sync after deploy
  • Verify list/detail stock consistency