# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** (6/6 ADRs confirmed)
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design
- [x] PRD-to-SDD traceability verified (Codex review 2026-02-05)

---

## Constraints

**CON-1 Technology Stack**
- PHP 8.x with Slim 2.6.2 framework
- Twig 1.44.8 templating
- MySQL multi-database architecture (central + per-store)
- Bootstrap 5.3.3 with design token system
- SyncFusion EJ2 for floor plan canvas

**CON-2 Coding Standards**
- PSR-4 autoloading under `BuyerKiosk\` namespace
- camelCase for database columns and table names
- Migration system via `php userfrosting/conductor run`
- PHPStan level compliance

**CON-3 Data Constraints**
- POS subcategories (drsSubCategories) only available for Plato's Closet stores initially
- POS subcategory codes are alphanumeric VARCHAR(10) strings (e.g., '1011', 'ACCD', 'MBBO') - NOT purely numeric
- Buy data sourced from `kiosk_sales.buys` table with subCatID for per-category tracking (NOTE: PRD references `buyQueue` but kiosk_sales.buys provides accurate per-category counts)
- Floor plan socket assignments are the authoritative category-to-floor mapping
- Backstock bin `mainCategory` column will be modified to store POS subcategory codes (existing values migrated to tags as custom categories since POS integration is new)

**CON-4 Performance**
- Report calculations on-demand (not real-time streaming)
- Cache strategy using Redis for computed scores
- Maximum 7-day rolling window for scoring

**CON-5 Browser Support**
- Responsive design: Desktop (≥1200px), Tablet (768-1199px), Mobile (≤767px)
- Modern browsers (Chrome, Safari, Firefox, Edge - latest 2 versions)

## Implementation Context

### Required Context Sources

```yaml
# Internal documentation and patterns
- doc: docs/patterns/psr4-autoloading.md
  relevance: HIGH
  why: "All new classes must follow PSR-4 conventions"

- doc: docs/patterns/namespace-structure.md
  relevance: HIGH
  why: "Directory structure for BuyerKiosk namespace"

- doc: CLAUDE.md
  relevance: CRITICAL
  why: "Project commands, database conventions, migration system"

# Source code - Floor Plan Module (CRITICAL)
- file: userfrosting/src/BuyerKiosk/FloorPlan/Services/HeatmapService.php
  relevance: HIGH
  sections: [calculatePercentileRange, getSocketAssignments]
  why: "Pattern for color calculation and socket mapping - reference only (not extending)"

# Source code - Sales Module (CRITICAL)
- file: userfrosting/src/BuyerKiosk/Sales/SalesItem.php
  relevance: CRITICAL
  why: "kiosk_sales.sales table structure - subCatID is VARCHAR matching buys table"

# Database tables - kiosk_sales (CRITICAL)
- table: kiosk_sales.sales
  relevance: CRITICAL
  columns: [code, buyDate, salesDate, quantity, price, cost, deptID, catID, subCatID, typeNum]
  why: "Items sold - query by subCatID and salesDate for items_sold count"

- table: kiosk_sales.buys
  relevance: CRITICAL
  columns: [code, buyDate, quantity, price, cost, deptID, catID, subCatID, typeNum]
  why: "Items bought - query by subCatID and buyDate for items_bought count"

- file: userfrosting/src/BuyerKiosk/FloorPlan/Services/FloorPlanService.php
  relevance: HIGH
  why: "Rack and socket management patterns"

- file: userfrosting/src/BuyerKiosk/FloorPlan/Services/LayoutService.php
  relevance: HIGH
  why: "Socket assignment CRUD patterns"

- file: userfrosting/src/BuyerKiosk/FloorPlan/Controllers/FloorPlanApiController.php
  relevance: HIGH
  why: "API endpoint patterns for floor plan module"

- file: userfrosting/src/BuyerKiosk/FloorPlan/Models/SocketAssignment.php
  relevance: HIGH
  why: "Socket assignment data model with POS subcategory linking"

# Source code - Backstock Module (HIGH)
- file: userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php
  relevance: HIGH
  sections: [getBinsByCategory, getBinsByLocation, getCategoriesArray]
  why: "Bin retrieval patterns for replenishment recommendations"

- file: userfrosting/src/BuyerKiosk/Backstock/Bin.php
  relevance: HIGH
  sections: [mainCategory, ageDate, getCategories, createSubCategories]
  why: "Bin model for category management and age tracking"

- file: userfrosting/src/BuyerKiosk/Backstock/Category.php
  relevance: MEDIUM
  why: "Category model for custom vs POS category handling"

# Source code - Workbook/Tasks Module (HIGH)
- file: userfrosting/src/BuyerKiosk/Workbook/TaskListManager.php
  relevance: HIGH
  why: "Task creation and completion patterns"

- file: userfrosting/src/BuyerKiosk/Workbook/Models/TaskCompletion.php
  relevance: HIGH
  why: "Task completion tracking with UPSERT pattern"

- file: userfrosting/routes/workbook/tasks.php
  relevance: MEDIUM
  why: "Task API route patterns"

# Source code - Analytics (MEDIUM)
- file: userfrosting/src/BuyerKiosk/Analytics/Jobs/MomentumAggregationJob.php
  relevance: MEDIUM
  sections: [sales query patterns, drsSubCategories joins]
  why: "Pattern for joining sales to POS categories"

# Database tables (CRITICAL)
- file: userfrosting/migrations/input/20251217_000_pc_category_lookups.json
  relevance: CRITICAL
  why: "drsCategories and drsSubCategories schema"

- file: userfrosting/migrations/input/20251217_001_floorplan_core.json
  relevance: HIGH
  why: "fpRacks, fpRackTypes, fpRackSockets schema"

- file: userfrosting/migrations/input/20251217_002_floorplan_layouts.json
  relevance: HIGH
  why: "fpLayouts, fpSocketAssignments schema"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing HeatmapService interface for sales heatmap
  - Existing backstock category CRUD (backward compatibility)
  - Existing task completion workflow
  - Floor plan canvas rendering

- **Can Modify**:
  - Add new methods to HeatmapService for replenishment
  - Extend fpRackTypes with rack unit defaults
  - Extend fpSocketAssignments with rack unit allocation
  - Add POS category support to backstock bins
  - Create new replenishment-specific services

- **Must Not Touch**:
  - Core authentication/authorization
  - SyncFusion diagram persistence
  - kiosk_sales.sales table structure
  - Central user/store database schema

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph Users
        Manager[Store Manager]
        Shift[Shift Lead]
        Specialist[Backstock Specialist]
    end

    subgraph ReplenishmentSystem[Replenishment Reporting System]
        API[Replenishment API]
        Service[Replenishment Service]
        HeatmapExt[Heatmap Extension]
        TaskInt[Task Integration]
    end

    subgraph ExistingModules[Existing BuyerKiosk Modules]
        FloorPlan[Floor Plan Module]
        Backstock[Backstock Module]
        Workbook[Workbook/Tasks Module]
        Sales[Sales Data]
    end

    subgraph Databases
        StoreDB[(Store DB)]
        SalesDB[(kiosk_sales)]
        CentralDB[(kiosk_users)]
    end

    Manager --> API
    Shift --> API
    Specialist --> API

    API --> Service
    Service --> HeatmapExt
    Service --> TaskInt

    HeatmapExt --> FloorPlan
    TaskInt --> Workbook
    Service --> Backstock

    FloorPlan --> StoreDB
    Backstock --> StoreDB
    Workbook --> StoreDB
    Sales --> SalesDB

    Service --> SalesDB
    Service --> StoreDB
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Store Admin Web Interface"
    type: HTTPS
    format: REST JSON
    authentication: Session (existing)
    doc: userfrosting/routes/api.php
    data_flow: "Replenishment reports, task creation, configuration"

  - name: "Mobile App API (Future)"
    type: HTTPS
    format: REST JSON
    authentication: JWT
    doc: docs/api/mobile-agent-requests.md
    data_flow: "Report viewing, task completion"

# Outbound Interfaces
outbound:
  - name: "Ably Real-time"
    type: WebSocket
    format: JSON
    authentication: API Key
    doc: userfrosting/src/BuyerKiosk/Workbook/WorkbookAbly.php
    data_flow: "Task completion events"
    criticality: MEDIUM

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: PDO via dbConnectByName()
    doc: userfrosting/models/BaseModel.php
    data_flow: "Floor plan, backstock, tasks, categories"

  - name: "Sales Database"
    type: MySQL
    connection: PDO to kiosk_sales
    doc: userfrosting/src/BuyerKiosk/FloorPlan/Services/HeatmapService.php
    data_flow: "Sales transactions (kiosk_sales.sales) and buy transactions (kiosk_sales.buys) for scoring"

  - name: "Redis Cache"
    type: Redis
    connection: Predis client
    doc: userfrosting/models/BaseModel.php
    data_flow: "Computed score caching"
```

### Project Commands

```bash
# Environment Setup
Install Dependencies: cd userfrosting && composer install
Start Development: Local Apache/nginx with PHP 8.x

# Testing Commands
Unit Tests: ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
Test Coverage: ./test.sh --coverage
Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse

# CSS Build
Development: php userfrosting/conductor build-css
Production: php userfrosting/conductor build-css --minify
Watch Mode: php userfrosting/conductor build-css --watch

# Database Migrations (CRITICAL)
Run Migrations: php userfrosting/conductor run
Create Migration: Add JSON file to userfrosting/migrations/input/

# Deployment
Deploy: ./deploy.sh (runs tests + deploys)
```

## Solution Strategy

### Architecture Pattern: **Feature Module with Service Layer**

Following the established BuyerKiosk pattern, the Replenishment Reporting System will be implemented as a new feature module within the existing architecture:

```
Replenishment Module
├── Controllers/       # API and Page controllers
├── Services/         # Business logic (ReplenishmentService, ReplenishmentScoreService)
├── Models/           # Data entities (ReplenishmentScore, ReplenishmentTask)
└── Repositories/     # Data access (optional, can use services directly)
```

### Integration Approach

1. **Create ReplenishmentHeatmapService** (NEW) - Separate service for replenishment-specific heatmap logic (ADR-3: clean separation from existing HeatmapService)
2. **Create ReplenishmentService** - Central service for score calculation, bin recommendations, and task coordination
3. **Extend BackstockFactory** - Add POS category support with backward-compatible API
4. **Leverage TaskListManager** - Create replenishment tasks using existing task infrastructure
5. **Share UI Patterns** - Reuse floor plan canvas, modal patterns, and design tokens
6. **Modify existing mainCategory** - Update backstock bins to store POS codes in existing column (ADR-6)

### Justification

- **Consistency**: Follows established patterns in FloorPlan module
- **Reusability**: Leverages existing HeatmapService scoring infrastructure
- **Maintainability**: Clear separation of concerns with service layer
- **Testability**: Services can be unit tested independently

### Key Technical Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Scoring calculation | On-demand with Redis cache | Balances freshness with performance |
| Buy data source | `kiosk_sales.buys` table | Has subCatID and typeNum for per-category tracking (more accurate than buyQueue) |
| Time window | Hybrid (7-day rolling + reset on replenishment) | Per PRD requirements |
| Backstock category column | Modify existing `mainCategory` | Store POS codes in existing column; migrate non-POS values to tags (simpler schema) |
| Heatmap service | New ReplenishmentHeatmapService | Clean separation from existing HeatmapService (ADR-3) |
| Threshold tiers | 5-tier system matching PRD | Green (0-5), Yellow (5-10), Orange (10-15), Red (15-20), Deep Red (20+) |

## Building Block View

### Components

```mermaid
graph LR
    subgraph UI Layer
        ReportPage[Replenishment Report Page]
        HeatmapView[Heatmap Visualization]
        TableView[Table Report View]
        OffsitePrint[Offsite Pull Report]
    end

    subgraph API Layer
        ReplenishmentApi[ReplenishmentApiController]
        FloorPlanApi[FloorPlanApiController]
        BackstockApi[BackstockApiController]
    end

    subgraph Service Layer
        ReplenishmentService[ReplenishmentService]
        ScoreService[ReplenishmentScoreService]
        RepHeatmap[ReplenishmentHeatmapService]
        BackstockFactory[BackstockFactory Extended]
        TaskManager[TaskListManager]
    end

    subgraph Data Layer
        SocketAssignments[fpSocketAssignments]
        RackUnits[Rack Unit Config]
        Bins[bsBins + POS Categories]
        Sales[kiosk_sales.sales]
        Tasks[Workbook Tasks]
        Tracking[Replenishment Tracking]
    end

    ReportPage --> ReplenishmentApi
    HeatmapView --> FloorPlanApi
    TableView --> ReplenishmentApi
    OffsitePrint --> ReplenishmentApi

    ReplenishmentApi --> ReplenishmentService
    ReplenishmentApi --> ScoreService
    FloorPlanApi --> RepHeatmap

    ReplenishmentService --> ScoreService
    ReplenishmentService --> BackstockFactory
    ReplenishmentService --> TaskManager
    ScoreService --> RepHeatmap

    HeatmapService --> SocketAssignments
    HeatmapService --> Sales
    ScoreService --> RackUnits
    ScoreService --> Tracking
    BackstockFactory --> Bins
    TaskManager --> Tasks
```

### Directory Map

**Component**: Replenishment Module (NEW)
```
userfrosting/src/BuyerKiosk/
├── Replenishment/                            # NEW MODULE
│   ├── Controllers/
│   │   ├── ReplenishmentApiController.php   # NEW: API endpoints
│   │   └── ReplenishmentPageController.php  # NEW: Page rendering
│   ├── Services/
│   │   ├── ReplenishmentService.php         # NEW: Main orchestration service
│   │   ├── ReplenishmentScoreService.php    # NEW: Score calculation engine
│   │   └── ReplenishmentHeatmapService.php  # NEW: Heatmap data generation (separate from FloorPlan HeatmapService)
│   └── Models/
│       ├── ReplenishmentScore.php           # NEW: Score data entity
│       ├── ReplenishmentTask.php            # NEW: Task with bin linkage
│       └── ReplenishmentTracking.php        # NEW: Time window tracking
│
├── FloorPlan/
│   └── Models/
│       ├── RackType.php                     # MODIFY: Add defaultRackUnits
│       ├── Rack.php                         # MODIFY: Add rackUnitsOverride
│       └── SocketAssignment.php             # MODIFY: Add rackUnitsAllocated
│
├── Backstock/
│   ├── BackstockFactory.php                 # MODIFY: Add getBinsByPOSCategory()
│   └── Bin.php                              # MODIFY: POS category as main, tags system
│
└── Workbook/
    └── Models/
        └── TaskCompletion.php               # MODIFY: Add replenishment tracking linkage

userfrosting/routes/
├── replenishment/                           # NEW ROUTE GROUP
│   ├── pages.php                            # NEW: Page routes
│   └── api.php                              # NEW: API routes

userfrosting/templates/themes/default/
├── replenishment/                           # NEW TEMPLATES
│   ├── report.html                          # NEW: Main report page
│   ├── partials/
│   │   ├── heatmap-controls.html           # NEW: Heatmap filter controls
│   │   ├── table-view.html                 # NEW: Table report partial
│   │   ├── offsite-print.html              # NEW: Printable pull list
│   │   └── zone-modal.html                 # NEW: Zone detail modal
│   └── modals/
│       ├── create-task-modal.html          # NEW: Task creation modal
│       └── reset-confirmation-modal.html   # NEW: Reset confirmation

public_html/js/
├── replenishment/                           # NEW JS
│   ├── replenishment-report.js             # NEW: Main report controller
│   ├── replenishment-heatmap.js            # NEW: Heatmap visualization
│   └── replenishment-table.js              # NEW: Table interactions

public_html/css/admin/modules/
└── replenishment.css                        # NEW: Replenishment-specific styles

userfrosting/migrations/input/
├── 20260206_001_replenishment_rack_units.json          # NEW: Rack unit columns
├── 20260206_002_replenishment_backstock_pos.json       # NEW: Backstock POS categories
├── 20260206_003_replenishment_tracking.json            # NEW: Tracking table
└── 20260206_004_replenishment_task_linkage.json        # NEW: Task-bin linkage
```

### UI Entry Points and Navigation

**Entry Point (PRD Feature 4)**:
- Replace existing "Audit" button on heatmap reports page with "Replenishment" button
- Route: `/admin/:typeNum/floor-plan/reports/replenishment`
- Modify: `templates/themes/default/floor-plan/reports.html` to add new button

**Heatmap Toggle (PRD Feature 4)**:
- Toggle control between "Sales Heatmap" and "Replenishment Heatmap" views
- Implementation: Radio button group or segmented control in `heatmap-controls.html`
- State: URL parameter `?view=sales|replenishment` for shareable links

**Setup Wizard (PRD Feature 4)**:
- Displayed when: `layoutService->hasActiveLayout() === false`
- Steps:
  1. Create floor plan (link to floor plan editor)
  2. Assign categories to sockets (link to layout assignments)
  3. Configure rack units (inline or link to rack settings)
- Template: `replenishment/partials/setup-wizard.html`

### Offsite Print UX Specification

**Pull Report Interactions (PRD Feature 6)**:
```yaml
State Management:
  - bins[].isPulled: boolean (checkbox for marking pulled)
  - bins[].includeInPrint: boolean (checkbox for print selection)
  - Default: All bins selected for print, none marked pulled

UI Elements:
  - "Select All / Deselect All" toggle for print selection
  - "Mark All Pulled" button (updates all visible checkboxes)
  - Individual bin row with:
    - Checkbox: Include in print (left column)
    - Bin Name, Category, Age
    - Checkbox: Pulled (right column, print-hidden)

Print Styling:
  - @media print rules in replenishment.css
  - Hide: navigation, header, footer, "Pulled" checkboxes
  - Show: clean checklist with empty checkbox squares for manual marking
  - Page breaks between location groups
  - Header: "Offsite Pull Report - [Store Name] - [Date]"
  - Footer: "Page X of Y"
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Migration 1: Rack Unit Configuration
Table: fpRackTypes (MODIFY)
  ADD COLUMN: defaultRackUnits DECIMAL(5,2) DEFAULT 2.0
    - Constraints: NOT NULL, DEFAULT 2.0
    - Purpose: Rack type's default capacity in abstract units

Table: fpRacks (MODIFY)
  ADD COLUMN: rackUnitsOverride DECIMAL(5,2) NULL
    - Purpose: Individual rack can override type default
    - NULL means use type default

Table: fpSocketAssignments (MODIFY)
  ADD COLUMN: rackUnitsAllocated DECIMAL(5,2) NULL
    - Purpose: Rack units allocated to this category on this socket
    - NULL means auto-calculate (equal split)

# Migration 2: Backstock POS Categories
Table: bsBins (MODIFY)
  MODIFY COLUMN: mainCategory VARCHAR(10) NULL
    - Purpose: Updated to store POS subcategory code (FK reference: drsSubCategories.subCatCode, logical not enforced)
    - Migration: Non-POS values moved to bsBin_Cat as custom tags, mainCategory set to NULL for migration
    - NOTE: We modify the EXISTING mainCategory column rather than adding new column (simpler schema per ADR-6)

Table: bsBin_Cat (MODIFY)
  ADD COLUMN: categoryType ENUM('custom', 'pos_subcategory') DEFAULT 'custom'
    - Purpose: Distinguish POS categories from custom tags
    - Migration: Existing entries marked as 'custom'; new POS tag entries marked as 'pos_subcategory'

# Migration 3: Replenishment Tracking
Table: replenishmentTracking (NEW)
  id: INT AUTO_INCREMENT PRIMARY KEY
  subcategoryCode: VARCHAR(10) NOT NULL
  lastReplenishmentDate: TIMESTAMP NOT NULL
  resetType: ENUM('task_completion', 'manual_category', 'manual_all') NOT NULL
  resetBy: INT NULL (FK to kiosk_users.users.id)
  createdAt: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  INDEX: idx_category (subcategoryCode)
  INDEX: idx_date (lastReplenishmentDate)

# Migration 3b: Category-Level Threshold Settings
Table: replenishmentCategorySettings (NEW)
  id: INT AUTO_INCREMENT PRIMARY KEY
  subcategoryCode: VARCHAR(10) NOT NULL UNIQUE
  thresholdAdequate: DECIMAL(5,2) NULL  -- NULL means use store default
  thresholdMonitor: DECIMAL(5,2) NULL   -- NULL means use store default
  thresholdUrgent: DECIMAL(5,2) NULL    -- NULL means use store default
  thresholdCritical: DECIMAL(5,2) NULL  -- NULL means use store default (ADR-4: 5-tier system)
  isActive: TINYINT(1) DEFAULT 1        -- Can disable category from reports
  notes: TEXT NULL                       -- Admin notes about this category
  createdAt: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updatedAt: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  INDEX: idx_category (subcategoryCode)

# Store-level defaults in fpSettings table (existing key-value store)
# Keys: replenishment_threshold_adequate (default: 5)
#       replenishment_threshold_monitor (default: 15)
#       replenishment_threshold_urgent (default: 15)
#       replenishment_threshold_critical (default: 20)
#       replenishment_bins_per_trip (default: 10)
#       replenishment_default_window_days (default: 7)

# Migration 4: Task-Bin Linkage
Table: replenishmentTasks (NEW)
  id: INT AUTO_INCREMENT PRIMARY KEY
  taskCompletionId: INT NULL (FK to workbook_task_completions.id)
  binId: INT NOT NULL (FK to bsBins.id)
  subcategoryCode: VARCHAR(10) NOT NULL
  socketId: INT NULL (FK to fpRackSockets.id)  -- Destination zone (optional, per PRD F7)
  status: ENUM('pending', 'in_progress', 'completed', 'cancelled') DEFAULT 'pending'
  createdAt: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  completedAt: TIMESTAMP NULL
  completedBy: INT NULL (FK to kiosk_users.users.id)
  notes: TEXT NULL
  INDEX: idx_bin (binId)
  INDEX: idx_category (subcategoryCode)
  INDEX: idx_socket (socketId)
  INDEX: idx_status (status)
```

#### Internal API Changes

```yaml
# Replenishment Report APIs
Endpoint: Get Replenishment Heatmap Data
  Method: GET
  Path: /api/:typeNum/replenishment/heatmap
  Request:
    layoutId: int (required) - Active layout to use
    startDate: string (optional) - Window start, defaults to 7 days ago
    endDate: string (optional) - Window end, defaults to today
  Response:
    success:
      sockets: array[{socketId, subcategoryCode, score, color, itemsSold, itemsBought, rackUnits}]
      range: {min, max, average, p5, p95}
      stats: {adequateCount, monitorCount, urgentCount, criticalCount}
      unassignedCategories: array[{code, description, score}]
    error:
      error_code: string
      message: string

Endpoint: Get Replenishment Table Data
  Method: GET
  Path: /api/:typeNum/replenishment/table
  Request:
    layoutId: int (required)
    minScore: float (optional) - Filter by minimum score
    onsiteOnly: bool (optional) - Only show categories with onsite bins
  Response:
    success:
      categories: array[{
        subcategoryCode, description, itemsSold, itemsBought,
        netDepletion, rackUnits, score, urgencyLevel,
        recommendedBin: {id, name, location, onsite, ageDate},
        alternativeBins: array[{...}]
      }]
    error:
      error_code: string
      message: string

Endpoint: Get Offsite Pull Report
  Method: GET
  Path: /api/:typeNum/replenishment/offsite-pull
  Request:
    categoryIds: array[string] (optional) - Specific categories
  Response:
    success:
      locations: array[{
        locationId, locationName,
        bins: array[{id, name, category, categoryDescription, ageDate, ageDays}],
        binCount: int
      }]
      totalBins: int
      estimatedTrips: int
    error:
      error_code: string
      message: string

Endpoint: Create Replenishment Task
  Method: POST
  Path: /api/:typeNum/replenishment/task
  Request:
    binId: int (required)
    subcategoryCode: string (required)
    socketId: int (optional) - Destination zone
    assignedTo: int (optional) - Employee ID
    dueDate: string (optional)
    dueTime: string (optional)
  Response:
    success:
      taskId: int
      replenishmentTaskId: int
      message: string
    error:
      error_code: string
      message: string

Endpoint: Complete Replenishment Task
  Method: POST
  Path: /api/:typeNum/replenishment/task/:taskId/complete
  Request:
    completedBy: int (required)
    notes: string (optional)
    removeCategoryFromBin: bool (default true)
    newMainCategory: string (optional) - If bin has other POS categories
  Response:
    success:
      message: string
      categoryReset: bool
      binUpdated: bool
    error:
      error_code: string
      message: string

Endpoint: Reset Replenishment Tracking
  Method: POST
  Path: /api/:typeNum/replenishment/reset
  Request:
    subcategoryCode: string (optional) - Single category, or omit for all
  Response:
    success:
      categoriesReset: int
      message: string
    error:
      error_code: string
      message: string

Endpoint: Get/Update Store Replenishment Settings
  Method: GET/POST
  Path: /api/:typeNum/replenishment/settings
  Request (POST):
    thresholds: {adequate: float, monitor: float, urgent: float}
    binsPerTrip: int
    defaultTimeWindowDays: int
  Response:
    success:
      settings: {thresholds, binsPerTrip, defaultTimeWindowDays}
    error:
      error_code: string
      message: string

Endpoint: Get/Update Category-Specific Settings
  Method: GET/POST
  Path: /api/:typeNum/replenishment/category/:subcategoryCode/settings
  Request (POST):
    thresholdAdequate: float|null (null = use store default)
    thresholdMonitor: float|null
    thresholdUrgent: float|null
    isActive: bool
    notes: string|null
  Response:
    success:
      category: {subcategoryCode, description, thresholds, isActive, notes}
      effectiveThresholds: {adequate, monitor, urgent} (computed with fallback to store defaults)
    error:
      error_code: string
      message: string

Endpoint: Bulk Update Category Settings
  Method: POST
  Path: /api/:typeNum/replenishment/categories/settings
  Request:
    categories: array[{subcategoryCode, thresholdAdequate, thresholdMonitor, thresholdUrgent, isActive}]
  Response:
    success:
      updated: int (count of updated categories)
    error:
      error_code: string
      message: string
```

#### Application Data Models

```pseudocode
# Score calculation entity
ENTITY: ReplenishmentScore (NEW)
  FIELDS:
    subcategoryCode: string (POS code)
    subcategoryDescription: string
    categoryCode: string (parent category)
    categoryDescription: string
    itemsSold: int
    itemsBought: int
    netDepletion: int (sold - bought)
    totalRackUnits: float
    score: float (netDepletion / rackUnits)
    urgencyLevel: enum (adequate|monitor|high|urgent|critical)
    color: string (hex color code)
    lastReplenishment: timestamp|null
    windowStart: timestamp
    windowEnd: timestamp

  BEHAVIORS:
    calculateScore(): float
    getUrgencyLevel(thresholds): enum
    getColor(thresholds): string

# Bin recommendation entity
ENTITY: BinRecommendation (NEW)
  FIELDS:
    bin: Bin
    matchType: enum (main_category|pos_tag)
    ageDays: int
    isOnsite: bool
    priorityScore: float (lower = better, considers age + location)

  BEHAVIORS:
    static fromBin(Bin, subcategoryCode): BinRecommendation
    compareTo(BinRecommendation): int (for sorting)

# Replenishment task extension
ENTITY: ReplenishmentTask (NEW)
  FIELDS:
    id: int
    taskCompletionId: int|null (links to workbook task)
    binId: int
    bin: Bin (lazy loaded)
    subcategoryCode: string
    destinationSocketId: int|null
    status: enum
    createdAt: timestamp
    completedAt: timestamp|null
    completedBy: int|null
    notes: string|null

  BEHAVIORS:
    create(): int
    complete(userId, notes, removeCategoryFromBin): bool
    cancel(): bool
    static getForCategory(subcategoryCode): array
    static getPending(): array
```

#### Integration Points

```yaml
# Inter-Module Communication
- from: ReplenishmentScoreService
  to: ReplenishmentHeatmapService (NEW)
  protocol: PHP method call
  endpoints: [getHeatmapData, calculatePercentileRange]
  data_flow: "Score calculation with percentile-based color scaling"
  note: "ReplenishmentHeatmapService is a NEW class (ADR-3), not extending existing HeatmapService"

- from: ReplenishmentService
  to: BackstockFactory
  protocol: PHP method call
  endpoints: [getBinsByPOSCategory (NEW), getBinByID]
  data_flow: "Bin recommendations and updates"
  note: "getBinsByPOSCategory is a NEW method to be added to BackstockFactory"

- from: ReplenishmentService
  to: TaskListManager + TaskCompletion
  protocol: PHP method call
  endpoints: [createTaskList, TaskCompletion::create, TaskCompletion::complete]
  data_flow: "Workbook task creation and completion"
  note: "TaskListManager creates lists; TaskCompletion model handles individual tasks"

- from: ReplenishmentApiController
  to: WorkbookAbly
  protocol: PHP method call → WebSocket
  endpoints: [publish]
  data_flow: "Real-time task updates"

# External Data Integration
kiosk_sales.sales:
  - doc: userfrosting/src/BuyerKiosk/FloorPlan/Services/HeatmapService.php
  - sections: [getSalesHeatmapData]
  - integration: "Direct PDO query with typeNum + subCatID (VARCHAR) + date range"
  - critical_data: [subCatID (VARCHAR), quantity, salesDate, typeNum]

kiosk_sales.buys:
  - integration: "Direct PDO query with typeNum + subCatID (VARCHAR) + date range"
  - critical_data: [subCatID (VARCHAR), quantity, buyDate, typeNum]
  - note: "subCatID is VARCHAR in both sales and buys tables"
```

### Implementation Examples

#### Example: Replenishment Score Calculation

**Why this example**: The core scoring algorithm is the heart of the system and demonstrates how to join sales, buys, and floor plan data.

```php
// ReplenishmentScoreService::calculateScores()
// This demonstrates the expected data flow, not exact implementation

public function calculateScores(
    int $layoutId,
    string $startDate,
    string $endDate
): array {
    // Step 1: Get socket assignments with rack units
    $sockets = $this->layoutService->getAssignments($layoutId);

    // Step 2: Aggregate rack units per category
    $categoryRackUnits = [];
    foreach ($sockets as $socket) {
        $code = $socket->subcategoryCode;
        $units = $socket->rackUnitsAllocated ?? $this->calculateDefaultUnits($socket);
        $categoryRackUnits[$code] = ($categoryRackUnits[$code] ?? 0) + $units;
    }

    // Step 3: Get sales per category from kiosk_sales.sales
    // SELECT subCatID, SUM(quantity) FROM sales
    // WHERE typeNum = ? AND salesDate BETWEEN ? AND ? GROUP BY subCatID
    $salesData = $this->getSalesAggregation($startDate, $endDate);

    // Step 4: Get buy activity per category from kiosk_sales.buys
    // SELECT subCatID, SUM(quantity) FROM buys
    // WHERE typeNum = ? AND buyDate BETWEEN ? AND ? GROUP BY subCatID
    $buysData = $this->getBuysAggregation($startDate, $endDate);

    // Step 5: Calculate scores with time window adjustment
    $scores = [];
    foreach ($categoryRackUnits as $code => $rackUnits) {
        $sold = $salesData[$code] ?? 0;      // From kiosk_sales.sales
        $bought = $buysData[$code] ?? 0;     // From kiosk_sales.buys

        // Check for replenishment history - adjust window if category was replenished
        $tracking = $this->getTrackingForCategory($code);
        if ($tracking && $tracking->lastReplenishmentDate > $startDate) {
            // Reset window to replenishment date for accurate scoring
            $sold = $this->getSalesFromDate($code, $tracking->lastReplenishmentDate);
            $bought = $this->getBuysFromDate($code, $tracking->lastReplenishmentDate);
        }

        $netDepletion = max(0, $sold - $bought);
        $score = $rackUnits > 0 ? $netDepletion / $rackUnits : 0;

        $scores[] = new ReplenishmentScore([
            'subcategoryCode' => $code,
            'itemsSold' => $sold,
            'itemsBought' => $bought,
            'netDepletion' => $netDepletion,
            'totalRackUnits' => $rackUnits,
            'score' => $score,
            'urgencyLevel' => $this->getUrgencyLevel($score),
            'color' => $this->getScoreColor($score),
        ]);
    }

    // Sort by score descending (most urgent first)
    usort($scores, fn($a, $b) => $b->score <=> $a->score);

    return $scores;
}
```

#### Example: Bin Recommendation with FIFO

**Why this example**: Shows the priority logic for bin selection (onsite first, then oldest).

```php
// ReplenishmentService::getRecommendedBins()
// Demonstrates bin selection priority

public function getRecommendedBins(string $subcategoryCode, int $limit = 5): array {
    // Get bins matching this POS category (main or tag)
    $bins = $this->backstockFactory->getBinsByPOSCategory($subcategoryCode);

    if (empty($bins)) {
        return [];
    }

    // Convert to recommendations with priority scoring
    $recommendations = array_map(
        fn($bin) => BinRecommendation::fromBin($bin, $subcategoryCode),
        $bins
    );

    // Sort: onsite first, then by age (oldest first = FIFO)
    usort($recommendations, function ($a, $b) {
        // Primary sort: onsite bins first
        if ($a->isOnsite !== $b->isOnsite) {
            return $b->isOnsite ? 1 : -1;
        }
        // Secondary sort: oldest first (higher age = lower priority score)
        return $b->ageDays <=> $a->ageDays;
    });

    return array_slice($recommendations, 0, $limit);
}
```

#### Example: Task Completion with Category Removal

**Why this example**: Shows the bin state handling logic after task completion.

```php
// ReplenishmentTask::complete()
// Demonstrates post-completion bin handling

public function complete(
    int $userId,
    ?string $notes,
    bool $removeCategoryFromBin = true,
    ?string $newMainCategory = null
): array {
    $this->status = 'completed';
    $this->completedAt = date('Y-m-d H:i:s');
    $this->completedBy = $userId;
    $this->notes = $notes;
    $this->save();

    // Reset replenishment tracking for this category
    $this->replenishmentService->resetCategoryTracking(
        $this->subcategoryCode,
        'task_completion',
        $userId
    );

    $binResult = ['updated' => false, 'message' => ''];

    if ($removeCategoryFromBin) {
        $bin = $this->getBin();

        // Remove main category if it matches
        if ($bin->mainPOSCategory === $this->subcategoryCode) {
            $bin->mainPOSCategory = null;

            // Check remaining POS categories in tags
            $posTags = $bin->getPOSCategoryTags();

            if (empty($posTags)) {
                // No POS categories remain
                $customTags = $bin->getCustomTags();
                if (empty($customTags)) {
                    // Bin is now empty
                    $bin->hide();
                    $binResult['message'] = 'Bin emptied and hidden';
                } else {
                    // Only custom tags remain - needs new main category (PRD: prompt user)
                    $binResult['message'] = 'Add new main POS category or bin will be emptied';
                    $binResult['needsMainCategory'] = true;
                    $binResult['customTagsRemaining'] = $customTags;
                    $binResult['action'] = 'prompt_new_main_or_empty';
                }
            } elseif ($newMainCategory && in_array($newMainCategory, $posTags)) {
                // Promote specified tag to main (uses existing mainCategory column per ADR-6)
                $bin->mainCategory = $newMainCategory;
                $bin->removePOSTag($newMainCategory);
                $binResult['message'] = 'Main category updated to ' . $newMainCategory;
            } else {
                // Multiple POS tags remain - user must choose
                $binResult['message'] = 'Select new main category from POS tags';
                $binResult['availablePOSTags'] = $posTags;
            }

            $bin->update();
            $binResult['updated'] = true;
        }
    }

    return [
        'taskCompleted' => true,
        'categoryReset' => true,
        'bin' => $binResult,
    ];
}
```

## Runtime View

### Primary Flow: View Replenishment Report

1. Manager opens Replenishment Report page
2. Frontend requests heatmap data from API
3. ReplenishmentApiController validates permissions
4. ReplenishmentScoreService calculates scores per category
5. HeatmapService maps scores to socket positions
6. Frontend renders heatmap overlay on floor plan
7. Manager clicks zone to see details in modal

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Report Page
    participant API as ReplenishmentApiController
    participant Score as ReplenishmentScoreService
    participant Heatmap as HeatmapService
    participant DB as Store DB
    participant Sales as kiosk_sales

    Manager->>UI: Open Replenishment Report
    UI->>API: GET /api/:typeNum/replenishment/heatmap
    API->>API: Validate permissions (uri_floor_plan)
    API->>Score: calculateScores(layoutId, dateRange)

    Score->>Heatmap: getSocketAssignments(layoutId)
    Heatmap->>DB: SELECT fpSocketAssignments
    DB-->>Heatmap: Socket assignments

    Score->>Sales: Aggregate sales by subCatID
    Sales-->>Score: Sales counts

    Score->>DB: Get buy activity count
    DB-->>Score: Buy counts

    Score->>DB: Get replenishment tracking
    DB-->>Score: Last replenishment dates

    Score-->>API: ReplenishmentScore[]
    API->>Heatmap: calculatePercentileRange(scores)
    Heatmap-->>API: Color range

    API-->>UI: {sockets, range, stats}
    UI->>UI: Render heatmap overlay

    Manager->>UI: Click zone
    UI->>UI: Show zone detail modal
```

### Secondary Flow: Create Replenishment Task

1. Manager views zone needing replenishment
2. Manager clicks "Create Task" in modal
3. System recommends best bin (onsite, oldest)
4. Manager confirms or selects alternative bin
5. System creates workbook task + replenishment task
6. Task appears in Workbook for assignment

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Zone Modal
    participant API as ReplenishmentApiController
    participant Service as ReplenishmentService
    participant Backstock as BackstockFactory
    participant Tasks as TaskListManager
    participant Ably as WorkbookAbly

    Manager->>UI: Click "Create Task"
    UI->>API: GET recommended bins for category
    API->>Backstock: getBinsByPOSCategory(code)
    Backstock-->>API: Bin list (sorted: onsite, FIFO)
    API-->>UI: Recommended bin + alternatives

    Manager->>UI: Confirm bin selection
    UI->>API: POST /replenishment/task
    API->>Service: createReplenishmentTask(binId, categoryCode)

    Service->>Tasks: createTask(taskData)
    Tasks-->>Service: taskId

    Service->>Service: Create ReplenishmentTask record
    Service->>Ably: Publish task created event

    Service-->>API: {taskId, replenishmentTaskId}
    API-->>UI: Task created
    UI->>UI: Show success, close modal
```

### Error Handling

| Error Type | Handling | User Feedback |
|------------|----------|---------------|
| No floor plan exists | Detect in API | Show setup wizard with steps |
| No socket assignments | Detect in scoring | "Configure category assignments first" message |
| No sales data | Return empty scores | "No sales data for period" with date picker |
| No POS categories (non-PC store) | Check drsSubCategories | Warning banner: "POS categories not configured" |
| Bin not found | 404 response | "Bin no longer available" |
| Task creation fails | Database transaction rollback | "Failed to create task, please try again" |
| Network failure | Frontend retry logic | Toast: "Connection lost, retrying..." |
| Score calculation timeout | Redis cache + partial results | "Showing cached results" indicator |

### Complex Logic: Replenishment Scoring Algorithm

```
ALGORITHM: Calculate Replenishment Scores
INPUT: layoutId, startDate, endDate, typeNum, thresholds
OUTPUT: ReplenishmentScore[] sorted by urgency

1. VALIDATE:
   - Layout exists and is active
   - Date range is valid (max 30 days)
   - Store has drsSubCategories data

2. AGGREGATE_RACK_UNITS:
   FOR each socket in layout:
     units = socket.rackUnitsAllocated ?? calculateDefaultUnits(socket)
     categoryRackUnits[socket.subcategoryCode] += units

3. AGGREGATE_SALES:
   Query: SELECT subCatID, SUM(quantity) as items_sold
          FROM kiosk_sales.sales
          WHERE typeNum = :typeNum
            AND salesDate BETWEEN :start AND :end
          GROUP BY subCatID

4. AGGREGATE_BUYS:
   Query: SELECT subCatID, SUM(quantity) as items_bought
          FROM kiosk_sales.buys
          WHERE typeNum = :typeNum
            AND buyDate BETWEEN :start AND :end
          GROUP BY subCatID

5. CALCULATE_SCORES:
   FOR each category in categoryRackUnits:
     tracking = getTrackingForCategory(category)

     IF tracking.lastReplenishmentDate > startDate:
       // Hybrid window: use replenishment date as start
       effectiveStart = tracking.lastReplenishmentDate
     ELSE:
       effectiveStart = startDate

     sold = salesData[category] ?? 0    // From kiosk_sales.sales
     bought = buysData[category] ?? 0   // From kiosk_sales.buys
     netDepletion = MAX(0, sold - bought)
     score = netDepletion / categoryRackUnits[category]

     urgency = CASE
       WHEN score <= thresholds.adequate: 'adequate'
       WHEN score <= thresholds.monitor: 'monitor'
       WHEN score <= thresholds.urgent: 'high'
       WHEN score <= thresholds.critical: 'urgent'
       ELSE: 'critical'

     color = getColorForUrgency(urgency, score)

     scores.append(ReplenishmentScore(...))

6. SORT: scores by score DESC (most urgent first)

7. RETURN: scores with range statistics
```

## Deployment View

### Single Application Deployment

No changes to existing deployment process. New module deploys with standard `./deploy.sh`.

- **Environment**: Existing PHP application server
- **Configuration**: Store-level settings in `storeSettings` table
- **Dependencies**: No new external services
- **Performance**:
  - Score calculation: Target < 2s for 100 categories
  - Redis caching: 5-minute TTL for computed scores
  - Invalidate cache on: task completion, manual reset

### Database Migration Sequence

1. `20260206_001_replenishment_rack_units.json` - Add rack unit columns (safe, NULL defaults)
2. `20260206_002_replenishment_backstock_pos.json` - Add POS category support (safe, NULL defaults)
3. `20260206_003_replenishment_tracking.json` - Create tracking table (new table)
4. `20260206_004_replenishment_task_linkage.json` - Create task linkage table (new table)
5. `20260206_005_replenishment_backstock_migration.json` - Migrate existing mainCategory to tags (data migration)

### Feature Flag

New store setting: `replenishment_enabled` (default: false)
- Allows gradual rollout to stores
- Hides menu item and routes when disabled
- Can be enabled per-store by admin

### Analytics Events Mapping (PRD Tracking Requirements)

| PRD Event | Emit Location | Service Method | Properties |
|-----------|---------------|----------------|------------|
| `replenishment_report_viewed` | ReplenishmentPageController::report() | - | typeNum, reportType (heatmap/table), userId |
| `replenishment_score_calculated` | ReplenishmentScoreService::calculateScores() | After calculation | typeNum, categoryCount, urgentCount, calculationMs |
| `bin_category_assigned` | BackstockFactory::updateBinCategory() | On save | typeNum, binId, categoryType (pos/custom), categoryCode |
| `replenishment_task_created` | ReplenishmentService::createTask() | On task creation | typeNum, binId, categoryCode, isOnsite |
| `replenishment_task_completed` | ReplenishmentTask::complete() | On completion | typeNum, taskId, timeToCompleteMinutes |
| `offsite_report_printed` | JS: replenishment-report.js | window.print() call | typeNum, binCount |
| `replenishment_reset` | ReplenishmentService::resetTracking() | On reset | typeNum, resetType (all/category), categoryCode |
| `stockout_event` | ReplenishmentScoreService (scheduled) | Score > Urgent 24h | typeNum, categoryCode, score, hoursAboveThreshold |
| `stockout_resolved` | ReplenishmentTask::complete() / resetTracking() | On resolution | typeNum, categoryCode, resolutionType |

**Implementation Notes:**
- Use existing `FloorPlanAnalyticsService` pattern for event emission
- Events logged via AJAX to `/api/:typeNum/analytics/event`
- `stockout_event` requires scheduled job to check scores hourly

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns to follow
- pattern: HeatmapService scoring pattern
  relevance: CRITICAL
  why: "Score calculation, percentile ranges, socket mapping"

- pattern: BackstockFactory bin retrieval
  relevance: HIGH
  why: "Category-based bin queries"

- pattern: TaskListManager task lifecycle
  relevance: HIGH
  why: "Task creation and completion flow"

- pattern: FloorPlanApiController endpoint structure
  relevance: HIGH
  why: "Permission checks, store context, response format"
```

### System-Wide Patterns

**Security:**
- Permission check: `uri_floor_plan` for report access
- Store group validation via `checkStoreGroup()`
- No new permissions required (reuse floor plan permissions)

**Error Handling:**
- API errors return JSON with `error_code` and `message`
- Database errors logged, generic message to user
- Validation errors return 400 with field-specific messages

**Performance:**
- Redis caching for computed scores (5-minute TTL)
- Eager loading for bin → location relationships
- Pagination for table view (50 items per page)

**Logging/Auditing:**
- All task creations logged with user context
- Reset actions logged in replenishmentTracking
- Analytics events for adoption tracking

### Implementation Patterns

#### Code Patterns and Conventions

- Follow existing PSR-4 autoloading conventions
- Use type hints for all parameters and return types
- Service methods should be stateless (store context passed in)
- Use dependency injection via constructor

#### State Management Patterns

- Score state: Computed on-demand, cached in Redis
- Task state: Persisted in database, real-time via Ably
- UI state: Frontend manages with JavaScript, no session storage

#### Component Structure Pattern

```pseudocode
COMPONENT: ReplenishmentReportPage
  INITIALIZE:
    - Load active layout from API
    - Fetch initial heatmap data
    - Setup WebSocket listener for task updates

  RENDER:
    IF no_floor_plan: setup_wizard
    IF no_assignments: assignment_prompt
    IF no_pos_categories: warning_banner
    ELSE: heatmap_view + table_view

  INTERACTIONS:
    - Toggle heatmap/table view
    - Click zone → open modal
    - Create task → refresh table
    - Reset tracking → refresh all
```

### Integration Points

- **Floor Plan Canvas**: Reuse SyncFusion diagram with new overlay
- **Workbook Tasks**: Create tasks via TaskListManager
- **Ably Events**: Publish task updates for real-time sync
- **Analytics**: Emit tracking events via FloorPlanAnalyticsService pattern

## Architecture Decisions

- [x] ADR-1 **Per-Category Buy Tracking via kiosk_sales.buys**: Use kiosk_sales.buys table for accurate per-category buy counts
  - Rationale: kiosk_sales.buys has subCatID and typeNum matching the sales table structure
  - Trade-offs: None - this provides accurate per-category buy data
  - User confirmed: ✅ 2026-02-05

- [x] ADR-2 **POS Category as Main, Custom as Tags**: Backstock bins use POS subcategory as primary category
  - Rationale: Enables automatic sales-to-bin matching via subCatCode
  - Trade-offs: Requires migration of existing bins; non-PC stores need POS categories
  - User confirmed: ✅ 2026-02-05

- [x] ADR-3 **New ReplenishmentHeatmapService**: Create separate service for replenishment heatmap logic
  - Rationale: Clean separation of concerns; HeatmapService already large
  - Trade-offs: Some code duplication with shared utilities; need to extract common patterns
  - User confirmed: ✅ 2026-02-05

- [x] ADR-4 **Category-Level Thresholds with Store Defaults**: Thresholds configurable per-category, with store-level defaults
  - Rationale: High-demand categories (Women's Denim) may need different thresholds than slow-movers
  - Defaults (5-tier system per PRD):
    - 0-5: Adequate (Green #28a745)
    - 5-10: Monitor (Yellow #ffc107)
    - 10-15: High (Orange #fd7e14)
    - 15-20: Urgent (Red #dc3545)
    - 20+: Critical (Deep Red #721c24)
  - Override: Per-category threshold overrides stored in new `replenishmentCategorySettings` table
  - User confirmed: ✅ 2026-02-05

- [x] ADR-5 **Task Completion Resets Category Window**: Completing a task automatically resets that category's tracking
  - Rationale: Per PRD hybrid time window requirement - scores calculate from replenishment date
  - Trade-offs: Multiple concurrent tasks for same category - first to complete resets
  - User confirmed: ✅ 2026-02-05

- [x] ADR-6 **Modify Existing mainCategory Column**: Update existing `bsBins.mainCategory` to store POS codes
  - Rationale: Simpler schema than adding new column; matches PRD requirement; avoids code fragmentation
  - Migration: Non-POS values moved to bsBin_Cat as 'custom' tags; mainCategory set to NULL for non-POS bins
  - Trade-offs: Requires data migration; existing code must handle NULL mainCategory during transition
  - User confirmed: ✅ 2026-02-05

## Scope Boundaries

### Features Included (Must Have - PRD Features 1-8)
All Must Have features from PRD are fully designed in this SDD.

### Features Deferred to Phase 2 (Should Have - PRD Features 9-10)

**Feature 9: Auto-Suggested Replenishment Alerts**
- **Status**: Deferred to Phase 2
- **Prerequisites**: Complete Phase 1 with replenishment history data; add scheduled job for threshold monitoring
- **Placeholder**: Dashboard widget with "Coming Soon" indicator

**Feature 10: Replenishment History & Trends**
- **Status**: Deferred to Phase 2
- **Prerequisites**: Sufficient replenishment data collected (recommend 30+ days); reporting endpoints
- **Data Foundation**: `replenishmentTasks` and `replenishmentTracking` tables store required data
- **Phase 2 Work**: Add `/api/:typeNum/replenishment/history` endpoint with weekly/monthly aggregation

### Features Not In Scope (Could Have - PRD Features 11-12)
Bin Reservation (Feature 11) and Batch Replenishment Mode (Feature 12) are future considerations.

## Quality Requirements

| Requirement | Target | Measurement |
|-------------|--------|-------------|
| Report Load Time | < 3 seconds | API response time for heatmap endpoint |
| Score Accuracy | Within 5% of manual calculation | Unit test validation |
| Mobile Usability | Fully functional on 375px width | Manual testing on iPhone SE |
| Accessibility | WCAG 2.1 AA compliance | Color contrast, keyboard navigation |
| Uptime | 99.9% availability | Existing monitoring |
| Data Freshness | < 5 minutes stale | Redis cache TTL |
| Cache Invalidation | Immediate on write | Reset/task actions invalidate Redis keys immediately; UI auto-refreshes |

## Risks and Technical Debt

### Known Technical Issues

- buyQueue lacks per-item category tracking → Simplified scoring model
- Non-PC stores lack drsSubCategories → Feature requires POS category setup
- HeatmapService is already large (~500 lines) → May need refactoring

### Technical Debt

- Legacy backstock subCat1/subCat2/subCat3 columns → Should migrate to junction table only
- Multiple heatmap types in one service → Consider extraction after v1
- Hardcoded urgency thresholds in scoring → Make configurable

### Implementation Gotchas

- SyncFusion diagram state persistence → Must not break existing floor plans
- Redis cache invalidation → Ensure all write paths invalidate
- Timezone handling → Use store timezone for date comparisons
- Task completion race conditions → Use database transactions

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Score Calculation Happy Path**
```gherkin
Given: Store has active floor plan with socket assignments
And: Categories assigned with known rack units (total 10 units for category "5020")
And: 50 items sold in category "5020" in last 7 days
And: No replenishment history for category "5020"
When: Manager views replenishment report
Then: Category "5020" shows score of 5.0 (50/10)
And: Urgency level is "monitor" (score between adequate and urgent)
And: Color is yellow (#ffc107)
```

**Scenario 2: Bin Recommendation Priority**
```gherkin
Given: Category "5020" needs replenishment
And: Bin A is onsite, age 30 days, contains category "5020"
And: Bin B is onsite, age 60 days, contains category "5020"
And: Bin C is offsite, age 90 days, contains category "5020"
When: System recommends bins for category "5020"
Then: Bin B is recommended first (onsite + oldest)
And: Bin A is second (onsite + newer)
And: Bin C is third (offsite)
```

**Scenario 3: Task Completion Resets Window**
```gherkin
Given: Category "5020" has been replenished 3 days ago
And: Manager views replenishment report
When: Score is calculated
Then: Sales window starts from replenishment date (3 days ago)
And: Not from default 7-day window
```

**Scenario 4: No Floor Plan Setup Wizard**
```gherkin
Given: Store has no floor plan configured
When: Manager opens replenishment report
Then: Setup wizard is displayed
And: Step 1 prompts to create floor plan
And: Step 2 prompts to assign categories
And: Step 3 prompts to configure rack units
```

### Test Coverage Requirements

- **Business Logic**: Score calculation, bin prioritization, time window handling
- **User Interface**: Heatmap rendering, table sorting, modal interactions
- **Integration Points**: Task creation, Ably events, cache invalidation
- **Edge Cases**: Zero rack units, no sales data, concurrent task completion
- **Performance**: Score calculation under 2s for 100 categories
- **Security**: Permission checks, store isolation

---

## PRD-to-SDD Traceability Matrix

| PRD Feature | SDD Component | API Endpoint | Migration | Test Scenario |
|-------------|---------------|--------------|-----------|---------------|
| F1: Backstock Category Redesign | Bin.php, BackstockFactory | PUT /api/:typeNum/backstock/bin/:id | 20260206_002 | Category assignment |
| F2: Rack Capacity Configuration | RackType, Rack, SocketAssignment models | PUT /api/:typeNum/floor-plan/rack/:id | 20260206_001 | Rack unit override |
| F3: Replenishment Scoring | ReplenishmentScoreService | GET /replenishment/heatmap | - | Scenario 1 |
| F4: Replenishment Heatmap | ReplenishmentHeatmapService, report.html | GET /replenishment/heatmap | - | Scenario 4 |
| F5: Replenishment Table | replenishment-table.js, table-view.html | GET /replenishment/table | - | Table sorting |
| F6: Offsite Pull Report | offsite-print.html, replenishment.css | GET /replenishment/offsite-pull | - | Print styling |
| F7: Task Creation | ReplenishmentTask, TaskListManager | POST /replenishment/task | 20260206_004 | Task completion |
| F8: Manual Reset | ReplenishmentService::resetTracking | POST /replenishment/reset | 20260206_003 | Scenario 3 |
| F9: Auto Alerts | DEFERRED | - | - | - |
| F10: History & Trends | DEFERRED (data collected) | - | - | - |

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Replenishment Score | Calculated value indicating floor restocking urgency | Higher score = more urgent need |
| Rack Unit | Abstract measure of floor display capacity | H-rack default = 2 units |
| Socket | A logical display location on a rack | Where categories are assigned |
| POS Subcategory | Category code from point-of-sale system | Links sales to floor assignments |
| Hybrid Window | Time period that resets on replenishment | 7-day default OR since last replenishment |
| Onsite Bin | Backstock bin stored at the store location | Prioritized for quick replenishment |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| typeNum | Store identifier pattern (e.g., "ou00") | Used in all store-specific queries |
| Layout | Version of floor plan category assignments | "current" or "wanted" layout types |
| Junction Table | Database table linking many-to-many relationships | bsBin_Cat links bins to categories |
| FIFO | First In, First Out inventory method | Oldest bins recommended first |
| Percentile Range | Statistical range (5th-95th) for color scaling | Handles outliers in heatmap |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| subcategoryCode | 4-digit POS category identifier | Matches drsSubCategories.subCatCode |
| rackUnitsAllocated | Decimal value of capacity assigned to category | Stored in fpSocketAssignments |
| urgencyLevel | Enum: adequate/monitor/high/urgent/critical | Derived from score vs thresholds |
