日次締め
Document ID: SRS-REG-002 Parent: SRS-ROOT-001 v0.5 Version: 0.2 Status: Implemented (API + Web UI 最小) Last Updated: 2026-05-06 Depends on: SRS-TEN-001 v0.4, SRS-TEN-002 v0.2, SRS-TEN-003 v0.2, SRS-REG-001 v0.2, SRS-RES-004 v0.3, SRS-WRK-001 v0.2 依存される: SRS-ANL-001
本書は SRS-ROOT-001 v0.5 に従う。日次締めは 1 店舗 1 レジを前提とし、暦日 closing_date を採用する。
当日現金残高を確定し、前日以前の売上集計を daily_sales_summary に固定化する。差額は register_closing.diff と cash_movement(category='cash_diff_adjust') に残し、締め直しは void で扱う(物理削除しない)。
assertNotClosedDate(storeId, date) 共通関数で締め後改変を強制ガードする(親 §7.18)。
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”- As a
receptionist, I want to 1 日の現金残高を締めたい - As a
manager, I want to 差額が出たら自動で帳尻行を残したい - As a
owner, I want to 前日分の売上を固定し、当日だけオンザフライ集計したい
3. ユースケース
Section titled “3. ユースケース”3.1 主シナリオ
Section titled “3.1 主シナリオ”- オペレーターが closing_date の expected cash を確認
- counted cash を入力し
POST /api/admin/register/closings - API が advisory lock 取得(
pg_advisory_xact_lock(hashtext('register_closing:'||store_id||':'||closing_date))) - 対象日が未締めであることを確認
- expected cash を二系統で計算し照合(cash_movement 累計 vs visit 集計)
- 差額があれば
cash_movement(category='cash_diff_adjust')を同 tx で挿入 register_closingを INSERT- 翌日付け
cash_movement(category='opening', amount=counted_cash)を同 tx で自動作成
3.2 代替フロー
Section titled “3.2 代替フロー”- 未会計予約あり: warning、
admin:register:close+ 強行フラグで通せる - 誤締め:
POST /api/admin/register/closings/:id/voidでis_voided=true - withdraw / expense: closing 前に手動
cash_movement登録
3.3 例外フロー
Section titled “3.3 例外フロー”- 同一 store/date 二重締め: 409
REGISTER_CLOSING.ALREADY_CLOSED - advisory lock 競合: 409
REGISTER_CLOSING.IN_PROGRESS - 二系統 expected cash 不一致: 500
REGISTER_CLOSING.CASH_RECONCILIATION_FAILED+ Sentry - 未会計予約あり + permission 不足: 422
REGISTER_CLOSING.PENDING_RESERVATIONS_EXIST - void 対象 not found / 既に void: 409
REGISTER_CLOSING.ALREADY_VOIDED
4. UI仕様
Section titled “4. UI仕様”4.1 主要要素
Section titled “4.1 主要要素”- expected cash 表示
- counted cash 入力
- diff 表示
- 未会計予約 warning
- cash movement 一覧
- closing 一覧と void 状態表示
4.2 バリデーション
Section titled “4.2 バリデーション”- counted cash は 0 以上整数
- void は理由必須
5. API仕様
Section titled “5. API仕様”5.1 エンドポイント一覧
Section titled “5.1 エンドポイント一覧”| Method | Path | 用途 | permission |
|---|---|---|---|
| GET | /api/admin/register/closings/:date | 日次締め状況 | admin:register:close |
| POST | /api/admin/register/closings | 締め実行 | admin:register:close |
| POST | /api/admin/register/closings/:id/void | 締め直し | admin:register:close |
| GET | /api/admin/cash-movements?from=&to= | 入出金一覧 | admin:register:operate |
| POST | /api/admin/cash-movements | 手動入出金 | admin:register:operate |
5.2 zodスキーマ
Section titled “5.2 zodスキーマ”POST /register/closings{ closing_date: 'YYYY-MM-DD', counted_cash: bigint, memo?: string, force_close_with_pending?: boolean}
POST /register/closings/:id/void{ reason: string }
POST /cash-movements{ closing_date: 'YYYY-MM-DD', category: 'withdraw' | 'expense', amount: bigint, memo: string}5.3 エラーコード
Section titled “5.3 エラーコード”REGISTER_CLOSING.ALREADY_CLOSEDREGISTER_CLOSING.IN_PROGRESSREGISTER_CLOSING.PENDING_RESERVATIONS_EXISTREGISTER_CLOSING.CASH_RECONCILIATION_FAILEDREGISTER_CLOSING.ALREADY_VOIDEDCASH_MOVEMENT.INVALID_CATEGORY
6. データモデル影響
Section titled “6. データモデル影響”6.1 スキーマ
Section titled “6.1 スキーマ”register_closing
Section titled “register_closing”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | FK |
closing_date | date | NOT NULL |
expected_cash | bigint | NOT NULL |
counted_cash | bigint | NOT NULL |
diff | bigint | NOT NULL |
operator_id | uuid | NOT NULL(TEN-002 後 FK) |
memo | text | NULL |
is_voided | boolean | NOT NULL DEFAULT false |
voided_by | uuid | NULL |
voided_reason | text | NULL |
voided_at | timestamptz | NULL |
closed_at | timestamptz | NOT NULL |
| 制約 | UNIQUE(store_id, id) | |
| 制約 | UNIQUE(store_id, closing_date) WHERE is_voided=false |
cash_movement
Section titled “cash_movement”| カラム | 型 | 制約 |
|---|---|---|
id | bigserial | PK(親 §7.2.2 例外) |
store_id | uuid | FK |
closing_date | date | NOT NULL |
closing_id | uuid | NULL FK |
visit_id | uuid | NULL FK(sales_cash / refund) |
category | varchar(32) | CHECK IN (opening, sales_cash, refund, withdraw, expense, cash_diff_adjust) |
amount | bigint | CHECK > 0 |
memo | text | NULL |
operator_id | uuid | NULL(system カテゴリは NULL) |
occurred_at | timestamptz | NOT NULL |
created_at | timestamptz | NOT NULL |
daily_sales_summary
Section titled “daily_sales_summary”| カラム | 型 | 制約 |
|---|---|---|
store_id | uuid | PK part |
summary_date | date | PK part |
service_sales | bigint | NOT NULL |
goods_sales | bigint | NOT NULL |
option_sales | bigint | NOT NULL |
discount_sales | bigint | NOT NULL |
net_sales | bigint | NOT NULL |
cash_sales | bigint | NOT NULL |
customer_count | integer | NOT NULL |
aggregated_at | timestamptz | NOT NULL |
6.2 マイグレーション計画
Section titled “6.2 マイグレーション計画”0020_visit_register_closing.sql で REG-001 と統合:
- 上記 3 表を追加
- RLS / GRANT / index / category CHECK
assertNotClosedDate関数(または application 層共通関数として実装)- permission backfill (
admin:register:close)
7. 業務ルール
Section titled “7. 業務ルール”7.1 closing_date
Section titled “7.1 closing_date”closing_date は paid_at AT TIME ZONE store.timezone で求める暦日。Phase 1 は Asia/Tokyo 固定。
7.2 期待値計算
Section titled “7.2 期待値計算”二系統で計算し一致確認:
- movement 系:
opening + sales_cash - refund - withdraw - expense + cash_diff_adjust - visit 系: 当日 cash 支払 visit_payment 合計 + 当日以前 carry opening - refund - withdraw - expense
不一致は 5xx + Sentry。
7.3 差額処理
Section titled “7.3 差額処理”diff = counted_cash - expected_cash。diff != 0 なら cash_movement(category='cash_diff_adjust', amount=abs(diff)) を自動挿入、符号は register_closing.diff 列で表現。amount は正数固定。
7.4 締め race 制御
Section titled “7.4 締め race 制御”pg_advisory_xact_lock(hashtext('register_closing:'||store_id||':'||closing_date)) を取得。
7.5 開店残高の自動連携
Section titled “7.5 開店残高の自動連携”締め成功時、同一 tx で翌日付け cash_movement(category='opening', amount=counted_cash) を自動挿入。翌日 0:00 cron は使わない。cash_movement.closing_date 列で「翌日付け」を明示。
7.6 未会計予約
Section titled “7.6 未会計予約”対象日に reservations.status != 'paid' がある場合 warning。force_close_with_pending=true で押し通せる(owner / manager のみ)。
7.7 締め後改変禁止(親 §7.18)
Section titled “7.7 締め後改変禁止(親 §7.18)”assertNotClosedDate(storeId, date) 共通関数:
async function assertNotClosedDate(storeId: string, date: string, db: Tx): Promise<void> { const exists = await db.execute( `SELECT 1 FROM register_closing WHERE store_id = $1 AND closing_date = $2 AND is_voided = false`, [storeId, date] ); if (exists.rows.length > 0) { throw new ApiError(409, 'VISIT.CLOSED_PERIOD_LOCKED', '締め済みの日付の取引は変更できません'); }}全 visit / cash_movement mutation の handler で必ず先頭で呼ぶ。Phase 1 はアプリ層強制、Phase 2 で DB trigger 化を検討(OQ-17)。
7.8 締め直し
Section titled “7.8 締め直し”is_voided=true, voided_by, voided_reason, voided_at を埋める。物理削除なし。void 後のみ対象日の会計再修正と再締めを許可。
8. 非機能要件
Section titled “8. 非機能要件”- closing API p95 1s
- advisory lock 取得待ち 3 秒で打ち切り 409
- 失敗時 Sentry に
storeId,closingDateを付与
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”| key | 用途 | 初期付与 |
|---|---|---|
admin:register:close | 締め・締め直し | owner / manager |
RLS は register_closing, cash_movement, daily_sales_summary に適用。
10. 受け入れ基準(Given-When-Then)
Section titled “10. 受け入れ基準(Given-When-Then)”- GWT-1 通常締め: expected=50,000, counted=50,000 → diff=0 で成功
- GWT-2 差額発生: expected=50,000, counted=49,000 →
diff=-1000、cash_diff_adjust1 行 - GWT-3 同日二重締め禁止: 409
REGISTER_CLOSING.ALREADY_CLOSED - GWT-4 advisory lock: 2 人同時 close → 片方
IN_PROGRESS - GWT-5 未会計予約 warning: 422
PENDING_RESERVATIONS_EXIST(permission 不足時) - GWT-6 強行締め:
force_close_with_pending=true+ 権限あり → 成功、監査記録 - GWT-7 翌日 opening: 締め成功 → 翌日付け
category='opening'movement 1 行 - GWT-8 category 制約:
category='deposit'→ 422 / DB CHECK 違反 - GWT-9 TZ 境界:
paid_at='2026-05-05T16:00:00Z'(JST 2026-05-06 01:00) →closing_date='2026-05-06' - GWT-10 二系統不一致: 5xx + Sentry
- GWT-11 void 締め:
is_voided=true、物理削除なし - GWT-12 void 二重: 409
- GWT-13 withdraw 反映: expected から差し引かれる
- GWT-14 summary 参照境界: 前日以前は
daily_sales_summary、当日はvisit*直接参照 - GWT-15
assertNotClosedDate通過: visit PATCH がregister_closing(is_voided=false)存在で 409
11. テスト計画
Section titled “11. テスト計画”- integration: closing 正常 / diff / 自動 opening / void
- db-level: category CHECK / UNIQUE / RLS
- time-zone: UTC/JST 境界
- worker integration:
aggregate-daily-sales-for-storeがdaily_sales_summaryを作る - failure injection: 集計不一致で 5xx
12. 関連ジョブ
Section titled “12. 関連ジョブ”aggregate-daily-sales(system scheduler、UTC 18:00 daily)aggregate-daily-sales-for-store(store fan-out)
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 扱い |
|---|---|---|
| OQ-REG-002-01 | 営業日ベース締め (cutoff hour) | Phase 2 |
| OQ-REG-002-02 | 締め後改変 DB trigger 化 | Phase 2 OQ-17 |
| OQ-REG-002-03 | 複数レジ (register_id) | Phase 3 |
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-05-05 | Codex / yudai | 初版 |
| 0.2 | 2026-05-05 | yudai (with Codex co-design) | Round 2 反映: migration 番号 0020、assertNotClosedDate 共通関数明記、advisory lock 規律、is_voided 方式の void、cash_movement の closing_date 列、permission 1 個に集約 |