# 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 **Existing table**: Must enhance the `bsActions` table via migration — no separate audit system
CON-2 **Backward compatibility**: Existing action codes (0, 1, 2, 3, 6, 7, 8) and their consumers must continue working
CON-3 **Performance**: Logging must not add noticeable latency to bulk operations (already inside transaction loop)
CON-4 **Migration system**: All schema changes via JSON migration files (`userfrosting/migrations/input/`)
CON-5 **Three consumers**: Action code rendering exists in 3 places — all must be updated consistently

## Implementation Context

### Required Context Sources

- ICO-1 General Application Context
```yaml
- doc: docs/specs/042-backstock-mass-edit-bins/
  relevance: HIGH
  why: "Original mass edit spec that introduced all 9 bulk actions"

- doc: CLAUDE.md
  relevance: HIGH
  why: "Migration system rules, database conventions, testing commands"
```

- ICO-2 Backstock Action System
```yaml
- file: userfrosting/src/BuyerKiosk/Backstock/Action.php
  relevance: HIGH
  sections: [getReadableString (lines 67-109), createFromRow (lines 43-53), getUserNameArray (lines 204-222)]
  why: "Core action model — must extend with new codes and detail field"

- file: userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php
  relevance: HIGH
  sections: [logMassAction (lines 713-728), massChangeLocation (782-845), massEmptyBins (857-917), massChangeCategory (931-984), massChangeTags (999-1087), massHideBins (1099-1160), massUnhideBins (1172-1233), massPrintLabels (1246-1257), massUpdateAge (1270-1325), massAddNotes (1338-1402), makeBinReadable action switch (429-466), getAllRecentActions (48-57)]
  why: "All mass edit methods that call logMassAction — each needs unique action code + detail"

- file: userfrosting/routes/groups/backstock.php
  relevance: HIGH
  sections: [per-bin history endpoint (286-345), global actions endpoint (544-549), mass edit routes (765-1111)]
  why: "Route handlers that pass userId and render action history"
```

- ICO-3 Migration System
```yaml
- file: userfrosting/migrations/input/20251203_001_backstock_bin_hide_feature.json
  relevance: MEDIUM
  why: "Recent migration pattern for altering store-level tables"
```

### Implementation Boundaries

- **Must Preserve**: All existing action codes (0-3, 6-8) continue to work identically
- **Can Modify**: `logMassAction()`, `getReadableString()`, `makeBinReadable()` action switches, per-bin history endpoint switch, `Action` model properties
- **Must Not Touch**: Individual (non-bulk) action logging via `Action::create()`, the `bsBins` table schema

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    User[Store Manager / Team Member]

    User --> BulkActionUI[Bulk Action Modals]
    BulkActionUI --> MassEditRoutes[Mass Edit API Routes]
    MassEditRoutes --> BackstockFactory[BackstockFactory Mass Methods]
    BackstockFactory --> bsActions[(bsActions Table)]

    bsActions --> GlobalFeed[Global Activity Feed GET /actions]
    bsActions --> BinHistory[Per-Bin History GET /bin/:id/actions]
    bsActions --> BinCard[Bin Card Last Action Display]
    bsActions --> Reports[ReportService Activity Queries]

    GlobalFeed --> User
    BinHistory --> User
    BinCard --> User
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit         # Run unit tests
./test.sh --testsuite integration  # Run integration tests

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

# Targeted Tests
cd userfrosting && ./vendor/bin/phpunit --filter "Backstock"  # Backstock-specific tests
```

## Solution Strategy

- **Architecture Pattern**: Extend-in-place — enhance the existing `bsActions` table and `Action` model rather than creating parallel systems
- **Integration Approach**: Add two nullable columns (`detail` and `batchId`) to `bsActions`, introduce new action codes for mass-specific operations, update all three rendering consumers
- **Justification**: The existing action logging infrastructure works correctly for individual actions. The problem is solely that mass actions reuse generic codes and lack detail. Extending is lower risk and lower effort than replacing.
- **Key Decisions**:
  1. New action codes (10-18) for mass-specific operations keep backward compatibility
  2. VARCHAR `detail` column for human-readable change context
  3. VARCHAR `batchId` column (UUID format) to group actions from one bulk operation

## Building Block View

### Components

```mermaid
graph LR
    Routes[Mass Edit Routes] --> Factory[BackstockFactory]
    Factory --> LogHelper["logMassAction()"]
    LogHelper --> DB[(bsActions)]

    DB --> ActionModel[Action Model]
    ActionModel --> ReadableString["getReadableString()"]
    ActionModel --> BinReadable["makeBinReadable()"]

    ReadableString --> GlobalFeed[Global Feed API]
    BinReadable --> BinCards[Bin Card Display]
    DB --> BinHistory[Per-Bin History API]
```

### Directory Map

**Component**: Backstock Module
```
userfrosting/
├── src/BuyerKiosk/Backstock/
│   ├── Action.php                    # MODIFY: Add detail/batchId properties, extend getReadableString() with codes 7-18
│   └── BackstockFactory.php          # MODIFY: Update logMassAction() signature, update all 9 mass methods, update makeBinReadable()
├── routes/groups/
│   └── backstock.php                 # MODIFY: Generate batchId in routes, pass detail strings, update per-bin history switch
└── migrations/input/
    └── 20260402_001_bsactions_audit_columns.json  # NEW: Add detail, batchId columns + index

tests/
└── Unit/Backstock/
    └── ActionCodeTest.php            # NEW: Test all action codes render correctly
```

### Interface Specifications

#### Data Storage Changes

```yaml
Table: bsActions (MODIFY)
  ADD COLUMN: detail VARCHAR(500) NULL AFTER categoryID
    # Human-readable change context. Examples:
    # "→ Back Room" (location change)
    # "→ Electronics" (category change)
    # "age → 2026-03-15" (age update)
    # "tags +Furniture, +Decor" (tag addition)
    # "tags -Furniture" (tag removal)
    # NULL for empty/hide/unhide/print (no additional context needed)

  ADD COLUMN: batchId VARCHAR(36) NULL AFTER detail
    # UUID linking all actions from one bulk operation
    # NULL for individual (non-bulk) actions
    # Format: UUID v4 (e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890")

  ADD INDEX: idx_batchId (batchId)
    # For grouping batch operations in the activity feed
```

#### New Action Codes

| Code | Name | Mass Method | Detail Format | Example Display |
|------|------|-------------|---------------|-----------------|
| 0 | empty | massEmptyBins | NULL | "Casey emptied Bin A" |
| 1 | add_category | (individual only) | — | (no change) |
| 2 | remove_some | (individual only) | — | (no change) |
| 3 | remove_all_category | massChangeTags(remove) | tag names | "Casey removed tags from Bin A (tags -Furniture)" |
| 6 | add_items | (individual only) | — | (no change — keep for backward compat) |
| 7 | hide | massHideBins | NULL | "Casey hid Bin A" |
| 8 | unhide | massUnhideBins | NULL | "Casey unhid Bin A" |
| 10 | mass_change_location | massChangeLocation | "→ {locationName}" | "Casey moved Bin A → Back Room" |
| 11 | mass_change_category | massChangeCategory | "→ {categoryName}" | "Casey changed category of Bin A → Electronics" |
| 12 | mass_add_tags | massChangeTags(add) | "+{tag1}, +{tag2}" | "Casey added tags to Bin A (+Furniture, +Decor)" |
| 13 | mass_update_age | massUpdateAge | "age → {date}" | "Casey updated age of Bin A (age → 2026-03-15)" |
| 14 | mass_add_notes | massAddNotes | NULL | "Casey added note to Bin A" |
| 15 | mass_print_labels | massPrintLabels | "qty: {n}" | "Casey printed label for Bin A" |

**Key design decision**: Codes 0, 3, 7, 8 are KEPT for mass operations that already use them correctly (empty, tag remove, hide, unhide). We only introduce NEW codes (10-15) for operations that were incorrectly using code 6.

#### Internal API Changes

No new API endpoints. Existing endpoints return enhanced data:

```yaml
Endpoint: Per-Bin History (existing, enhanced response)
  Method: GET
  Path: /api/:typeNum/backstock/bin/:bin_id/actions
  Response (each action object gains):
    detail: string|null     # NEW — change context
    batchId: string|null    # NEW — bulk operation group ID
    isBulk: boolean         # NEW — derived from batchId !== null
    batchSize: int|null     # NEW — count of actions sharing same batchId (only if isBulk)

Endpoint: Global Activity Feed (existing, no response change needed)
  Method: GET
  Path: /api/:typeNum/backstock/actions
  # getAllRecentActions() already returns Action objects
  # Action objects will automatically include detail/batchId via createFromRow()
```

#### Application Data Models

```pseudocode
ENTITY: Action (MODIFIED)
  FIELDS:
    id: int
    binID: int
    employeeID: int
    categoryID: int|null
    action: int
    timeStamp: datetime
    + detail: string|null (NEW)
    + batchId: string|null (NEW)
    readableString: string
    employeeName: string
    dateReadable: string
    dateReadableShort: string

  BEHAVIORS:
    create(): Action|null
    createFromRow(row): bool
    ~ getReadableString(): string (CHANGED — add cases 7, 8, 10-15 with detail integration)
    getDateReadable(): string
    + isBulk(): bool (NEW — return batchId !== null)

ENTITY: BackstockFactory (MODIFIED)
  BEHAVIORS:
    ~ logMassAction(binId, actionCode, userId, categoryId, detail, batchId): void
      (CHANGED — add detail and batchId parameters)
    ~ massChangeLocation(...): array (CHANGED — pass detail="→ {name}", generate batchId)
    ~ massEmptyBins(...): array (CHANGED — generate batchId)
    ~ massChangeCategory(...): array (CHANGED — use code 11, pass detail="→ {name}", generate batchId)
    ~ massChangeTags(...): array (CHANGED — use code 12 for add, pass detail="+tags", generate batchId)
    ~ massHideBins(...): array (CHANGED — generate batchId)
    ~ massUnhideBins(...): array (CHANGED — generate batchId)
    ~ massPrintLabels(...): array (CHANGED — add logging with code 15, generate batchId)
    ~ massUpdateAge(...): array (CHANGED — use code 13, pass detail="age → {date}", generate batchId)
    ~ massAddNotes(...): array (CHANGED — use code 14, generate batchId)
    ~ makeBinReadable(): Bin (CHANGED — add cases 7, 8, 10-15 in action switch)
```

### Implementation Examples

#### Example: Updated logMassAction with detail and batchId

**Why this example**: The core logging method signature change affects all 9 mass methods.

```php
private function logMassAction(int $binId, int $actionCode, int $userId, ?int $categoryId = null, ?string $detail = null, ?string $batchId = null): void
{
    $stmt = $this->storeDB->prepare(
        "INSERT INTO bsActions (binID, action, employeeID, categoryID, detail, batchId, timePerformed)
         VALUES (:binID, :action, :employeeID, :categoryID, :detail, :batchId, CURRENT_TIMESTAMP)"
    );
    $stmt->bindValue(':binID', $binId);
    $stmt->bindValue(':action', $actionCode);
    $stmt->bindValue(':employeeID', $userId);
    $stmt->bindValue(':categoryID', $categoryId, $categoryId === null ? \PDO::PARAM_NULL : \PDO::PARAM_INT);
    $stmt->bindValue(':detail', $detail, $detail === null ? \PDO::PARAM_NULL : \PDO::PARAM_STR);
    $stmt->bindValue(':batchId', $batchId, $batchId === null ? \PDO::PARAM_NULL : \PDO::PARAM_STR);
    $stmt->execute();
}
```

#### Example: Updated getReadableString with all action codes

**Why this example**: This is the most complex change — all action codes need readable output.

```php
switch((int)$this->action) {
    case 0:
        $output = $employeeShortName . ". emptied " . $bin->name;
        break;
    case 1:
        $categoryName = isset($categories[$this->categoryID]['name']) ? $categories[$this->categoryID]['name'] : 'Unknown Category';
        $output = $employeeShortName . ". added some " . $categoryName . " to " . $bin->name;
        break;
    case 2:
        // ... existing code ...
        break;
    case 3:
        $categoryName = isset($categories[$this->categoryID]['name']) ? $categories[$this->categoryID]['name'] : 'Unknown Category';
        $output = $employeeShortName . ". removed all of the " . $categoryName . " from " . $bin->name;
        if ($this->detail) {
            $output .= " (" . $this->detail . ")";
        }
        break;
    case 7:
        $output = $employeeShortName . ". hid " . $bin->name;
        break;
    case 8:
        $output = $employeeShortName . ". unhid " . $bin->name;
        break;
    case 10:
        $output = $employeeShortName . ". moved " . $bin->name;
        if ($this->detail) {
            $output .= " " . $this->detail;  // "→ Back Room"
        }
        break;
    case 11:
        $output = $employeeShortName . ". changed category of " . $bin->name;
        if ($this->detail) {
            $output .= " " . $this->detail;  // "→ Electronics"
        }
        break;
    case 12:
        $output = $employeeShortName . ". added tags to " . $bin->name;
        if ($this->detail) {
            $output .= " (" . $this->detail . ")";  // "(+Furniture, +Decor)"
        }
        break;
    case 13:
        $output = $employeeShortName . ". updated age of " . $bin->name;
        if ($this->detail) {
            $output .= " (" . $this->detail . ")";  // "(age → 2026-03-15)"
        }
        break;
    case 14:
        $output = $employeeShortName . ". added note to " . $bin->name;
        break;
    case 15:
        $output = $employeeShortName . ". printed label for " . $bin->name;
        break;
    default:
        $output = $employeeShortName . ". updated " . $bin->name;
        break;
}
```

#### Example: BatchId generation in route handler

**Why this example**: Shows where batchId is created (route level, once per request) and passed down.

```php
// In each mass edit route handler:
$batchId = \Ramsey\Uuid\Uuid::uuid4()->toString();
// OR simpler without dependency:
$batchId = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
    mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
    mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
    mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));

$result = $factory->massChangeLocation($binIds, (int)$json->locationId, (int)$app->user->id, $batchId);
```

#### Example: Per-bin history endpoint with batch enrichment

**Why this example**: Shows how to add isBulk and batchSize to the history response.

```php
$actions[] = [
    'id' => $row['id'],
    'dateReadable' => $action->dateReadable,
    'employeeName' => $action->employeeName,
    'actionText' => $actionText,
    'categoryName' => $categoryName,
    'detail' => $row['detail'] ?? null,
    'batchId' => $row['batchId'] ?? null,
    'isBulk' => !empty($row['batchId']),
];

// After collecting all actions, enrich with batch sizes
$batchIds = array_filter(array_unique(array_column($actions, 'batchId')));
$batchSizes = [];
if (!empty($batchIds)) {
    $placeholders = implode(',', array_fill(0, count($batchIds), '?'));
    $batchStmt = $db->prepare("SELECT batchId, COUNT(*) as cnt FROM bsActions WHERE batchId IN ($placeholders) GROUP BY batchId");
    $batchStmt->execute(array_values($batchIds));
    while ($brow = $batchStmt->fetch(\PDO::FETCH_ASSOC)) {
        $batchSizes[$brow['batchId']] = (int)$brow['cnt'];
    }
}

foreach ($actions as &$a) {
    $a['batchSize'] = $a['batchId'] ? ($batchSizes[$a['batchId']] ?? null) : null;
}
```

## Runtime View

### Primary Flow: Bulk Action with Audit Logging

1. User selects bins on backstock overview and clicks a bulk action (e.g., "Change Location")
2. User confirms in modal → AJAX POST to `/api/:typeNum/backstock/mass/change-location`
3. Route handler validates input, generates `batchId` (UUID), creates `BackstockFactory`
4. Route resolves location name for detail string (e.g., "→ Back Room")
5. `massChangeLocation()` loops through bins:
   - Reads bin, skips if already at target
   - Updates bin location
   - Calls `logMassAction(binId, 10, userId, null, "→ Back Room", batchId)`
   - Each INSERT writes action code 10 + detail + batchId to `bsActions`
6. Transaction commits
7. Response returns updated bins to frontend

```mermaid
sequenceDiagram
    actor User
    participant Modal as Bulk Action Modal
    participant Route as Mass Edit Route
    participant Factory as BackstockFactory
    participant DB as bsActions Table
    participant Feed as Activity Feed

    User->>Modal: Select bins + confirm action
    Modal->>Route: POST /mass/change-location {binIds, locationId}
    Route->>Route: Generate batchId (UUID)
    Route->>Route: Resolve location name for detail
    Route->>Factory: massChangeLocation(binIds, locationId, userId, batchId)

    loop Each bin
        Factory->>DB: UPDATE bsBins SET location = :loc
        Factory->>DB: INSERT bsActions (action=10, detail="→ Back Room", batchId=uuid)
    end

    Factory-->>Route: BatchResult {updated, skipped, failed, bins}
    Route-->>Modal: JSON response

    Note over Feed: Later, when viewed...
    User->>Feed: View activity feed
    Feed->>DB: SELECT * FROM bsActions ORDER BY timePerformed DESC
    DB-->>Feed: Actions with codes 10, detail, batchId
    Feed-->>User: "Casey moved Bin A → Back Room (bulk: 12 bins)"
```

### Error Handling

- **Invalid input** (bad locationId, malformed date): Route returns 400 with message — no actions logged
- **Bin not found during loop**: Bin added to `failed[]`, no action logged for that bin, loop continues
- **Database error mid-transaction**: Transaction rolls back, no partial actions logged
- **NULL detail/batchId**: Columns are nullable, old code paths continue to work with NULL values
- **Deleted location/category referenced in detail**: Detail is a snapshot string, not a foreign key — always displays as-is

## Deployment View

No change to existing deployment process. The migration adds nullable columns, so it's non-breaking.

- **Migration**: Run `php userfrosting/conductor run` — adds 2 columns + 1 index to `bsActions` per store DB
- **Performance**: The `bsActions` table gains ~37 bytes per row (500-char VARCHAR is stored at actual length). No measurable impact.
- **Rollback**: Columns are nullable and ignored by old code paths. Simply revert the PHP code changes.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
- pattern: docs/specs/042-backstock-mass-edit-bins/
  relevance: HIGH
  why: "Established the batch result pattern and mass method signatures"
```

### System-Wide Patterns

- **Security**: No change — existing `checkStoreGroup()` + `$app->user->id` auth is sufficient
- **Error Handling**: Existing try/catch per-bin pattern preserved; failed bins don't log actions
- **Performance**: logMassAction already runs inside per-bin loop — adding 2 bound params is negligible
- **Logging/Auditing**: This IS the auditing improvement — making bsActions a proper audit trail

### Implementation Patterns

#### Code Patterns and Conventions
- Follow existing camelCase column naming (`batchId`, `detail`)
- Follow existing switch/case pattern in `getReadableString()` and `makeBinReadable()`
- Keep `logMassAction()` as private method on `BackstockFactory`

#### State Management Patterns
- `batchId` generated once per HTTP request in the route handler, passed through to factory method
- `detail` string constructed in the factory method where context (location name, category name) is available
- No frontend state changes required — activity feed consumes whatever the API returns

#### Performance Characteristics
- Two additional VARCHAR columns add negligible storage
- `idx_batchId` index enables efficient batch grouping queries
- Batch size calculation in per-bin history uses a single grouped COUNT query

#### Component Structure Pattern
```pseudocode
ROUTE HANDLER: Mass Edit
  GENERATE: batchId = uuid()
  RESOLVE: detail context (location name, category name, etc.)
  DELEGATE: factory.massMethod(binIds, ..., userId, batchId)

FACTORY METHOD: massXxx
  LOOP: each binId
    VALIDATE: bin exists, not already at target state
    EXECUTE: SQL update
    LOG: logMassAction(binId, specificCode, userId, categoryId, detail, batchId)
  RETURN: BatchResult

ACTION MODEL: getReadableString
  SWITCH: action code
    RENDER: human-readable string with detail appended where applicable
    DEFAULT: "updated [bin]" (catch-all for unknown codes)
```

#### Error Handling Pattern
```pseudocode
FUNCTION: logMassAction(binId, actionCode, userId, categoryId, detail, batchId)
  # This runs INSIDE the per-bin try/catch in each mass method
  # If it fails, the bin is added to failed[] and the loop continues
  # The transaction wrapping all bins ensures atomicity
  PREPARE: INSERT statement with 6 bound params
  EXECUTE: insert
  # No separate error handling — exception propagates to per-bin catch
```

#### Test Pattern
```pseudocode
TEST_SCENARIO: "Each mass action type logs correct action code"
  SETUP: Create BackstockFactory with mock store DB
  EXECUTE: Call each massXxx method with known binIds
  VERIFY: bsActions contains rows with expected action codes, detail, batchId

TEST_SCENARIO: "getReadableString renders all action codes"
  SETUP: Create Action objects with each code (0-3, 6-8, 10-15)
  EXECUTE: Call getReadableString() on each
  VERIFY: Non-empty, human-readable output for every code

TEST_SCENARIO: "Batch grouping works"
  SETUP: Insert 5 actions with same batchId, 2 with different batchId
  EXECUTE: Query per-bin history with batch enrichment
  VERIFY: First group has batchSize=5, second has batchSize=2
```

## Architecture Decisions

- [x] ADR-1 **Extend bsActions vs. new table**: Extend existing table with `detail` + `batchId` columns
  - Rationale: bsActions is already the single source of truth for backstock activity. Adding a parallel table would require syncing and creates confusion.
  - Trade-offs: Table rows grow slightly; existing `SELECT *` queries return extra columns (nullable, so no breakage)
  - User confirmed: _Pending_

- [x] ADR-2 **New action codes (10-15) vs. reuse code 6 with detail parsing**: Use dedicated new codes
  - Rationale: Explicit codes enable filtering by action type in queries and UI. Parsing detail strings is fragile.
  - Trade-offs: More cases in switch statements; old mass actions logged as code 6 won't retroactively change
  - User confirmed: _Pending_

- [x] ADR-3 **Detail as snapshot string vs. foreign key references**: Store detail as VARCHAR snapshot
  - Rationale: Location/category names can change or be deleted. A snapshot preserves the audit record as it was at the time of the action.
  - Trade-offs: Detail text can't be "updated" if a location is renamed (but this is actually desirable for audit)
  - User confirmed: _Pending_

- [x] ADR-4 **UUID batchId vs. auto-increment batch table**: Use UUID string directly in bsActions
  - Rationale: No need for a separate batch metadata table. UUID is generated in PHP, stored as VARCHAR(36), grouped by value. Simple and sufficient.
  - Trade-offs: 36 bytes per row vs. 4 bytes for an INT FK. At our scale this is negligible.
  - User confirmed: _Pending_

- [x] ADR-5 **batchId generation location**: Route handler (not factory method)
  - Rationale: One batchId per HTTP request. The route handler is the natural scope boundary. Factory methods are pure business logic.
  - Trade-offs: Factory method signatures gain a `$batchId` parameter
  - User confirmed: _Pending_

## Quality Requirements

- **Completeness**: 100% of mass action types produce unique, non-empty readable strings
- **Accuracy**: Action codes correctly identify the specific mass operation type
- **Backward Compatibility**: Existing action codes (0-3, 6) continue to render identically for old log entries
- **Performance**: Bulk operation of 100 bins completes in under 5 seconds (same as current)
- **Auditability**: Every bulk action is traceable to a specific user and timestamp via batchId grouping

## Risks and Technical Debt

### Known Technical Issues
- Existing mass actions logged as code 6 will remain as "added items to bin" in the activity feed. No retroactive fix — this is expected.
- `massPrintLabels()` currently has no `$userId` parameter. Need to add it from the route.

### Technical Debt
- Three separate switch statements rendering action codes (Action::getReadableString, BackstockFactory::makeBinReadable, per-bin history route) — ideally these would be consolidated into one method. This spec does NOT consolidate them (out of scope) but does update all three.
- `bsBin_Cat` uses `catName` instead of `catID` — existing debt, not introduced here.

### Implementation Gotchas
- **PDO param reuse**: If any query binds `:detail` twice, it will fail with HY093. Use unique param names.
- **`massPrintLabels()` has no `$userId`**: Must add `$userId` and `$batchId` params. Route currently doesn't pass userId because print didn't log before.
- **`makeBinReadable()` only fetches LAST action**: The switch there shows the most recent action on each bin card. Must add cases for all new codes so bin cards display correctly after mass operations.
- **Tag names for detail**: massChangeTags receives `$tagIds` which are category IDs. Need to resolve names from categoriesArray before building the detail string.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Each mass action logs correct code and detail**
```gherkin
Given: A store with bins [1, 2, 3] and location "Back Room" (id=21)
When: massChangeLocation is called with bins [1,2,3], locationId=21, batchId="abc-123"
Then: 3 rows inserted into bsActions with action=10, detail="→ Back Room", batchId="abc-123"
And: Each row has the correct binID and employeeID
```

**Scenario 2: getReadableString renders all codes**
```gherkin
Given: Action objects with codes [0, 1, 2, 3, 6, 7, 8, 10, 11, 12, 13, 14, 15]
When: getReadableString() is called on each
Then: Every call returns a non-empty string
And: Codes 7 returns "hid [bin]"
And: Codes 8 returns "unhid [bin]"
And: Code 10 returns "moved [bin] → [location]"
And: Unknown codes return "updated [bin]" (default case)
```

**Scenario 3: Skipped bins don't log**
```gherkin
Given: Bin 1 is already at location 21
When: massChangeLocation is called with bin [1], locationId=21
Then: Bin 1 is in skipped[], NOT in updated[]
And: No bsActions row is created for bin 1
```

**Scenario 4: Batch grouping in history**
```gherkin
Given: 5 bsActions rows with batchId="abc-123" and 2 with batchId="def-456"
When: Per-bin history endpoint is queried
Then: Actions with batchId="abc-123" have batchSize=5
And: Actions with batchId="def-456" have batchSize=2
And: Actions with NULL batchId have batchSize=null
```

### Test Coverage Requirements

- **Business Logic**: All 9 mass methods log correct action code, detail, and batchId
- **Rendering**: All action codes (0-3, 6-8, 10-15) produce correct readable strings in all 3 consumers
- **Edge Cases**: Empty binIds array, all bins skipped (no log entries), deleted category/location in old detail
- **Backward Compatibility**: Old action code 6 entries still render as "added items to bin"
- **Integration**: Migration applies cleanly on store DBs, new columns are queryable

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Bin | A physical storage container in the backstock area | Target of all actions logged in bsActions |
| Bulk/Mass Action | An operation applied to multiple bins simultaneously from the overview page | What triggers batch logging |
| Activity Feed | The log of all backstock actions, viewable globally or per-bin | Primary consumer of action records |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Action Code | Integer in `bsActions.action` identifying the type of operation | 0-3 (legacy), 6 (generic), 7-8 (hide/unhide), 10-15 (new mass-specific) |
| BatchId | UUID string grouping all bsActions rows from one bulk operation | Stored in `bsActions.batchId`, NULL for individual actions |
| Detail | Snapshot string recording what specifically changed | Stored in `bsActions.detail`, e.g., "→ Back Room" |
| BatchResult | Standard return format from mass methods: `{updated[], skipped[], failed[], bins[]}` | Existing pattern from Spec 042 |
