# ShiftPlacement

## Description

ShiftPlacement is the **placement of a workforce `Assignment` onto a `Shift`** — the single authoritative "who staffs this slot" record, now that Shift names nobody at all. It covers the ordinary case and the enterprise ones alike: filling an **open shift** (未割当), 応援 (support work by someone from another post), and multi-site placement where a worker is placed at a Site other than their home post (issues #11, #14, ADR-017).

Placements are **kept, never deleted**. A substitution supersedes the outgoing row and links it to its replacement; an unstaffing cancels it. This is what lets a confirmed shift schedule answer "who was planned here, and when did that change?" after the fact — and it is why staffing stays changeable right up to the shift date while the shift schedule's own definition is frozen at confirmation. A same-day swap is a change of plan, not a republication of the table.

Separating placement from the Shift itself lets one slot carry more than one placement (team shifts) and lets support / coverage be attributed to the right Assignment without mutating the demand slot. Because "whose" is resolved through a workforce `Assignment` (not a bare Worker), the same placement is what time-tracking and downstream cost attribution key on — the minimal one-dimensional allocation seam ADR-018 preserves for future multi-dimensional labor allocation.

## Domain Model Definitions

### Model type

Stateful

#### State Transitions

```mermaid
stateDiagram-v2
    [*] --> ACTIVE : createShiftPlacement / createShiftPlacements
    ACTIVE --> SUPERSEDED : swapShiftPlacement
    ACTIVE --> CANCELLED : releaseShiftPlacement
    SUPERSEDED --> [*]
    CANCELLED --> [*]
```

| Operation | From | To | Command |
|-----------|------|----|---------|
| supersede | ACTIVE | SUPERSEDED | [swapShiftPlacement](../command/SwapShiftPlacement.md) |
| release | ACTIVE | CANCELLED | [releaseShiftPlacement](../command/ReleaseShiftPlacement.md) |

### Command Definitions

- createShiftPlacement — place an Assignment onto a Shift (fill an open shift, or add 応援 / multi-site coverage) as `ACTIVE`; `provenance` defaults to `MANUAL`
- createShiftPlacements — bulk-place many Assignments onto many Shifts in one all-or-nothing transaction, with `provenance = GENERATED` (the counterpart to `createShifts`, used by rotation/shift schedule generation logic)
- swapShiftPlacement — replace who staffs a slot atomically: the outgoing placement becomes `SUPERSEDED` and links to the incoming `ACTIVE` one
- releaseShiftPlacement — unstaff the slot by moving the placement to `CANCELLED` (replaces the former `deleteShiftPlacement`)

### Query Definitions

- getShiftPlacement — retrieve a single placement by id
- listShiftPlacementsByShift — who is placed on a Shift, ACTIVE only unless `includeInactive`, paginated
- listShiftPlacementsByAssignment — where an Assignment is placed over a date range, ACTIVE only unless `includeInactive`, paginated

### Models

- ShiftPlacement

### Invariants

- Every ShiftPlacement references exactly one `Shift` and exactly one workforce `Assignment`
- The placed `Assignment` must be effective (ADR-013) on the Shift's `date` — you cannot place an assignment that does not exist on that date. This is enforced by the placement commands through an injected workforce `getAssignment`, since shiftSchedule cannot join workforce's tables
- `status` is one of `ACTIVE` / `SUPERSEDED` / `CANCELLED`; only `ACTIVE` placements staff a slot, and both non-ACTIVE states are terminal
- A placement is **never physically deleted** — unstaffing is `CANCELLED`, replacement is `SUPERSEDED`
- `supersededById` is set only on a `SUPERSEDED` row and points at the placement that replaced it; a released (`CANCELLED`) row leaves it null, which is what distinguishes a swap from an unstaffing
- `releasedAt` is stamped when the placement stops being `ACTIVE`, and is null while `ACTIVE`
- A placement may target only a live Shift; a withdrawn Shift (`cancelledAt` set) accepts no placements
- A shift is considered filled when at least one `ACTIVE` ShiftPlacement references it; open-shift detection and predicted-vs-actual variance both read `ACTIVE` only, or a swapped-out worker would keep being reported as a no-show and the replacement would not appear at all
- Withdrawing a Shift does **not** touch its placements, so "where is this Assignment placed" means `status = ACTIVE` **and** `Shift.cancelledAt` null — the two halves together are what makes a placement effective
- Staffing is not frozen by shift schedule confirmation: placements may be created, swapped, and released after the period is `CONFIRMED`
- 応援 (SUPPORT) / multi-site placement is **derived, not stored**: a placement whose Assignment's home Position / Site differs from the Shift's `siteId` represents support / multi-site work (the former stored `role` enum was dropped as a non-load-bearing hand-entered denormalization, issue #39)
- `assignedAt` records when the placement was made, contributing to the audit trail alongside effective-dated generations and approval decisions (ADR-013)
- `provenance` records the placement's origin: `GENERATED` (produced by an automated placement/generation process) or `MANUAL` (created or overridden by a person). A regeneration pass may safely retire `GENERATED` placements in a date range while leaving `MANUAL` ones untouched; a swap's replacement is always `MANUAL`, so a human decision survives regeneration

### Relationships

- **References Shift**: `shiftId` is the slot being staffed
- **References Assignment** (workforce, cross-module): `assignmentId` is who staffs it; resolves Worker / Position / Site via workforce
- **Fills open shifts and 応援**: a placement whose Assignment's home Site differs from the Shift's `siteId` expresses support and multi-site placement; any placement on an unstaffed slot is an open-shift fill
- **Variance against actuals**: worked time recorded in time-tracking (`ReportedTimeBlock` / `CalculatedTimeBlock`) for the placed Assignment is compared against the Shift's planned span to derive variance, over ACTIVE placements only (ADR-014)
