# Scheduling

Phases 0–1 of `docs/porting/port-01-scheduling-payroll.md`. Punches have their own [contract and rules](PUNCHES.md). Shared models are `StoreSettings`, `Shift`, and `SchedulePublication`. HTTP contract additions live in `api/contracts/fragments/scheduling.yaml`; the integration owner merges them into canonical OpenAPI.

## HTTP contract

All routes use Sanctum and `/v1/stores/{store}`. Success envelopes are `{data: ...}`. No legacy `users.store_id` fallback. Capabilities are resolved by Port 00 `StoreViewer`; platform admins can manage but **cannot impersonate staff self-service without a real active membership**.

| Method and suffix | Gate | Response |
|---|---|---|
| GET `/schedule/settings` | `schedule.view` | Eight scheduling settings |
| PATCH `/schedule/settings` | `settings.schedule` | Updated settings |
| GET `/schedule/week?week_start=YYYY-MM-DD&position_id=UUID` | `schedule.view` | Week bounds, shifts, publication |
| POST `/schedule/shifts` | `schedule.manage` | Created Shift, HTTP 201 |
| PATCH `/schedule/shifts/{shift}` | `schedule.manage` | Updated Shift |
| DELETE `/schedule/shifts/{shift}` | `schedule.manage` | `{deleted:true}`, HTTP 200 |
| POST `/schedule/copy-week` | `schedule.manage` | Preview/commit candidates and counts |
| POST `/schedule/publish` | `schedule.publish` | Updated week |
| POST `/schedule/unpublish` | `schedule.publish` | Updated week, publication null |
| GET `/my/schedule?week_start=YYYY-MM-DD` | `store.member` | Own published shifts only |

Settings: IANA `timezone` (America/Los_Angeles), `work_week_start_day` (mon), `work_week_start_time` (00:00), `payroll_rounding_mode` (none), `payroll_rounding_increment_minutes` (1), and `clock_in_early_minutes`, `clock_in_late_minutes`, `clock_out_late_minutes` (all 0). Windows allow 0–1440; rounding increment 1–60. `WorkWeekStartDay` and `PayrollRoundingMode` backed enums validate choices. Existing StoreSettings scalar properties are retained for the clock/payroll coupling; there is no enum-valued cast on those two shared properties. Store provisioning must create StoreSettings with its required commerce fields; scheduling never silently creates free-shipping settings if that row is missing (404).

Changing timezone or either work-week boundary setting is HTTP 409 once this store has an approved or exported timesheet. Reposting the same values is allowed. Clock windows and rounding remain editable; payroll export retains its own immutable snapshot. Pending payroll must be recalculated after a boundary change. No approved/exported history is silently reinterpreted.

Shift input: `starts_at`, `ends_at` required on create; nullable `store_membership_id` (null = open), nullable `position_id`, nullable `notes` (max 5000). PATCH fields are partial. Both timestamps require full ISO8601 with explicit Z/offset, at most six fractional digits; end must follow start. Resource fields: id, store_id, store_membership_id, member_name, position_id, position_name, starts_at, ends_at, notes, published_at, revision. Child IDs are scoped to the route store on create, edit, publish, copy, and week position filtering. Cross-store child IDs are 404, not validation 422. Inactive position or terminated assignee is 422.

PATCH and DELETE require body `revision`: the exact opaque token returned by Shift. HTTP output retains all six microsecond digits. SQL model date formats and timestamp columns retain microseconds; overlap query bindings are explicit microsecond strings because Laravel's query grammar otherwise truncates DateTime bindings. Tokens advance monotonically even under frozen/same-microsecond time. Stale requests receive 409, including a draft token held across publishing/unpublishing. Shifts are soft-deleted; deleted records do not overlap or appear in views.

## Work weeks and visibility

`Services/WorkWeek.php` preserves BuyerKiosk `Services/WorkWeek.php` math near-verbatim. All eight source test methods were adapted before implementing the service. Distinct semantics remain:

- A date **label** maps to its configured weekday and own cutoff, even if midnight precedes that cutoff.
- An **instant** before this week's cutoff belongs to the previous week.
- Fall-back ambiguous cutoff uses the first occurrence; spring-forward missing cutoff normalizes forward.
- Previous/next week cutoffs are rebuilt from the configured wall time, not +/- seven days on a normalized cutoff instant. Adjacent weeks tile without payroll gaps or double counts.

Week views normalize the requested label, return UTC `starts_at` and exclusive `ends_at`, and select shifts by **start instant**. An overnight shift appears once, in the week containing its start. A week query is bounded to that one work week and is not paginated. `position_id` is optional and tenant-checked.

Owners/managers with `schedule.view_drafts` see drafts. Team viewers without that capability see published shifts only. `/my/schedule` always filters the server-resolved membership and published stamps; an injected query member ID never selects somebody else's schedule. Publishing does not grant access to another store.

## Publication and termination

Publish input is `{week_start}`. Within one transaction: acquire scheduling locks, revalidate every assignee/position/overlap, stamp all week shifts, advance every revision, upsert the unique `(store_id, week_start)` publication row, and audit. Failure rolls back all rows. Unpublish mirrors this: clear week stamps, advance revisions, remove the publication row, and audit.

Editing a published shift clears **that shift's** published_at/published_by. Staff never see unpublished changes. Other published shifts remain visible. The publication row is the **last successful publication event**, with its then-current `shift_count`; it is not a promise that every current shift is still published. Clients should inspect shift stamps for draft/published status. Re-publish refreshes counts; explicit unpublish removes the row. No preserved published snapshot is implemented.

`MembershipTerminated` has a synchronous centrally registered listener. In Team's existing transaction it unassigns shifts starting strictly after server now, clears publication, advances revisions, and audits. Past/ongoing shifts retain their historical assignee. A caller rollback rolls back termination and unassignment together. Team never imports Scheduling.

## Overlap, locking and copy

Assigned intervals use strict half-open overlap: `existing.start < candidate.end && existing.end > candidate.start`. Adjacent shifts are legal. Open shifts do not collide. Draft and published assigned shifts both count. Assignment edits recheck overlap.

Every scheduling mutation uses a retried SQL transaction and `ScheduleWriteLock`: store row first, then all store memberships ordered by UUID, then shifts. Locking a parent—not only possibly nonexistent overlapping rows—serializes simultaneous new assignments and store-wide publish/copy. Membership-before-shift ordering also interoperates with Team's synchronous membership-then-shift termination flow. **The termination listener must not acquire the store lock.** SQLite ignores FOR UPDATE, so it first acquires a writer reservation via a no-op store UPDATE before any scheduling snapshot reads. A regression test runs two independent PHP processes and real SQL connections simultaneously: one create succeeds (201), the other conflicts (409), and exactly one row persists. Production MySQL/PostgreSQL lock behavior has not been exercised here; the SQL locking path is explicit, not mocked.

Copy input is `{source_week_start,target_week_start,preview}`; preview is mandatory (never implicit commit). Both labels normalize; equal normalized weeks are 422. UTC instants become store-local, are offset by signed **calendar date** difference, then return to UTC, preserving 9 AM across DST. BuyerKiosk `calculateDayOffset` is preserved. Gap/fold and reverse-copy fixtures exercise the source runtime's wall-time behavior. DST may change elapsed duration; a nonpositive result is a conflict.

Response: `{source_week_start,target_week_start,preview,shift_count,copied,conflict_count,conflicts,shifts}`. Preview candidates have null id/revision and persist no shifts. Conflicts include source_shift_id and message. Commit recomputes candidates under locks, checks existing and intra-batch overlaps, rejects **the entire batch** with 409 on any conflict, and otherwise creates drafts. There is no destructive overwrite/skip mode. Preview is advisory, not a reservation; clients must handle commit-time conflicts. Open-shift commits are not idempotency-key replayed.

## Fidelity and deviations

- Preserved: complete source WorkWeek regression matrix; label/instant distinction; DST cutoff and copy-day arithmetic; UTC persistence; nullable open shifts; strict overlap; microsecond revisions; transaction-scoped publication and termination audit.
- Rebuilt: Laravel FormRequests, Resources, capability routes, UUID tenant models, SQL transactions and factories. Tests use the repository's actual PHPUnit runner rather than Pest (the source assertions remain near-verbatim).
- Source WorkWeek's redundant `$parts[0] ?? 0` becomes `$parts[0]` because explode always returns that element; static-analysis cleanup only, no math change.
- Deliberate lean-core difference: copy conflicts reject atomically instead of source skip/overwrite options. Explicit unpublish is added to preserve forward/inverse symmetry. Publication uses fail-closed per-shift draft clearing rather than historical snapshots.
- Deferred: availability, swaps/offers/time-off, claiming, templates/recurrence, keyholder/open/close flags, AI scheduling, break-policy engine, kiosk/geofence clock, statutory holidays, realtime/push.
- Dropped: multi-DB/provider plumbing, WIW/Homebase gates, legacy dual writes and allowlists, Slim and Syncfusion UI.

## Verification

Use the installed PHP 8.5 (`/opt/homebrew/bin/php`). PHP 8.3 cannot boot these installed Composer dependencies (they require >=8.4); do not downgrade vendor to run tests.

```sh
php artisan test tests/Feature/Scheduling/ShiftTest.php tests/Feature/Scheduling/SchedulingSettingsTest.php tests/Unit/Scheduling
```

The concurrent test creates/migrates only its private temporary sqlite database and removes it afterwards. No production/local application database resets are used. Full-suite integration and external Redis/Typesense infrastructure are verified separately by the integration owner. Scheduling changed files also run through Pint and PHPStan level 6 without a suppression baseline.
