-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path005_journals.sql
More file actions
618 lines (557 loc) · 38.4 KB
/
Copy path005_journals.sql
File metadata and controls
618 lines (557 loc) · 38.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
-- 005 仕訳・仕訳明細・伝票番号の採番
--
-- 日付を 1 つに潰さない(docs/10 §2)。取引日・計上日・入力年月日・課税仕入れの時点は
-- それぞれ意味が違い、共用すると必ずどこかで壊れる。とくに入力年月日は
-- 「通常の業務処理期間の経過後の入力の事実を確認できる」という優良な電子帳簿の要件
-- (電帳規則 5 ⑤一イ(2))そのものなので、取引日と絶対に共用しない。
CREATE TABLE journal_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 伝票番号。計上時に採番する。下書きのうちは NULL。
-- 会計年度ごとの連番(欠番を埋め直さず、再利用しない。I-17)。
fiscal_year_id INTEGER NOT NULL REFERENCES fiscal_years(id),
entry_no INTEGER,
transaction_date DATE NOT NULL, -- 取引日。帳簿の「取引年月日」(法定記載事項②)
posting_date DATE NOT NULL, -- 計上日。会計期間への帰属を決める(I-03)
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'posted')),
entry_type TEXT NOT NULL DEFAULT 'normal' CHECK (entry_type IN (
'normal', 'correction', 'reversal',
'opening', 'closing', 'carryover')),
original_entry_id INTEGER REFERENCES journal_entries(id),
description TEXT,
partner_id INTEGER REFERENCES partners(id),
-- 他部品からの投入(docs/10 §10)。手入力は NULL。
source_component TEXT,
source_document_id TEXT,
idempotency_key TEXT UNIQUE, -- 同一の外部伝票を二重に計上しない(I-14)
-- システムが付ける。利用者は変更できない。
entered_at DATETIME NOT NULL,
posted_at DATETIME,
created_at DATETIME,
updated_at DATETIME,
-- 認証部品(app_users)の識別子。**外部キーを張らない。**
-- ユーザーは会計コアの責務ではなく別部品のものなので(ADR-0029)、DB 制約で結ぶと
-- 会計コアが認証部品なしでは立ち上がらなくなる。CLB の予約名として値は自動で入る。
creator INTEGER,
updater INTEGER,
optimistic_locking INTEGER NOT NULL DEFAULT 0,
-- 計上した人(認証部品のユーザー識別子。creator と同じ理由で外部キーを張らない)。
-- 計上時にサーバが書く。下書きは NULL。この列より前に計上された伝票も NULL のまま。
-- 新しい列は末尾(テーブル制約の前)に置く規約(migrations/README。ADD COLUMN と同値になる位置)。
posted_by INTEGER,
-- 伝票の取引先の名前の写し。計上時にサーバが焼く(ADR-0018・ADR-0037)。
-- **明細の写し(journal_lines.partner_name_snapshot)とは役割が違う。**
-- あちらは帳簿の法定記載事項(消税法 30 ⑧)で、こちらは**伝票の画面が計上時の姿を見せる**ためのもの。
-- 計上済みの伝票は不変(I-05)なのに、参照先のマスタを改名すると画面の表示だけが動く——
-- それを止める。**反対仕訳は原仕訳の値を引き継ぐ**(焼き直すと表と裏で名前が変わる)。
-- この列より前に計上された伝票は NULL のまま。取引先の無い伝票も NULL。
partner_name_snapshot TEXT,
-- I-06 訂正・取消は原仕訳を一意に特定する情報を持つ
CHECK (entry_type NOT IN ('correction', 'reversal') OR original_entry_id IS NOT NULL),
-- 自分自身を原仕訳にできない(自分を取り消す伝票は意味を成さない)
CHECK (original_entry_id IS NULL OR original_entry_id <> id),
-- 計上済みには必ず伝票番号と計上日時がある
CHECK (status = 'draft' OR (entry_no IS NOT NULL AND posted_at IS NOT NULL)),
-- 下書きに伝票番号を与えない(番号の先食いを防ぐ)
CHECK (status = 'posted' OR entry_no IS NULL),
-- 伝票番号は整数の連番である(金額と同じ理由で typeof を要求する)
CHECK (entry_no IS NULL OR typeof(entry_no) = 'integer'),
-- I-17 会計年度の中で伝票番号は一意
UNIQUE (fiscal_year_id, entry_no)
);
CREATE INDEX ix_journal_entries_posting_date ON journal_entries (posting_date);
CREATE INDEX ix_journal_entries_transaction_date ON journal_entries (transaction_date);
CREATE INDEX ix_journal_entries_original ON journal_entries (original_entry_id);
-- 1 本の仕訳を取り消す反対仕訳は 1 本まで(I-05 の系)。
-- **二重取消は残高を狂わせる。** 反対仕訳が 2 本残っても、元の取引は 1 回しか無い。
-- アプリ側も計上前に検査するが、同時に 2 人が取り消すと両方が「まだ取り消されていない」を
-- 読んでしまう。最後に止めるのは DB である。
CREATE UNIQUE INDEX ux_journal_entries_single_reversal
ON journal_entries (original_entry_id)
WHERE entry_type = 'reversal' AND status = 'posted';
-- 1 本の仕訳を訂正する再計上も 1 本まで(ADR-0015)。
-- **再計上が 2 本載ると、直した内容がそのまま二重に計上される。**
-- 訂正をやり直したいときは、その訂正の伝票を訂正する(訂正は訂正できる)。
-- 取消と同じく、アプリ側の検査は同時実行に勝てないので最後は DB が止める。
CREATE UNIQUE INDEX ux_journal_entries_single_correction
ON journal_entries (original_entry_id)
WHERE entry_type = 'correction' AND status = 'posted';
CREATE TABLE journal_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journal_entry_id INTEGER NOT NULL REFERENCES journal_entries(id),
line_no INTEGER NOT NULL CHECK (line_no > 0 AND typeof(line_no) = 'integer'),
-- 借方貸方は符号ではなく区分で持ち、金額は常に正(docs/10 §3)。
debit_credit TEXT NOT NULL CHECK (debit_credit IN ('debit', 'credit')),
account_id INTEGER NOT NULL REFERENCES accounts(id),
sub_account_id INTEGER REFERENCES sub_accounts(id),
department_id INTEGER REFERENCES departments(id), -- 損益科目では必須(I-13。判定は科目区分が要るのでアプリ側)
partner_id INTEGER REFERENCES partners(id),
-- 取引先名の写し。帳簿の法定記載事項①(消税法 30 ⑧)であり、
-- 取引先の改名で過去の帳簿の記載が変わらないように FK と両方持つ(docs/10 §4-2)。
partner_name_snapshot TEXT,
-- 税抜・正の整数円。REAL を使わない。
-- **INTEGER と書くだけでは整数にならない。** SQLite の型親和性は 100.5 を整数に落とせず、
-- REAL のまま格納する。typeof で明示的に拒まないと、貸借一致の判定と保存値がずれる(I-01)。
amount INTEGER NOT NULL CHECK (amount > 0 AND typeof(amount) = 'integer'),
-- 税に意味のない行にも「対象外」を明示する。NULL と対象外を 2 通りで表さない(docs/11 §1)。
tax_category_id INTEGER NOT NULL REFERENCES tax_categories(id),
-- 用途区分は明細が持つ。同じ科目でも取引ごとに変わるため。
tax_treatment TEXT CHECK (tax_treatment IN ('for_taxable_sales', 'common', 'for_exempt_sales')),
tax_point DATE, -- 課税仕入れの時点。経過措置・税率の判定基準日
applied_rule_version TEXT, -- 適用した制度ルールの版(I-16)
is_tax_line INTEGER NOT NULL DEFAULT 0 CHECK (is_tax_line IN (0, 1)),
parent_line_no INTEGER, -- 消費税行が対応する本体行
item_description TEXT, -- 資産又は役務の内容(法定記載事項③)
book_only_deduction TEXT, -- 帳簿のみ保存で控除する類型
evidence_ref TEXT, -- 証憑部品への参照キー
-- 計上時点の登録番号の写し(docs/13 §4・ADR-0018)。
-- **帳簿の法定記載事項ではない**(消税法 30 ⑧に相手方の登録番号は含まれない)。
-- 持つ理由は「判定の根拠の記録」——CSV の入出力と監査追跡が当時の値を要求するため。
-- 現在のマスタから引くと、登録を取り消した相手で当時と違う値が出る。
-- 帳簿には印字しない。**この行の「取引先」の番号であって、自社の番号ではない**
-- (適格請求書に載る登録番号は売手=自社のもの。消税法 57 の 4 ①)。
-- 引く日付は tax_point(課税仕入れを行った日)。**tax_point が空なら伝票の取引日で引く**
-- (docs/14 §6。画面が tax_point を入力させないので、無ければ写さないにすると永久に空になる)。
registration_no_snapshot TEXT,
UNIQUE (journal_entry_id, line_no),
-- 消費税行だけが親行を持ち、消費税行は必ず親行を持つ
CHECK (is_tax_line = 1 OR parent_line_no IS NULL),
CHECK (is_tax_line = 0 OR parent_line_no IS NOT NULL)
);
CREATE INDEX ix_journal_lines_entry ON journal_lines (journal_entry_id);
CREATE INDEX ix_journal_lines_account ON journal_lines (account_id);
CREATE INDEX ix_journal_lines_department ON journal_lines (department_id);
CREATE INDEX ix_journal_lines_partner ON journal_lines (partner_id);
-- 伝票番号の採番(I-17)。
-- 会計年度ごとに 1 行。next_entry_no は増やすだけで、戻さない・埋め直さない。
-- 下書きを消しても番号は消費されない(番号は計上時にしか採らないため)。
CREATE TABLE journal_entry_sequences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year_id INTEGER NOT NULL UNIQUE REFERENCES fiscal_years(id),
next_entry_no INTEGER NOT NULL DEFAULT 1 CHECK (next_entry_no >= 1)
);
--------------------------------------------------------------------------------
-- I-05 計上済み仕訳は変更も削除もされない
--
-- AccountingCore の検証とサーバの関門が第一の防波堤だが、CSV 取込・API・スクリプト・
-- 手作業の SQL のどれからでも通る最後の関門として、DB にも置く。
-- 「規則を迂回する経路を作らない」(ADR-0004)を規約ではなく DB に守らせる。
--
-- 下書き → 計上(status が draft から posted へ変わる UPDATE)は通す。
-- 計上済みの行に対する UPDATE / DELETE だけを止める。
--------------------------------------------------------------------------------
-- 計上は「下書きとして書いてから status を進める」経路しか無い。
-- **最初から計上済みとして INSERT する道を塞ぐ。** ここが開いていると、
-- 貸借不一致・明細ゼロの計上済み伝票を直接書き込めてしまい、しかも他のトリガが
-- UPDATE も DELETE も明細の追加も止めるので、**訂正も取消もできない行が恒久的に残る**。
CREATE TRIGGER trg_journal_entries_no_posted_insert
BEFORE INSERT ON journal_entries
FOR EACH ROW WHEN NEW.status = 'posted'
BEGIN
SELECT RAISE(ABORT, '仕訳は下書きとして作る。計上は検証を通してから状態を進める。');
END;
-- 入力年月日は「システムに記録された日時」であり、**下書きの間も含めて後から変えられない**。
-- 「通常の業務処理期間の経過後に入力した事実を確認できる」という優良な電子帳簿の要件
-- (電帳規則 5 ⑤一イ(2))は、この値が動かないことで初めて成り立つ。
CREATE TRIGGER trg_journal_entries_entered_at_immutable
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.entered_at IS NOT OLD.entered_at
BEGIN
SELECT RAISE(ABORT, '入力年月日は変更できない。');
END;
CREATE TRIGGER trg_journal_entries_posted_no_update
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN OLD.status = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳は変更できない。訂正・取消は反対仕訳で行う。');
END;
-- 種別は伝票を作るときに決まるもので、**後から変えてよいものではない**。
--
-- 変えられると、種別ごとの関門(JournalSubmitGate のホワイトリスト)が丸ごと外れる。
-- とくに「訂正」を「通常」に変えると、原仕訳との関係を見る検証を通らないまま計上でき、
-- しかも二重訂正の検出(下の部分ユニークインデックスと HasCorrectionAsync)は
-- entry_type = 'correction' の行しか数えないので、**同じ原仕訳にもう 1 本訂正を計上できる**。
-- 取引が帳簿に 2 回載る(2026-08-25 の自己レビューで発見)。
--
-- 直したいなら下書きを作り直す。下書きは自由に消せる。
CREATE TRIGGER trg_journal_entries_entry_type_immutable
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.entry_type IS NOT OLD.entry_type
BEGIN
SELECT RAISE(ABORT, '仕訳の種別は変更できない。種別を変えるなら下書きを作り直す。');
END;
-- 原仕訳を指してよいのは訂正・取消だけ(I-06 の逆向き)。
--
-- 通常の仕訳が原仕訳を指していると、「訂正のつもりで作った通常の仕訳」が
-- 帳簿の上では原仕訳と無関係の新しい取引として振る舞う。CHECK で書きたいところだが、
-- 既存のテーブルに CHECK を足すには作り直しが要るので、同じ規則をトリガで置く。
CREATE TRIGGER trg_journal_entries_original_only_for_amendment
BEFORE INSERT ON journal_entries
FOR EACH ROW WHEN NEW.original_entry_id IS NOT NULL
AND NEW.entry_type NOT IN ('correction', 'reversal')
BEGIN
SELECT RAISE(ABORT, '原仕訳を指定できるのは訂正・取消だけ。');
END;
CREATE TRIGGER trg_journal_entries_original_only_for_amendment_update
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.original_entry_id IS NOT NULL
AND NEW.entry_type NOT IN ('correction', 'reversal')
BEGIN
SELECT RAISE(ABORT, '原仕訳を指定できるのは訂正・取消だけ。');
END;
CREATE TRIGGER trg_journal_entries_posted_no_delete
BEFORE DELETE ON journal_entries
FOR EACH ROW WHEN OLD.status = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳は削除できない。訂正・取消は反対仕訳で行う。');
END;
CREATE TRIGGER trg_journal_lines_posted_no_update
BEFORE UPDATE ON journal_lines
FOR EACH ROW WHEN (SELECT status FROM journal_entries WHERE id = OLD.journal_entry_id) = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細は変更できない。');
END;
CREATE TRIGGER trg_journal_lines_posted_no_delete
BEFORE DELETE ON journal_lines
FOR EACH ROW WHEN (SELECT status FROM journal_entries WHERE id = OLD.journal_entry_id) = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細は削除できない。');
END;
CREATE TRIGGER trg_journal_lines_posted_no_insert
BEFORE INSERT ON journal_lines
FOR EACH ROW WHEN (SELECT status FROM journal_entries WHERE id = NEW.journal_entry_id) = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳に明細を追加できない。');
END;
-- **明細を計上済みの伝票へ「付け替える」道を塞ぐ。**
--
-- 上の update / delete の 2 本は OLD の伝票(=いま所属している伝票)の状態しか見ないので、
-- **下書きの明細の journal_entry_id を計上済みの伝票の id へ UPDATE する**経路は
-- どれにも当たらず通っていた(2026-09-03 の自己レビューで発見。qa/03 L-25)。
-- 通ると、計上済みの伝票に身に覚えのない行が増える——貸借一致(I-01)も
-- 不変性(I-05)もその瞬間に破れ、しかも訂正も取消もできない。
CREATE TRIGGER trg_journal_lines_no_move_into_posted
BEFORE UPDATE ON journal_lines
FOR EACH ROW WHEN (SELECT status FROM journal_entries WHERE id = NEW.journal_entry_id) = 'posted'
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳へ明細を移動できない。');
END;
--------------------------------------------------------------------------------
-- REPLACE の暗黙の DELETE を塞ぐ
--
-- **SQLite は、REPLACE が制約充足のために消す行の DELETE トリガを、
-- `PRAGMA recursive_triggers` が OFF のあいだ発火しない**(既定は OFF)。
-- そのため `INSERT OR REPLACE INTO journal_entries (id, ..., 'draft', ...)` の 1 文で、
-- **計上済みの伝票が音もなく下書きに戻る**(2026-09-03 に再現。qa/03 L-26)。
-- 明細も同じで、`id` をぶつけると計上済みの明細が 1 行黙って消える(I-01 が同時に破れる)。
--
-- **接続の PRAGMA に頼らない。** ここは「手作業の SQL からでも通る最後の関門」であり、
-- 接続ごとの設定は、まさにその手作業の経路で外れる。**衝突そのものを拒む。**
--------------------------------------------------------------------------------
CREATE TRIGGER trg_journal_entries_no_replace_posted_insert
BEFORE INSERT ON journal_entries
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳を上書きできない。訂正・取消は反対仕訳で行う。')
WHERE EXISTS (SELECT 1 FROM journal_entries e
WHERE e.status = 'posted'
AND (e.id = NEW.id
OR (NEW.idempotency_key IS NOT NULL
AND e.idempotency_key = NEW.idempotency_key)));
END;
CREATE TRIGGER trg_journal_entries_no_replace_posted_update
BEFORE UPDATE ON journal_entries
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳を上書きできない。訂正・取消は反対仕訳で行う。')
WHERE EXISTS (SELECT 1 FROM journal_entries e
WHERE e.status = 'posted' AND e.id <> OLD.id
AND (e.id = NEW.id
OR (NEW.idempotency_key IS NOT NULL
AND e.idempotency_key = NEW.idempotency_key)));
END;
CREATE TRIGGER trg_journal_lines_no_replace_posted_insert
BEFORE INSERT ON journal_lines
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.id = NEW.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_journal_lines_no_replace_posted_update
BEFORE UPDATE ON journal_lines
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.id = NEW.id AND l.id <> OLD.id AND e.status = 'posted');
END;
-- ---------------------------------------------------------------------------
-- 使用中のマスタは意味を変えられない(ADR-0038。トリガが要る理由は同 §4——関門 MasterMeaningGate が本体で、
-- ここは取込・CLI・SQL の直打ちへの最後の守り)。
-- 意味を決める列の現在形は docs/12 §2。関門と同じ列を守っていることは MasterMeaningGateTests が突き合わせる。
-- **意味の凍結は INSERT には張らない**——seed と標本の投入を止めない(ADR-0038 の帰結)。
-- 下の REPLACE 対策の BEFORE INSERT は、計上済みの明細が使っている id を指定した INSERT だけに当たる。
-- **下書きだけが参照している行は変えてよい**——下書きは直せる。違反は計上の関門が拾う(同 §1)。
-- これらを journals の後ろに置くのは、本体が journal_lines / journal_entries を参照するから。
CREATE TRIGGER trg_accounts_meaning_frozen_when_posted
BEFORE UPDATE OF code, category, is_contra, uses_sub_account ON accounts
FOR EACH ROW WHEN NEW.code IS NOT OLD.code
OR NEW.category IS NOT OLD.category
OR NEW.is_contra IS NOT OLD.is_contra
OR NEW.uses_sub_account IS NOT OLD.uses_sub_account
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている勘定科目の意味は変更できない。新しい勘定科目を作る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.account_id = OLD.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_sub_accounts_meaning_frozen_when_posted
BEFORE UPDATE OF code, account_id ON sub_accounts
FOR EACH ROW WHEN NEW.code IS NOT OLD.code
OR NEW.account_id IS NOT OLD.account_id
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている補助科目の意味は変更できない。新しい補助科目を作る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.sub_account_id = OLD.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_departments_meaning_frozen_when_posted
BEFORE UPDATE OF code, is_company_wide ON departments
FOR EACH ROW WHEN NEW.code IS NOT OLD.code
OR NEW.is_company_wide IS NOT OLD.is_company_wide
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている部門の意味は変更できない。新しい部門を作る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.department_id = OLD.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_tax_categories_meaning_frozen_when_posted
BEFORE UPDATE OF code, taxation_type, rate_kind ON tax_categories
FOR EACH ROW WHEN NEW.code IS NOT OLD.code
OR NEW.taxation_type IS NOT OLD.taxation_type
OR NEW.rate_kind IS NOT OLD.rate_kind
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている税区分の意味は変更できない。新しい税区分を作る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.tax_category_id = OLD.id AND e.status = 'posted');
END;
-- REPLACE の経路を塞ぐ(ADR-0038)。仕訳側の no_replace_posted と同じ穴(qa/03 L-26。仕組みはあちらの注記)。
-- 上の 4 本は UPDATE にしか張っていないので、id を指定した INSERT OR REPLACE と UPDATE OR REPLACE ... SET id で
-- 計上済みの明細が参照している id を別の中身の行が乗っ取れた。
-- **id を指定しない INSERT(seed・画面)には当たらない**——BEFORE INSERT の時点の NEW.id は
-- 実在しない値なので、どの明細とも一致しない(SQLite 3.53.1 で実測すると -1。仕様上は未定義なので、
-- 値そのものには依存しない。NULL ではないので NEW.id IS NULL で見分けようとすると静かに外れる)。
CREATE TRIGGER trg_accounts_no_replace_used_insert
BEFORE INSERT ON accounts
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている勘定科目を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.account_id = NEW.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_accounts_no_replace_used_update
BEFORE UPDATE OF id ON accounts
FOR EACH ROW WHEN NEW.id IS NOT OLD.id
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている勘定科目を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.account_id IN (OLD.id, NEW.id) AND e.status = 'posted');
END;
CREATE TRIGGER trg_sub_accounts_no_replace_used_insert
BEFORE INSERT ON sub_accounts
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている補助科目を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.sub_account_id = NEW.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_sub_accounts_no_replace_used_update
BEFORE UPDATE OF id ON sub_accounts
FOR EACH ROW WHEN NEW.id IS NOT OLD.id
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている補助科目を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.sub_account_id IN (OLD.id, NEW.id) AND e.status = 'posted');
END;
CREATE TRIGGER trg_departments_no_replace_used_insert
BEFORE INSERT ON departments
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている部門を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.department_id = NEW.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_departments_no_replace_used_update
BEFORE UPDATE OF id ON departments
FOR EACH ROW WHEN NEW.id IS NOT OLD.id
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている部門を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.department_id IN (OLD.id, NEW.id) AND e.status = 'posted');
END;
CREATE TRIGGER trg_tax_categories_no_replace_used_insert
BEFORE INSERT ON tax_categories
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている税区分を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.tax_category_id = NEW.id AND e.status = 'posted');
END;
CREATE TRIGGER trg_tax_categories_no_replace_used_update
BEFORE UPDATE OF id ON tax_categories
FOR EACH ROW WHEN NEW.id IS NOT OLD.id
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている税区分を上書きできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.tax_category_id IN (OLD.id, NEW.id) AND e.status = 'posted');
END;
-- 摘要のない仕訳を計上させない(docs/10 §4-2-1。法税規則 55 ① の仕訳帳の記載事項「内容」)。
-- **下書き → 計上の UPDATE だけを見る。** OLD.status を見ないと、
-- **規則より前に計上された摘要のない行**を触ったときにもこれが鳴り、
-- 本来出るべき trg_journal_entries_posted_no_update の「計上済みの仕訳は変更できない」を隠す
-- (2026-09-08 の自己レビューで指摘され、インメモリ SQLite で再現した)。
-- status = 'posted' の INSERT は trg_journal_entries_no_posted_insert が拒む。
--
-- **空白だけも空とみなす。** trim の第 2 引数は C# の char.IsWhiteSpace と同じ符号位置で、
-- **関門と同じ広さにしてある**(理由は docs/10 §4-2-1。両方向の同値は JournalDescriptionGuardTests が見る)。
--
-- **新しいトリガはこのファイルの末尾に足す**(理由は Designer/migrations/README.md)。
-- **位置で指す参照を作らない**——足すたびに末尾が変わるので、名前で指すこと。
CREATE TRIGGER trg_journal_entries_description_required_when_posted
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.status = 'posted' AND OLD.status <> 'posted'
AND trim(coalesce(NEW.description, ''),
char(9, 10, 11, 12, 13, 32, 133, 160, 5760,
8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202,
8232, 8233, 8239, 8287, 12288)) = ''
BEGIN
SELECT RAISE(ABORT, '摘要のない仕訳は計上できない。帳簿の記載事項「内容」を欠くため。');
END;
-- 補助科目の 2 値を、計上のときに守る(ADR-0038 §3・docs/15 §1)。
-- **使う科目では補助科目が要り、使わない科目は持てない。**
-- 上の摘要のトリガと同じく**下書き → 計上の UPDATE だけを見る**(status = 'posted' の INSERT は
-- trg_journal_entries_no_posted_insert が拒む)。
--
-- **ここは計上の関門(JournalEntryValidator)より狭い**(docs/10 §4-2-1 の二層の広さ)——
-- 関門は取消をすべて外すが、ここは「計上済みの原仕訳の写し」だけを外す。
-- **アプリからは差が出ない**(取消の明細はサーバが原仕訳から作るので必ず写しになる。JournalReversalPosting)。
-- **狭くしてあるのは、関門を通らない経路のためだけ**である。
-- **外すのは取消の、原仕訳を写しただけの明細だけ**である——取消の明細はサーバが原仕訳を反転して作り、
-- 利用者に直す手立てが無いので、止めると規則より前の伝票を打ち消せなくなる(docs/10 §5・ADR-0004)。
-- **種別だけを見て外さない**——entry_type は取込・CLI・手打ちの SQL が自由に書ける列なので、
-- 「取消だ」と名乗るだけで規則を外せてしまう。**計上済みの原仕訳の同じ行を、貸借だけ入れ替えて写したこと**まで見る
-- (行番号・勘定科目・金額が同じで、貸借が逆。JournalReversal が作る形そのもの)。
-- **科目の一致だけでは足りない**——規則より前の伝票を 1 本指せば、
-- **取引先の無い明細を何行でも・任意の金額で**新しく計上できてしまう(自己レビューで見つけた。2026-09-08)。
-- **「計上済み」まで見るのは、下書きをおとりに立てられるから**である——原仕訳が計上済みであることを
-- 保証する制約は journal_entries に無いので、違反する下書きを 1 件作って指せば外せてしまう。
-- **訂正(再計上)は外さない**——中身は利用者が決めるので、補助科目を空にすれば通る。
--
-- **見るのは補助科目の有無だけである。** 「その補助科目が明細の勘定科目に属しているか」は
-- 計上の関門(E-SUBACCOUNT-MISMATCH)だけが見ており、DB の層は無い(ddl/README の二重防御の表)。
CREATE TRIGGER trg_journal_entries_sub_account_presence_when_posted
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.status = 'posted' AND OLD.status <> 'posted'
BEGIN
SELECT RAISE(ABORT, '補助科目を使う勘定科目の明細には補助科目が要る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN accounts a ON a.id = l.account_id
WHERE l.journal_entry_id = NEW.id
AND a.uses_sub_account = 1 AND l.sub_account_id IS NULL
AND NOT (NEW.entry_type = 'reversal'
AND EXISTS (SELECT 1 FROM journal_lines o
JOIN journal_entries oe ON oe.id = o.journal_entry_id
WHERE o.journal_entry_id = NEW.original_entry_id
AND oe.status = 'posted'
AND o.line_no = l.line_no
AND o.account_id = l.account_id
AND o.amount = l.amount
AND o.debit_credit <> l.debit_credit
AND o.sub_account_id IS l.sub_account_id)));
SELECT RAISE(ABORT, '補助科目を使わない勘定科目の明細に補助科目は付けられない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN accounts a ON a.id = l.account_id
WHERE l.journal_entry_id = NEW.id
AND a.uses_sub_account = 0 AND l.sub_account_id IS NOT NULL
AND NOT (NEW.entry_type = 'reversal'
AND EXISTS (SELECT 1 FROM journal_lines o
JOIN journal_entries oe ON oe.id = o.journal_entry_id
WHERE o.journal_entry_id = NEW.original_entry_id
AND oe.status = 'posted'
AND o.line_no = l.line_no
AND o.account_id = l.account_id
AND o.amount = l.amount
AND o.debit_credit <> l.debit_credit
AND o.sub_account_id IS l.sub_account_id)));
END;
-- 取引先を要する科目の明細を、取引先の無いまま計上させない(docs/15 §1-2)。
-- **相手方を欠いた行は「相手方別」のどの帳簿にも載らない**(電帳規則 5 ① の括弧書き。docs/40 §4-1)。
-- 上の 2 本と同じく**下書き → 計上の UPDATE だけを見る**(status = 'posted' の INSERT は
-- trg_journal_entries_no_posted_insert が拒む)。
--
-- **見るのは実効値である**——明細の取引先が空なら伝票のものが帳簿に載るので、
-- COALESCE(l.partner_id, NEW.partner_id) で判定する(JournalEntry.PartnerOf と同じ規則)。
-- **NEW.partner_id を使う**のは、いま計上しようとしている値が帳簿に載る値だからである。
--
-- **「無い」は NULL だけではない。** 外部キーを切った接続(取込・CLI・手打ちの SQL——**この層が守る相手**)からは
-- 空文字や実在しない識別子が入りうる。**どちらも帳簿の取引先別には出てこない**(元帳の絞り込みは
-- partners との結合で、空値検索は IS NULL しか拾わない)ので、**マスタに実在するか**で見る
-- (自己レビューで見つけた。2026-09-08。金額に typeof を書いてあるのと同じ理由)。
--
-- **免除は補助科目の 2 値とまったく同じ形にしてある**(理由も同じ。上のトリガの注記)——
-- 取消の、**計上済みの原仕訳を写しただけの明細**だけを外す。
-- 規則より前に計上された取引先の無い明細が開発機に実在し(件数と数え方は docs/qa/04)、
-- 外さないとそれらを**永久に取り消せなくなる**(ADR-0004)。
-- **写しの判定も実効値で行う**——原仕訳の側も COALESCE(o.partner_id, oe.partner_id) で見る。
CREATE TRIGGER trg_journal_entries_partner_presence_when_posted
BEFORE UPDATE ON journal_entries
FOR EACH ROW WHEN NEW.status = 'posted' AND OLD.status <> 'posted'
BEGIN
SELECT RAISE(ABORT, '取引先を要する勘定科目の明細には取引先が要る。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN accounts a ON a.id = l.account_id
WHERE l.journal_entry_id = NEW.id
AND a.requires_partner = 1
AND NOT EXISTS (SELECT 1 FROM partners p
WHERE p.id = COALESCE(l.partner_id, NEW.partner_id))
AND NOT (NEW.entry_type = 'reversal'
AND EXISTS (SELECT 1 FROM journal_lines o
JOIN journal_entries oe ON oe.id = o.journal_entry_id
WHERE o.journal_entry_id = NEW.original_entry_id
AND oe.status = 'posted'
AND o.line_no = l.line_no
AND o.account_id = l.account_id
AND o.amount = l.amount
AND o.debit_credit <> l.debit_credit
AND NOT EXISTS (SELECT 1 FROM partners q
WHERE q.id = COALESCE(o.partner_id, oe.partner_id)))));
END;
-- 「取引先を要する」は、使用中の科目では**オフにできない**(docs/15 §1-2)。
-- **オンにするのはいつでも通る**——規則を後から採り入れられなくなってはいけないからである。
-- **オフを止めるのは、止めないと二層の守りをまとめて外せる**からである——
-- 画面でオフにして計上し、また戻せば、関門もこのトリガも素通りする(自己レビューで見つけた。2026-09-08)。
-- **ただし守れるのは「使用中になってから」だけである。** 計上済みの明細が 1 行も無い科目では
-- オフにできるので、**使い始める前なら「オフ → 計上 → オン」で規則より前の行を作れる**
-- (ADR-0038 §1 の「使用中」の線をそのまま使っているため。docs/15 §1-2 に残余として書いてある)。
-- **意味の凍結(trg_accounts_meaning_frozen_when_posted)とは別の規則である。**
-- あちらは過去の記録の意味が変わるから止めるが、こちらは意味を変えない。
CREATE TRIGGER trg_accounts_requires_partner_not_loosened_when_posted
BEFORE UPDATE OF requires_partner ON accounts
FOR EACH ROW WHEN OLD.requires_partner = 1 AND NEW.requires_partner = 0
BEGIN
SELECT RAISE(ABORT, '計上済みの仕訳明細が使っている勘定科目では、「取引先を要する」をオフにできない。')
WHERE EXISTS (SELECT 1 FROM journal_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.account_id = OLD.id AND e.status = 'posted');
END;