# 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**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

CON-1: PHP 8.x, Slim 2.6.2, Twig 1.44.8, MySQL/MariaDB, Syncfusion EJ2 JS components. No framework upgrades.
CON-2: `bsBins.mainCategory` is VARCHAR(20) -- group IDs must fit within this column using a prefix convention.
CON-3: Per-store configuration stored in individual store databases (`kiosk_{{typeNum}}`). POS data is read-only from `kiosk_sales`.
CON-4: All schema changes MUST use the migration system (`userfrosting/migrations/input/`). No manual SQL.
CON-5: Existing floor plan reporting (SpaceEfficiencyService, ReplenishmentService) queries `subcategoryCode` from `kiosk_sales.subcategories`. Group expansion must plug into these existing query patterns.
CON-6: Syncfusion EJ2 DropDownList/MultiSelect components used for category selection. Must follow existing initialization patterns (destroy/recreate on modal open).

## Implementation Context

### Required Context Sources

```yaml
# Internal - Category System
- file: userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php
  relevance: CRITICAL
  sections: [getCategoriesArray, getCategoriesForDropdown, makeBinReadable, massChangeCategory, massChangeTags, getBinsByPOSCategory]
  why: "Core category logic. All methods need modification to support groups."

- file: userfrosting/src/BuyerKiosk/Backstock/Category.php
  relevance: HIGH
  why: "Custom category CRUD. Needs shortName field added."

- file: userfrosting/src/BuyerKiosk/Backstock/Bin.php
  relevance: HIGH
  sections: [create, update, createSubCategories, getCategories, mergeCategoryTags, isEmpty]
  why: "Bin entity. mainCategory assignment must support group IDs."

# Internal - API & Routes
- file: userfrosting/routes/groups/backstock.php
  relevance: HIGH
  sections: [lines 597-671]
  why: "Category CRUD API endpoints. Need new endpoints for groups and visibility."

- file: userfrosting/routes/workbook/backstock.php
  relevance: MEDIUM
  sections: [lines 158-166]
  why: "Workbook category API. Needs group-aware response format."

# Internal - Floor Plan & Reporting
- file: userfrosting/src/BuyerKiosk/FloorPlan/Services/SpaceEfficiencyService.php
  relevance: HIGH
  sections: [getRackUnitsByCategory, getSocketsWithMetrics]
  why: "Floor plan reporting joins on subcategoryCode. Must handle group expansion."

- file: userfrosting/src/BuyerKiosk/Replenishment/Services/ReplenishmentService.php
  relevance: HIGH
  sections: [getRecommendedBins, getOffsiteBinReport]
  why: "Replenishment queries bins by mainCategory. Must resolve groups to DRS codes."

# Internal - UI
- file: userfrosting/templates/themes/default/workspace/partials/backstock/categories-modal.html
  relevance: HIGH
  why: "Categories management modal. Will be expanded with tabs."

- file: public_html/js/workspace/modules/backstock/BackstockConfigManager.js
  relevance: HIGH
  why: "JS category management. Needs group CRUD and visibility toggles."

- file: public_html/js/workspace/modules/backstock/BackstockBinCreation.js
  relevance: HIGH
  why: "Bin creation dropdown. Must show groups, hide grouped DRS, show indicators."

# Internal - Migrations
- file: userfrosting/migrations/input/20260206_002_replenishment_backstock_pos.json
  relevance: MEDIUM
  why: "Pattern reference for migration JSON format."
```

### Implementation Boundaries

- **Must Preserve**: All existing bin assignments (mainCategory values). All existing custom category data. Floor plan socket assignments (fpSocketAssignments.subcategoryCode). Replenishment task tracking.
- **Can Modify**: BackstockFactory category methods. Category modal template. Category API endpoints. Category dropdown population logic. Report display formatting.
- **Must Not Touch**: `kiosk_sales.subcategories` and `kiosk_sales.categories` tables (read-only POS data). Floor plan socket assignment schema. Replenishment tracking schema.

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Manager[Store Manager] --> CatMgmt[Category Management UI]
    Manager --> BinCreate[Bin Creation/Edit UI]

    CatMgmt --> CatAPI[Category API]
    BinCreate --> BinAPI[Bin API]

    CatAPI --> StoreDB[(Store DB)]
    CatAPI --> SalesDB[(kiosk_sales DB)]
    BinAPI --> StoreDB

    Reports[Floor Plan Reports] --> SpaceEff[SpaceEfficiencyService]
    Reports --> Replenish[ReplenishmentService]
    SpaceEff --> StoreDB
    SpaceEff --> SalesDB
    Replenish --> StoreDB

    StoreDB --> |bsCategoryGroups NEW| GroupData[Group Definitions]
    StoreDB --> |bsCategoryVisibility NEW| VisData[Visibility Settings]
    StoreDB --> |bsCategories MODIFIED| CustomData[Custom Categories]
    StoreDB --> |bsBins| BinData[Bin Assignments]
    SalesDB --> |subcategories| POSData[POS Categories]
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Backstock Web UI"
    type: HTTP/HTTPS
    format: REST (JSON)
    authentication: Session-based (UserFrosting)
    data_flow: "Category CRUD, group management, visibility toggles, bin assignments"

  - name: "Mobile API"
    type: HTTPS
    format: REST (JSON)
    authentication: JWT
    data_flow: "Category listing with bin counts (getCategoriesWithBinCounts)"

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL/MariaDB
    connection: PDO via dbConnectByName()
    tables: [bsBins, bsBin_Cat, bsCategories, bsCategoryGroups (NEW), bsCategoryGroupMembers (NEW), bsCategoryVisibility (NEW), bsCategoryShortNames (NEW)]

  - name: "kiosk_sales Database"
    type: MySQL/MariaDB (read-only)
    connection: Direct cross-DB query
    tables: [subcategories, categories]
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit                    # Run unit tests
cd userfrosting && ./vendor/bin/phpunit --filter "Backstock"  # Targeted tests

# Database Migrations
php userfrosting/conductor run                # Run all pending migrations

# CSS Build (if modal CSS changes)
php userfrosting/conductor build-css --minify  # Production build

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Backstock/
```

## Solution Strategy

- **Architecture Pattern**: Layered extension of existing Backstock module. New tables + service class (CategoryConfigService) that encapsulates all group/visibility/shortname logic. Existing Factory/Bin classes updated to delegate to this service.
- **Integration Approach**: CategoryConfigService injected into BackstockFactory. Dropdown builders and makeBinReadable() check group membership. Report services use a new `expandGroupToSubcategories()` method to translate group IDs to DRS codes for aggregation.
- **Justification**: This preserves the existing architecture (Factory+Entity pattern) while centralizing the new category configuration logic in one service. No architectural paradigm shift needed.
- **Key Decisions**: Group IDs stored with `grp_` prefix in mainCategory VARCHAR(20). Per-store visibility/shortnames in dedicated tables. Groups stored in normalized tables (group + members).

## Building Block View

### Components

```mermaid
graph LR
    UI[Category Management UI] --> API[Category Config API]
    DDL[Bin Category Dropdown] --> API
    API --> CCS[CategoryConfigService]
    CCS --> StoreDB[(Store DB)]
    CCS --> SalesDB[(kiosk_sales)]
    BF[BackstockFactory] --> CCS
    BF --> StoreDB
    RPT[SpaceEfficiencyService] --> CCS
    RPL[ReplenishmentService] --> CCS
```

### Directory Map

```
userfrosting/src/BuyerKiosk/Backstock/
├── BackstockFactory.php          # MODIFY: Use CategoryConfigService for dropdown, readable, mass ops
├── Bin.php                       # MODIFY: Support grp_ prefix in mainCategory
├── Category.php                  # MODIFY: Add shortName property
├── CategoryConfigService.php     # NEW: Groups, visibility, short names, group expansion
└── CategoryGroup.php             # NEW: Group entity (name, shortName, color, members)

userfrosting/routes/groups/
└── backstock.php                 # MODIFY: Add group CRUD, visibility toggle, short name endpoints

userfrosting/templates/themes/default/workspace/partials/backstock/
└── categories-modal.html         # MODIFY: Add tabs for Groups, DRS Visibility, Short Names

public_html/js/workspace/modules/backstock/
├── BackstockConfigManager.js     # MODIFY: Add group management, visibility toggles, short names
├── BackstockBinCreation.js       # MODIFY: Group-aware dropdown with visual indicators
└── BackstockMassEdit.js          # MODIFY: Groups in mass category change

userfrosting/migrations/input/
├── 047_001_bsCategoryGroups.json          # NEW: Groups table
├── 047_002_bsCategoryGroupMembers.json    # NEW: Group members table
├── 047_003_bsCategoryVisibility.json      # NEW: Visibility settings table
├── 047_004_bsCategoryShortNames.json      # NEW: Short name overrides table
├── 047_005_bsCategories_shortName.json    # NEW: Add shortName to bsCategories

tests/Unit/Backstock/
├── CategoryConfigServiceTest.php # NEW: Unit tests for the service
└── CategoryGroupTest.php         # NEW: Unit tests for group entity
```

### Interface Specifications

#### Data Storage Changes

```yaml
# NEW TABLE: bsCategoryGroups (store DB)
Table: bsCategoryGroups
  id:          INT AUTO_INCREMENT PRIMARY KEY
  name:        VARCHAR(150) NOT NULL          # Display name (e.g., "Girls Tops")
  shortName:   VARCHAR(30) DEFAULT NULL       # Optional short name for reports
  color:       VARCHAR(10) DEFAULT NULL       # Hex color without '#'
  sortOrder:   INT DEFAULT 0                  # Display ordering
  createdAt:   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updatedAt:   TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  UNIQUE KEY (name)

# NEW TABLE: bsCategoryGroupMembers (store DB)
Table: bsCategoryGroupMembers
  id:          INT AUTO_INCREMENT PRIMARY KEY
  groupId:     INT NOT NULL                   # FK to bsCategoryGroups.id
  subcategoryCode: VARCHAR(20) NOT NULL       # DRS subcategory code (e.g., "WFOU")
  UNIQUE KEY (groupId, subcategoryCode)
  FOREIGN KEY (groupId) REFERENCES bsCategoryGroups(id) ON DELETE CASCADE

# NEW TABLE: bsCategoryVisibility (store DB)
Table: bsCategoryVisibility
  id:          INT AUTO_INCREMENT PRIMARY KEY
  subcategoryCode: VARCHAR(20) NOT NULL       # DRS subcategory code
  visible:     TINYINT(1) NOT NULL DEFAULT 1  # 1=visible, 0=hidden
  UNIQUE KEY (subcategoryCode)

# NEW TABLE: bsCategoryShortNames (store DB)
Table: bsCategoryShortNames
  id:          INT AUTO_INCREMENT PRIMARY KEY
  categoryCode: VARCHAR(20) NOT NULL          # DRS subcategory code
  categoryType: ENUM('drs_subcategory', 'drs_category') NOT NULL DEFAULT 'drs_subcategory'
  shortName:   VARCHAR(30) NOT NULL           # Short name override
  UNIQUE KEY (categoryCode, categoryType)

# MODIFIED TABLE: bsCategories (store DB)
Table: bsCategories
  ADD COLUMN: shortName VARCHAR(30) DEFAULT NULL  # Short name for report tables
```

**Storage convention for groups in bsBins.mainCategory:**
- Individual DRS subcategory: raw code, e.g., `"WFOU"` (unchanged)
- Custom category: raw ID, e.g., `"10"` (unchanged)
- Category group: prefixed with `grp_`, e.g., `"grp_3"` (NEW)

This fits within VARCHAR(20): `grp_` (4 chars) + up to 16 digit ID = well within limit.

#### Internal API Changes

```yaml
# ---- GROUP MANAGEMENT ----

Endpoint: List Category Groups
  Method: GET
  Path: /api/:typeNum/backstock/category-groups/
  Response:
    success: boolean
    data: array of {
      id: int,
      name: string,
      shortName: string|null,
      color: string|null,
      memberCount: int,
      members: array of { subcategoryCode: string, name: string }
    }

Endpoint: Create Category Group
  Method: POST
  Path: /api/:typeNum/backstock/category-groups/
  Request:
    name: string (required, max 150)
    shortName: string|null (optional, max 30)
    color: string|null (optional, hex without #)
    members: array of string (required, min 1, DRS subcategory codes)
  Response:
    success: boolean
    data: { id: int, name: string, ... }
  Error:
    400: "Group must have at least 1 DRS category member"
    409: "Group name already exists"

Endpoint: Update Category Group
  Method: PUT
  Path: /api/:typeNum/backstock/category-groups/:groupId/
  Request:
    name: string (optional)
    shortName: string|null (optional)
    color: string|null (optional)
    members: array of string (optional, replaces all members if provided)
  Response:
    success: boolean
    data: { id: int, ... }

Endpoint: Delete Category Group
  Method: DELETE
  Path: /api/:typeNum/backstock/category-groups/:groupId/
  Request: (none, or force=1 query param)
  Response (if bins use group):
    success: false
    affectedBinCount: int
    message: "X bins use this group. Use force=1 to delete and uncategorize them."
  Response (force or no bins):
    success: boolean
    uncategorizedBins: int

# ---- VISIBILITY ----

Endpoint: Get DRS Visibility Settings
  Method: GET
  Path: /api/:typeNum/backstock/category-visibility/
  Response:
    success: boolean
    data: array of { subcategoryCode: string, name: string, visible: boolean }
    # NOTE: All DRS subcategories returned. Those NOT in bsCategoryVisibility default to visible=true.

Endpoint: Toggle DRS Visibility
  Method: POST
  Path: /api/:typeNum/backstock/category-visibility/
  Request:
    subcategoryCode: string (required)
    visible: boolean (required)
  Response:
    success: boolean

Endpoint: Bulk Toggle DRS Visibility
  Method: POST
  Path: /api/:typeNum/backstock/category-visibility/bulk/
  Request:
    changes: array of { subcategoryCode: string, visible: boolean }
  Response:
    success: boolean
    updated: int

# ---- SHORT NAMES ----

Endpoint: Get Short Name Overrides
  Method: GET
  Path: /api/:typeNum/backstock/category-short-names/
  Response:
    success: boolean
    data: array of { categoryCode: string, categoryType: string, shortName: string }

Endpoint: Set Short Name Override
  Method: POST
  Path: /api/:typeNum/backstock/category-short-names/
  Request:
    categoryCode: string (required)
    categoryType: enum('drs_subcategory', 'drs_category') (required)
    shortName: string (required, max 30)
  Response:
    success: boolean

Endpoint: Delete Short Name Override
  Method: DELETE
  Path: /api/:typeNum/backstock/category-short-names/:categoryCode/
  Request:
    categoryType: enum('drs_subcategory', 'drs_category') (query param)
  Response:
    success: boolean

# ---- MODIFIED EXISTING ----

Endpoint: Get Categories (MODIFIED)
  Method: GET
  Path: /api/:typeNum/backstock/categories/
  Query: type=dropdown (for dropdown-formatted response)
  Response (type=dropdown):
    success: boolean
    data: array of {
      id: string,         # "WFOU" | "10" | "grp_3"
      name: string,       # Display name
      shortName: string|null,
      color: string,
      textColor: string,
      group: string,      # "Category Groups" | "DRS Sub-categories" | "Custom Tags"
      type: string,       # "group" | "drs" | "custom"
      memberCount: int|null,  # Only for groups
      isPOS: boolean
    }
    # Grouped DRS categories are EXCLUDED from standalone listing.
    # Only ungrouped DRS (visible=true) appear in "DRS Sub-categories" group.

Endpoint: Update Custom Category (MODIFIED)
  Method: POST
  Path: /api/:typeNum/backstock/categories/:id/edit/
  Request:
    name: string
    color: string
    shortName: string|null (NEW field)
  Response: (unchanged)
```

#### Application Data Models

```pseudocode
ENTITY: CategoryConfigService (NEW)
  DEPENDENCIES:
    storeDb: PDO
    concept: string  # Store concept code (e.g., "ou")

  BEHAVIORS:
    # Groups
    + getGroups(): CategoryGroup[]
    + getGroupById(int groupId): ?CategoryGroup
    + createGroup(string name, ?string shortName, ?string color, array memberCodes): CategoryGroup
    + updateGroup(int groupId, array changes): CategoryGroup
    + deleteGroup(int groupId, bool force = false): array{deleted: bool, uncategorizedBins: int}
    + getGroupMembers(int groupId): string[]  # Returns subcategory codes
    + expandGroupToSubcategories(string mainCategory): string[]  # "grp_3" -> ["WFOU", "WBOU", ...]
    + isGroupId(string mainCategory): bool  # Checks for "grp_" prefix

    # Visibility
    + getVisibilitySettings(): array  # All DRS subcats with visible flag
    + setVisibility(string subcategoryCode, bool visible): void
    + bulkSetVisibility(array changes): int
    + getHiddenSubcategoryCodes(): string[]  # For filtering dropdowns
    + isVisible(string subcategoryCode): bool

    # Short Names
    + getShortNameOverrides(): array
    + setShortName(string code, string type, string shortName): void
    + deleteShortName(string code, string type): void
    + resolveDisplayName(string code, string type): string  # shortName fallback to POS name

    # Dropdown Building (replaces parts of BackstockFactory)
    + getCategoriesForDropdown(bool includeHidden = false): array
    + getGroupedSubcategoryCodes(): string[]  # All DRS codes that belong to any group

    # Reporting Support
    + resolveMainCategoryForReporting(string mainCategory): array  # Returns DRS codes for aggregation
    + getDisplayInfo(string mainCategory): array{name, shortName, color, textColor, type}

ENTITY: CategoryGroup (NEW)
  FIELDS:
    id: int
    name: string
    shortName: ?string
    color: ?string
    members: string[]  # subcategory codes
    sortOrder: int

  BEHAVIORS:
    + create(): void
    + update(): void
    + delete(bool force): array
    + addMember(string subcategoryCode): void
    + removeMember(string subcategoryCode): void
    + getMemberCount(): int

ENTITY: Category (MODIFIED)
  FIELDS:
    id: int
    name: string
    color: string
    + shortName: ?string (NEW)

  BEHAVIORS:
    create(): void
    update(): void  # Now includes shortName
    delete(): void
    + getShortName(): ?string (NEW)
    + setShortName(?string): void (NEW)

ENTITY: BackstockFactory (MODIFIED)
  DEPENDENCIES:
    + categoryConfigService: CategoryConfigService (NEW)

  BEHAVIORS:
    ~ getCategoriesArray(): array  # Now includes groups as entries with type='group'
    ~ getCategoriesForDropdown(): array  # Delegates to CategoryConfigService, respects visibility + groups
    ~ makeBinReadable(): void  # Resolves grp_ prefix to group display info
    ~ getCategoriesWithBinCounts(): array  # Includes groups with bin counts
    ~ massChangeCategory(): array  # Supports group IDs

ENTITY: Bin (MODIFIED)
  BEHAVIORS:
    ~ isEmpty(): bool  # "grp_X" is NOT empty
    ~ create(): void  # Unchanged, mainCategory accepts grp_ values
    ~ update(): void  # Unchanged, mainCategory accepts grp_ values
```

#### Integration Points

```yaml
# Floor Plan Reporting Integration
- from: SpaceEfficiencyService
  to: CategoryConfigService
    - method: Direct PHP call
    - pattern: "When building report rows, check if bin's mainCategory starts with grp_. If so, call expandGroupToSubcategories() and sum all member DRS category sales."
    - data_flow: "grp_3 -> ['WFOU', 'WBOU', ...] -> SUM(sales for each)"

# Replenishment Integration
- from: ReplenishmentService
  to: CategoryConfigService
    - method: Direct PHP call
    - pattern: "getRecommendedBins() and getOffsiteBinReport() need to match bins by group. When filtering by subcategoryCode, also match bins whose mainCategory is a group containing that code."
    - data_flow: "subcategoryCode 'WFOU' -> find groups containing WFOU -> include bins with those group mainCategories"

# Mass Edit Integration
- from: BackstockFactory::massChangeCategory()
  to: CategoryConfigService
    - method: Direct PHP call
    - pattern: "Mass change supports grp_ values. Validation confirms group exists."
```

### Implementation Examples

#### Example: Group Expansion for Reporting

**Why this example**: The group-to-DRS expansion is the most complex integration point. SpaceEfficiencyService currently queries per subcategoryCode. When a bin has a group mainCategory, we need to aggregate across all member codes.

```php
// In CategoryConfigService
public function expandGroupToSubcategories(string $mainCategory): array
{
    if (!$this->isGroupId($mainCategory)) {
        // Individual DRS code or custom category - return as-is
        return [$mainCategory];
    }

    $groupId = (int) substr($mainCategory, 4); // Remove "grp_" prefix
    $stmt = $this->storeDb->prepare(
        "SELECT subcategoryCode FROM bsCategoryGroupMembers WHERE groupId = :groupId"
    );
    $stmt->execute([':groupId' => $groupId]);
    return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}

public function isGroupId(string $mainCategory): bool
{
    return str_starts_with($mainCategory, 'grp_');
}
```

#### Example: Dropdown Building with Group Awareness

**Why this example**: The dropdown logic is the most user-facing change. It must show groups, hide grouped DRS categories, respect visibility, and include visual type indicators.

```php
// In CategoryConfigService::getCategoriesForDropdown()
public function getCategoriesForDropdown(bool $includeHidden = false): array
{
    $items = [];
    $groupedCodes = $this->getGroupedSubcategoryCodes();
    $hiddenCodes = $includeHidden ? [] : $this->getHiddenSubcategoryCodes();

    // 1. Category Groups (always shown)
    foreach ($this->getGroups() as $group) {
        $items[] = [
            'id' => 'grp_' . $group->id,
            'name' => $group->name,
            'shortName' => $group->shortName,
            'color' => $group->color ?? '8b5cf6',
            'textColor' => getContrastColor($group->color ?? '8b5cf6'),
            'group' => 'Category Groups',
            'type' => 'group',
            'memberCount' => $group->getMemberCount(),
            'isPOS' => false,
        ];
    }

    // 2. Ungrouped, visible DRS subcategories
    $stmt = $this->storeDb->prepare(
        "SELECT id, name FROM kiosk_sales.subcategories WHERE concept = :concept ORDER BY name"
    );
    $stmt->execute([':concept' => $this->concept]);
    while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
        if (in_array($row['id'], $groupedCodes)) continue;  // Skip grouped
        if (in_array($row['id'], $hiddenCodes)) continue;    // Skip hidden

        $shortName = $this->resolveDisplayName($row['id'], 'drs_subcategory');
        $items[] = [
            'id' => $row['id'],
            'name' => $row['name'],
            'shortName' => $shortName !== $row['name'] ? $shortName : null,
            'color' => '6366f1',
            'textColor' => '#fff',
            'group' => 'DRS Sub-categories',
            'type' => 'drs',
            'memberCount' => null,
            'isPOS' => true,
        ];
    }

    // 3. Custom categories
    $stmt = $this->storeDb->prepare(
        "SELECT * FROM bsCategories ORDER BY name ASC"
    );
    $stmt->execute();
    while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
        $items[] = [
            'id' => (string) $row['id'],
            'name' => $row['name'],
            'shortName' => $row['shortName'] ?? null,
            'color' => $row['color'] ?? '6c757d',
            'textColor' => getContrastColor($row['color'] ?? '6c757d'),
            'group' => 'Custom Tags',
            'type' => 'custom',
            'memberCount' => null,
            'isPOS' => false,
        ];
    }

    return $items;
}
```

#### Example: makeBinReadable Group Resolution

**Why this example**: makeBinReadable() is called for every bin display. It must handle the new group type alongside existing DRS and custom types.

```php
// In BackstockFactory::makeBinReadable() - modified mainCategory block
if ($tempBin->mainCategory === null || $tempBin->mainCategory === '' || $tempBin->mainCategory === 0) {
    $tempBin->mainCategory = ['name' => 'Empty', 'color' => 'e5e7eb', 'textColor' => '#000', 'type' => 'empty'];
} elseif ($this->categoryConfigService->isGroupId($tempBin->mainCategory)) {
    // Group resolution
    $info = $this->categoryConfigService->getDisplayInfo($tempBin->mainCategory);
    $tempBin->mainCategory = $info; // {name, shortName, color, textColor, type: 'group', memberCount}
} elseif (isset($this->categoriesArray[$tempBin->mainCategory])) {
    $entry = $this->categoriesArray[$tempBin->mainCategory];
    $entry['type'] = $entry['isPOS'] ? 'drs' : 'custom';
    $tempBin->mainCategory = $entry;
} else {
    $tempBin->mainCategory = ['name' => 'Unknown', 'color' => 'ffffff', 'textColor' => '#000', 'type' => 'unknown'];
}
```

#### Example: Visibility Toggle UI Pattern

**Why this example**: Shows the Syncfusion-free approach for the visibility toggle list within the modal tab.

```javascript
// In BackstockConfigManager._loadVisibilityTab()
fetch('/api/' + this.typeNum + '/backstock/category-visibility/')
    .then(response => response.json())
    .then(data => {
        const container = document.getElementById('wbVisibilityList');
        container.innerHTML = '';

        data.data.forEach(cat => {
            const row = document.createElement('div');
            row.className = 'list-group-item d-flex justify-content-between align-items-center';
            row.innerHTML = `
                <span>
                    <span class="badge me-2" style="background-color: #6366f1;">&nbsp;</span>
                    ${cat.name}
                </span>
                <div class="form-check form-switch">
                    <input class="form-check-input" type="checkbox"
                           data-code="${cat.subcategoryCode}"
                           ${cat.visible ? 'checked' : ''}>
                </div>
            `;
            container.appendChild(row);
        });

        // Bind toggle events
        container.querySelectorAll('.form-check-input').forEach(toggle => {
            toggle.addEventListener('change', (e) => {
                this._toggleVisibility(e.target.dataset.code, e.target.checked);
            });
        });
    });
```

#### Test Examples as Interface Documentation

```php
// CategoryConfigServiceTest.php
public function testExpandGroupToSubcategories()
{
    // Setup: group "Girls Tops" with 3 DRS members
    $service = $this->createService();
    $group = $service->createGroup('Girls Tops', null, 'f1a0d5', ['GSST4', 'GLST4', 'GSST5']);

    // Expand group ID
    $codes = $service->expandGroupToSubcategories('grp_' . $group->id);
    $this->assertCount(3, $codes);
    $this->assertContains('GSST4', $codes);
    $this->assertContains('GLST4', $codes);
    $this->assertContains('GSST5', $codes);

    // Non-group code returns itself
    $codes = $service->expandGroupToSubcategories('WFOU');
    $this->assertEquals(['WFOU'], $codes);
}

public function testDropdownExcludesGroupedDRS()
{
    $service = $this->createService();
    $service->createGroup('Girls Tops', null, null, ['GSST4', 'GLST4']);

    $dropdown = $service->getCategoriesForDropdown();

    // Group appears
    $groups = array_filter($dropdown, fn($i) => $i['type'] === 'group');
    $this->assertCount(1, $groups);

    // Grouped DRS codes do NOT appear standalone
    $drs = array_filter($dropdown, fn($i) => $i['type'] === 'drs');
    $drsCodes = array_column($drs, 'id');
    $this->assertNotContains('GSST4', $drsCodes);
    $this->assertNotContains('GLST4', $drsCodes);
}

public function testHiddenCategoryStillInGroup()
{
    $service = $this->createService();
    $service->createGroup('Girls Tops', null, null, ['GSST4', 'GLST4']);
    $service->setVisibility('GSST4', false); // Hide GSST4

    // GSST4 still expands from group
    $codes = $service->expandGroupToSubcategories('grp_1');
    $this->assertContains('GSST4', $codes);

    // But GSST4 doesn't appear standalone in dropdown
    // (it's grouped anyway, so this is a double check)
    $dropdown = $service->getCategoriesForDropdown();
    $standaloneGSST4 = array_filter($dropdown, fn($i) => $i['id'] === 'GSST4' && $i['type'] === 'drs');
    $this->assertEmpty($standaloneGSST4);
}
```

## Runtime View

### Primary Flow: Assign Group to Bin

1. Manager opens bin create/edit modal
2. System fetches `/api/:typeNum/backstock/categories/?type=dropdown`
3. CategoryConfigService builds dropdown list: groups first, then ungrouped visible DRS, then custom
4. Syncfusion DropDownList populated with grouped data source
5. Manager selects "Girls Tops" (id: `grp_3`, type: `group`)
6. On save, `grp_3` stored in `bsBins.mainCategory`
7. When bin displayed, `makeBinReadable()` detects `grp_` prefix, calls `getDisplayInfo('grp_3')`
8. Returns `{name: 'Girls Tops', color: 'f1a0d5', type: 'group', memberCount: 5}`
9. UI shows group badge with folder icon and member count

```mermaid
sequenceDiagram
    actor Manager
    participant Modal as Bin Modal
    participant API as Category API
    participant CCS as CategoryConfigService
    participant DB as Store DB
    participant Sales as kiosk_sales

    Manager->>Modal: Open bin create
    Modal->>API: GET /categories/?type=dropdown
    API->>CCS: getCategoriesForDropdown()
    CCS->>DB: SELECT from bsCategoryGroups + members
    CCS->>DB: SELECT from bsCategoryVisibility
    CCS->>Sales: SELECT from subcategories
    CCS->>DB: SELECT from bsCategories
    CCS-->>API: Merged dropdown items
    API-->>Modal: JSON response
    Modal-->>Manager: Dropdown shows groups + ungrouped DRS + custom

    Manager->>Modal: Select "Girls Tops" (grp_3)
    Modal->>API: POST create bin {mainCategory: "grp_3"}
    API->>DB: INSERT bsBins (mainCategory = "grp_3")
    API-->>Modal: Success
```

### Secondary Flow: Floor Plan Report with Group

```mermaid
sequenceDiagram
    participant Report as Report UI
    participant SE as SpaceEfficiencyService
    participant CCS as CategoryConfigService
    participant DB as Store DB
    participant Sales as kiosk_sales

    Report->>SE: getSpaceEfficiencyReport(layoutId)
    SE->>DB: Get bins with mainCategory
    Note over SE: Bin has mainCategory = "grp_3"
    SE->>CCS: expandGroupToSubcategories("grp_3")
    CCS->>DB: SELECT subcategoryCode FROM bsCategoryGroupMembers WHERE groupId = 3
    CCS-->>SE: ["GSST4", "GLST4", "GSST5", "GLST6", "GST6P"]
    SE->>Sales: SUM sales for all 5 codes
    SE->>CCS: getDisplayInfo("grp_3")
    CCS-->>SE: {name: "Girls Tops", shortName: "Girls Tops", type: "group"}
    SE-->>Report: Row with aggregated data, labeled "Girls Tops"
```

### Error Handling

- **Invalid group ID in mainCategory**: `makeBinReadable()` falls through to "Unknown" category display. No crash.
- **Deleted group still referenced by bins**: On group delete, bins are set to `mainCategory = NULL` (if force=true). Report shows "Empty".
- **DRS category removed from POS**: Group members table still references the code. `expandGroupToSubcategories()` returns it. Sales query returns 0 for that code. Group still functions with remaining members.
- **Network failure on visibility toggle**: Toggle reverts to previous state. Toast error notification.
- **Duplicate group name**: 409 Conflict response. UI shows validation error.
- **Short name too long**: Client-side maxlength + server-side validation. 400 Bad Request.

### Complex Logic: Dropdown Filtering

```
ALGORITHM: Build Category Dropdown
INPUT: store concept, store DB connection
OUTPUT: ordered category list for Syncfusion DropDownList

1. LOAD grouped codes = SELECT DISTINCT subcategoryCode FROM bsCategoryGroupMembers
2. LOAD hidden codes = SELECT subcategoryCode FROM bsCategoryVisibility WHERE visible = 0
3. LOAD short names = SELECT * FROM bsCategoryShortNames
4. LOAD groups = SELECT * FROM bsCategoryGroups WITH member counts

5. BUILD dropdown items:
   a. FOR EACH group: ADD with type='group', id='grp_{id}', memberCount
   b. FOR EACH DRS subcategory WHERE concept matches:
      - SKIP if code IN grouped_codes (accessible via group only)
      - SKIP if code IN hidden_codes (hidden from dropdown)
      - ADD with type='drs', apply short name override if exists
   c. FOR EACH custom category: ADD with type='custom', include shortName

6. SORT: Groups first (by sortOrder), then DRS (by name), then Custom (by name)
7. RETURN items array
```

## Deployment View

- **Environment**: Same web server (ngrok to dev2.buyerkiosk.com). No new services.
- **Configuration**: No new env vars. Per-store data in store databases.
- **Dependencies**: No new external dependencies. Uses existing Syncfusion EJ2 license.
- **Performance**: Category dropdown data is small (typically <100 items). Group expansion query is O(members) with indexed lookups. No caching needed initially.
- **Migration**: 5 migration files run via `php userfrosting/conductor run`. Non-destructive (ADD TABLE, ADD COLUMN). Safe to run on all stores.
- **Rollback**: Drop new tables if needed. Bins with `grp_` mainCategory would show as "Unknown" until manually reassigned. No data loss for existing assignments.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: Store-scoped DB operations via dbConnectByName()
  relevance: CRITICAL
  why: "All new tables are per-store. CategoryConfigService receives store DB connection."

- pattern: Migration JSON format (check_query + sql)
  relevance: CRITICAL
  why: "All schema changes must follow migration system patterns."

- pattern: Syncfusion EJ2 component lifecycle (destroy/recreate on modal open)
  relevance: HIGH
  why: "Category dropdown and group member multi-select follow this pattern."

- pattern: Factory + Entity (BackstockFactory + Bin/Category)
  relevance: HIGH
  why: "New CategoryGroup follows same Entity pattern. CategoryConfigService follows Service pattern."
```

### System-Wide Patterns

- **Security**: Same permission checks via `checkAccess('uri_store_settings')` and `checkStoreGroup($typeNum)`. No new permission levels needed. Group/visibility management uses store-level access.
- **Error Handling**: All new endpoints use `try/catch (\Throwable $e)` pattern (per PHP 8.5 Throwable gotcha). JSON error responses with appropriate HTTP status codes.
- **Performance**: Dropdown query runs 4 small SELECT statements. No joins across large tables. Group expansion is a single indexed query on `bsCategoryGroupMembers`.
- **Logging/Auditing**: Group CRUD operations logged via existing `bsActions` table. Action codes TBD (suggest: 20=group created, 21=group edited, 22=group deleted, 23=visibility changed).

### Implementation Patterns

#### Code Patterns and Conventions
- **Namespace**: `BuyerKiosk\Backstock\CategoryConfigService`, `BuyerKiosk\Backstock\CategoryGroup`
- **Constructor injection**: CategoryConfigService receives `PDO $storeDb, string $concept`
- **camelCase columns**: All new table columns follow existing convention
- **Color handling**: Store hex without `#`, sanitize via `preg_replace('/[^A-Za-z0-9\-]/', '', $color)`

#### State Management Patterns
- **JS data caching**: `BackstockConfigManager` caches category data in `_categoriesData`. New group/visibility data cached similarly. Cache invalidated on any CRUD operation.
- **Modal lifecycle**: Category management modal tabs are lazy-loaded (data fetched when tab activated, not on modal open). Reduces initial load time.

#### Error Handling Pattern
```pseudocode
FUNCTION: handle_category_api_error(operation)
  TRY: execute operation
  CATCH Throwable:
    LOG: error details to error_log
    IF validation error: RESPOND 400 with field-specific messages
    IF conflict: RESPOND 409 with conflict details
    IF not found: RESPOND 404
    ELSE: RESPOND 500 with generic message
```

#### Component Structure Pattern
```pseudocode
COMPONENT: CategoryManagementModal
  TABS: [Custom Categories, DRS Visibility, Category Groups, Short Names]

  ON_TAB_ACTIVATE:
    IF data not loaded: FETCH data for tab
    RENDER tab content

  ON_CRUD_ACTION:
    VALIDATE input
    CALL API
    IF success: UPDATE local cache, REFRESH tab, NOTIFY dependents
    IF error: SHOW toast with error message

  ON_MODAL_CLOSE:
    IF changes made: INVALIDATE caches in BinCreation, MassEdit
```

### Integration Points

- **BackstockFactory**: Receives CategoryConfigService via constructor. Delegates dropdown building and group resolution.
- **SpaceEfficiencyService**: New dependency on CategoryConfigService. Calls `expandGroupToSubcategories()` when bin mainCategory starts with `grp_`.
- **ReplenishmentService**: New dependency on CategoryConfigService. `getOffsiteBinReport()` expands group categories. `getRecommendedBins()` matches bins with groups containing the target subcategory.
- **MobileApiController**: `getCategoriesWithBinCounts()` includes groups with bin counts. Mobile API response format extended with `type` field.

## Architecture Decisions

- [x] ADR-1 **Group ID Storage Format**: Use `grp_` prefix in `bsBins.mainCategory` VARCHAR(20)
  - Rationale: Fits within existing column. No schema change to bsBins. Easy string detection via `str_starts_with()`. Prefix convention is unambiguous (no DRS code starts with `grp_`).
  - Trade-offs: Slightly unconventional. Alternative was a separate `mainCategoryType` column, but that would require touching every query that reads mainCategory.
  - User confirmed: Yes (2026-04-21)

- [x] ADR-2 **Separate Tables for Config vs Inline Columns**: Use `bsCategoryVisibility`, `bsCategoryShortNames` tables instead of adding columns to a single config table
  - Rationale: Each config type has different cardinality (visibility = 1 per DRS code, short names = 1 per DRS code, groups = 1-to-many). Separate tables are normalized and easier to query independently.
  - Trade-offs: More tables (4 new). Alternative was a single `bsCategoryConfig` key-value table, but that makes JOINs harder and loses type safety.
  - User confirmed: Yes (2026-04-21)

- [x] ADR-3 **Visibility Default**: New DRS categories default to visible (opt-out model)
  - Rationale: When POS adds new subcategories, stores should see them by default. Hiding is intentional. An opt-in model would cause new POS categories to silently not appear.
  - Trade-offs: Stores that have heavily curated their dropdown will see new POS categories appear. Acceptable since it's visible and easy to hide.
  - User confirmed: Yes (2026-04-21)

- [x] ADR-4 **Group Reporting as Sum**: Groups sum all member categories' sales/buys data
  - Rationale: Simple, predictable. Proportional splitting adds complexity with minimal accuracy gain. The group IS an approximation -- summing is honest about that.
  - Trade-offs: Reports may over-count if a DRS category appears in multiple groups used on the same floor plan. This is a known approximation documented in the PRD.
  - User confirmed: Yes (2026-04-21)

- [x] ADR-5 **CategoryConfigService as New Service (not extending Category class)**
  - Rationale: Category.php is a simple CRUD entity for custom categories. Groups, visibility, and short names are cross-cutting concerns that span DRS + custom categories. A service class is the right abstraction level.
  - Trade-offs: New file, new dependency injection point. But cleaner separation of concerns.
  - User confirmed: Yes (2026-04-21)

## Quality Requirements

- **Performance**: Category dropdown loads in <200ms. Group expansion (for reporting) completes in <50ms per bin. No measurable impact on existing page load times.
- **Usability**: Modal tabs are intuitive. Search/filter on visibility list. Group creation takes <30 seconds. Visual type indicators visible at a glance on bin cards.
- **Security**: Same store-level access control. No cross-store data leakage. Category config APIs require `uri_store_settings` or equivalent permission.
- **Reliability**: Deleting a group with bins prompts confirmation. No silent data loss. Migrations are idempotent (check_query pattern). Rollback-safe.

## Risks and Technical Debt

### Known Technical Issues
- `bsBin_Cat` junction table has dual column usage (`catID` vs `catName`) from migration history. New features use `catName` + `categoryType` pattern consistently.
- `getCategoriesArray()` merges POS + custom with potential ID collisions (custom INT id could theoretically match a POS string code). Extremely unlikely but worth noting.

### Technical Debt
- `getCategoriesForDropdown()` currently in BackstockFactory will be partially duplicated in CategoryConfigService during transition. Plan: deprecate factory method, delegate to service.
- `massChangeCategory()` uses `categoryType` param that currently accepts 'pos_subcategory' or 'custom'. Adding 'group' as a third type.

### Implementation Gotchas
- **Syncfusion DropDownList grouping**: The `groupBy` field works well for simple grouping. Category Groups appearing as a distinct visual group requires `group: 'Category Groups'` in the data source. Must be first group in sort order.
- **PDO named params**: Cannot reuse `:param` in a single query. Group expansion queries with multiple member codes must use positional params or unique names.
- **makeBinReadable() timing**: Must be called AFTER `prepareLookupArrays()`. CategoryConfigService must be initialized before factory lookup arrays are built.
- **Modal tab lazy loading**: Syncfusion components inside hidden Bootstrap tabs will fail. Load tab data only when tab is shown (`shown.bs.tab` event).

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Group CRUD Happy Path**
```gherkin
Given: Store has DRS subcategories loaded
When: Manager creates group "Girls Tops" with members ["GSST4", "GLST4"]
Then: Group appears in database with 2 members
And: Group appears in dropdown with type='group'
And: Members GSST4 and GLST4 do NOT appear standalone in dropdown
```

**Scenario 2: Bin Assignment with Group**
```gherkin
Given: Group "Girls Tops" exists with id=3
When: Manager creates bin with mainCategory="grp_3"
Then: Bin is saved with mainCategory="grp_3"
And: makeBinReadable() returns {name: "Girls Tops", type: "group"}
And: Bin card shows group indicator
```

**Scenario 3: Floor Plan Report with Group**
```gherkin
Given: Bin has mainCategory="grp_3" (Girls Tops with codes GSST4, GLST4)
And: GSST4 has 100 items sold, GLST4 has 50 items sold
When: Floor plan report is generated
Then: Bin row shows "Girls Tops" label
And: Bin row shows 150 total items sold (sum of both codes)
```

**Scenario 4: Hidden Category Still in Group**
```gherkin
Given: GSST4 is hidden AND GSST4 is a member of "Girls Tops" group
When: Dropdown is built
Then: GSST4 does NOT appear standalone in dropdown
And: "Girls Tops" group DOES appear in dropdown
And: expandGroupToSubcategories("grp_3") DOES include GSST4
```

**Scenario 5: Delete Group with Bins**
```gherkin
Given: 5 bins have mainCategory="grp_3"
When: Manager tries to delete group 3 (no force)
Then: API returns 405 with affectedBinCount=5
When: Manager deletes with force=1
Then: Group is deleted
And: 5 bins have mainCategory set to NULL
```

**Scenario 6: Visibility Toggle**
```gherkin
Given: DRS subcategory WFOU is currently visible (default)
When: Manager toggles WFOU to hidden
Then: bsCategoryVisibility row created with visible=0
And: WFOU no longer appears in dropdown
And: Existing bins with mainCategory=WFOU still display correctly
```

### Test Coverage Requirements

- **Business Logic**: Group expansion, dropdown filtering, visibility rules, short name resolution, group deletion with bin handling
- **Integration Points**: SpaceEfficiencyService group expansion, ReplenishmentService group matching, makeBinReadable group resolution
- **Edge Cases**: Empty groups (all members removed from POS), DRS in multiple groups, max-length short names, concurrent group edit
- **API Validation**: Missing required fields, duplicate names, invalid subcategory codes, force-delete confirmation

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| DRS Category | POS subcategory from the store's point-of-sale system | Read-only reference data in kiosk_sales database |
| Category Group | User-defined bundle of DRS categories treated as a single classification | Stored in bsCategoryGroups with members in bsCategoryGroupMembers |
| Main Category | The primary classification assigned to a backstock bin | Stored in bsBins.mainCategory as VARCHAR(20) |
| Short Name | Abbreviated display name for use in compact report tables | Stored per custom category (bsCategories.shortName) or per DRS (bsCategoryShortNames) |
| Concept | 2-letter store type code (ou, pc, cm, se) | Filters which DRS categories are relevant to a store |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| grp_ prefix | Convention for storing group IDs in mainCategory column | `grp_3` means category group with id=3 |
| Visibility toggle | Per-store setting to hide DRS categories from dropdown | Stored in bsCategoryVisibility table |
| Group expansion | Resolving a group ID to its member DRS subcategory codes | Used by reporting services to aggregate sales data |
| Grouped DRS code | A DRS subcategory that belongs to at least one group | Excluded from standalone dropdown selection |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| type=dropdown | Query parameter for category API to get dropdown-formatted data | Returns items with group/type/memberCount fields |
| force=1 | Query parameter to confirm destructive operations | Used in group delete when bins reference the group |
| categoryType | Enum distinguishing source of a tag | Values: 'custom', 'pos_subcategory' (existing), 'group' (new for mass edit) |
