@k2works/claude-code-booster 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/lib/assets/docs/article/index.md +4 -1
  2. package/lib/assets/docs/article/practical-database-design/index.md +121 -0
  3. package/lib/assets/docs/article/practical-database-design/part1/chapter01.md +288 -0
  4. package/lib/assets/docs/article/practical-database-design/part1/chapter02.md +518 -0
  5. package/lib/assets/docs/article/practical-database-design/part1/chapter03.md +557 -0
  6. package/lib/assets/docs/article/practical-database-design/part2/chapter04.md +924 -0
  7. package/lib/assets/docs/article/practical-database-design/part2/chapter05.md +1627 -0
  8. package/lib/assets/docs/article/practical-database-design/part2/chapter06.md +2716 -0
  9. package/lib/assets/docs/article/practical-database-design/part2/chapter07.md +2082 -0
  10. package/lib/assets/docs/article/practical-database-design/part2/chapter08.md +2105 -0
  11. package/lib/assets/docs/article/practical-database-design/part2/chapter09.md +2031 -0
  12. package/lib/assets/docs/article/practical-database-design/part2/chapter10.md +1387 -0
  13. package/lib/assets/docs/article/practical-database-design/part2/chapter11.md +1677 -0
  14. package/lib/assets/docs/article/practical-database-design/part2/chapter12.md +1417 -0
  15. package/lib/assets/docs/article/practical-database-design/part2/chapter13.md +1434 -0
  16. package/lib/assets/docs/article/practical-database-design/part3/chapter14.md +667 -0
  17. package/lib/assets/docs/article/practical-database-design/part3/chapter15.md +1625 -0
  18. package/lib/assets/docs/article/practical-database-design/part3/chapter16.md +1915 -0
  19. package/lib/assets/docs/article/practical-database-design/part3/chapter17.md +1708 -0
  20. package/lib/assets/docs/article/practical-database-design/part3/chapter18.md +2095 -0
  21. package/lib/assets/docs/article/practical-database-design/part3/chapter19.md +1123 -0
  22. package/lib/assets/docs/article/practical-database-design/part3/chapter20.md +1031 -0
  23. package/lib/assets/docs/article/practical-database-design/part3/chapter21.md +1382 -0
  24. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter14-orm.md +991 -0
  25. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter15-orm.md +1300 -0
  26. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter16-orm.md +1166 -0
  27. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter17-orm.md +1584 -0
  28. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter18-orm.md +1183 -0
  29. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter19-orm.md +1016 -0
  30. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter20-orm.md +1753 -0
  31. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter21-orm.md +1447 -0
  32. package/lib/assets/docs/article/practical-database-design/part3-orm/chapter22-orm.md +1878 -0
  33. package/lib/assets/docs/article/practical-database-design/part4/chapter22.md +965 -0
  34. package/lib/assets/docs/article/practical-database-design/part4/chapter23.md +2069 -0
  35. package/lib/assets/docs/article/practical-database-design/part4/chapter24.md +2439 -0
  36. package/lib/assets/docs/article/practical-database-design/part4/chapter25.md +3661 -0
  37. package/lib/assets/docs/article/practical-database-design/part4/chapter26.md +2916 -0
  38. package/lib/assets/docs/article/practical-database-design/part4/chapter27.md +3105 -0
  39. package/lib/assets/docs/article/practical-database-design/part4/chapter28.md +2697 -0
  40. package/lib/assets/docs/article/practical-database-design/part4/chapter29.md +2544 -0
  41. package/lib/assets/docs/article/practical-database-design/part4/chapter30.md +2180 -0
  42. package/lib/assets/docs/article/practical-database-design/part4/chapter31.md +1192 -0
  43. package/lib/assets/docs/article/practical-database-design/part4/chapter32.md +2101 -0
  44. package/lib/assets/docs/article/practical-database-design/part5/chapter33.md +1032 -0
  45. package/lib/assets/docs/article/practical-database-design/part5/chapter34.md +1609 -0
  46. package/lib/assets/docs/article/practical-database-design/part5/chapter35.md +1453 -0
  47. package/lib/assets/docs/article/practical-database-design/part5/chapter36.md +1292 -0
  48. package/lib/assets/docs/article/practical-database-design/part5/chapter37.md +1470 -0
  49. package/lib/assets/docs/article/practical-database-design/part5/chapter38.md +1698 -0
  50. package/lib/assets/docs/article/practical-database-design/part5/chapter39.md +2334 -0
  51. package/lib/assets/docs/article/practical-database-design/study/study2-1.md +1693 -0
  52. package/lib/assets/docs/article/practical-database-design/study/study2-2.md +1347 -0
  53. package/lib/assets/docs/article/practical-database-design/study/study2-3.md +2044 -0
  54. package/lib/assets/docs/article/practical-database-design/study/study2-4.md +2229 -0
  55. package/lib/assets/docs/article/practical-database-design/study/study2-5.md +2418 -0
  56. package/lib/assets/docs/article/practical-database-design/study/study3-1.md +2205 -0
  57. package/lib/assets/docs/article/practical-database-design/study/study3-2.md +2221 -0
  58. package/lib/assets/docs/article/practical-database-design/study/study3-3.md +2253 -0
  59. package/lib/assets/docs/article/practical-database-design/study/study3-4.md +2106 -0
  60. package/lib/assets/docs/article/practical-database-design/study/study3-5.md +2507 -0
  61. package/lib/assets/docs/article/practical-database-design/study/study4-1.md +2587 -0
  62. package/lib/assets/docs/article/practical-database-design/study/study4-2.md +2075 -0
  63. package/lib/assets/docs/article/practical-database-design/study/study4-3.md +1805 -0
  64. package/lib/assets/docs/article/practical-database-design/study/study4-4.md +1895 -0
  65. package/lib/assets/docs/article/practical-database-design/study/study4-5.md +2878 -0
  66. package/package.json +1 -1
@@ -0,0 +1,2716 @@
1
+ # 第6章:受注・出荷・売上の設計
2
+
3
+ 販売管理システムの中核となるトランザクションデータを設計していきます。本章では、受注から出荷、売上計上までの一連の業務フローに対応したデータベース設計と実装を行います。
4
+
5
+ ## 販売業務の全体フロー
6
+
7
+ 販売業務は「見積 → 受注 → 出荷 → 売上」という一連のフローで構成されます。
8
+
9
+ ```plantuml
10
+ @startuml
11
+
12
+ title 販売業務フロー
13
+
14
+ |営業部門|
15
+ start
16
+ :見積作成;
17
+ note right
18
+ 顧客からの問い合わせに
19
+ 対して見積書を作成
20
+ end note
21
+
22
+ :見積提出;
23
+
24
+ if (受注確定?) then (yes)
25
+ :受注登録;
26
+ else (no)
27
+ :失注処理;
28
+ stop
29
+ endif
30
+
31
+ |倉庫部門|
32
+ :出荷指示;
33
+ note right
34
+ 受注データを元に
35
+ 出荷指示を作成
36
+ end note
37
+
38
+ :ピッキング;
39
+ :検品・梱包;
40
+ :出荷;
41
+
42
+ |営業部門|
43
+ :売上計上;
44
+ note right
45
+ 出荷完了時点で
46
+ 売上を計上
47
+ end note
48
+
49
+ :納品書発行;
50
+ stop
51
+
52
+ @enduml
53
+ ```
54
+
55
+ ### トランザクションデータの特徴
56
+
57
+ トランザクションデータには以下の共通した特徴があります。
58
+
59
+ | 特徴 | 説明 |
60
+ |-----|------|
61
+ | **ヘッダ・明細構造** | 1つの取引に複数の商品が含まれるため、ヘッダ(親)と明細(子)の構造を持つ |
62
+ | **ステータス管理** | 業務の進捗状態を管理するステータスを持つ |
63
+ | **連番管理** | 伝票番号などの連番を自動採番する |
64
+ | **日時管理** | 作成日時、更新日時など監査証跡を残す |
65
+ | **マスタ参照** | 顧客・商品・社員などのマスタを参照する |
66
+
67
+ ---
68
+
69
+ ## 6.1 受注業務の DB 設計
70
+
71
+ ### 受注業務フローの理解
72
+
73
+ 受注業務は、顧客からの注文を受け付け、システムに登録する業務です。
74
+
75
+ ```plantuml
76
+ @startuml
77
+
78
+ title 受注業務フロー
79
+
80
+ |営業担当|
81
+ start
82
+ :見積書を確認;
83
+
84
+ if (見積あり?) then (yes)
85
+ :見積から受注へ変換;
86
+ else (no)
87
+ :新規受注入力;
88
+ endif
89
+
90
+ :受注データ登録;
91
+ note right
92
+ 顧客情報
93
+ 商品・数量・単価
94
+ 納期
95
+ 配送先
96
+ end note
97
+
98
+ :受注明細登録;
99
+
100
+ :受注確認書発行;
101
+
102
+ |倉庫担当|
103
+ :在庫引当;
104
+ note right
105
+ 受注数量分の
106
+ 在庫を確保
107
+ end note
108
+
109
+ if (在庫あり?) then (yes)
110
+ :引当完了;
111
+ else (no)
112
+ :入荷待ち;
113
+ endif
114
+
115
+ stop
116
+
117
+ @enduml
118
+ ```
119
+
120
+ ### 受注データ・受注明細データの構造
121
+
122
+ #### 受注データの ER 図
123
+
124
+ ```plantuml
125
+ @startuml
126
+
127
+ title 受注関連テーブル
128
+
129
+ entity 見積データ {
130
+ ID <<PK>>
131
+ --
132
+ 見積番号 <<UK>>
133
+ 見積日
134
+ 見積有効期限
135
+ 顧客コード <<FK>>
136
+ 顧客枝番 <<FK>>
137
+ 担当者コード
138
+ 件名
139
+ 見積金額
140
+ 消費税額
141
+ 見積合計
142
+ ステータス
143
+ 備考
144
+ 作成日時
145
+ 更新日時
146
+ }
147
+
148
+ entity 見積明細 {
149
+ ID <<PK>>
150
+ --
151
+ 見積ID <<FK>>
152
+ 行番号
153
+ 商品コード <<FK>>
154
+ 商品名
155
+ 数量
156
+ 単位
157
+ 単価
158
+ 金額
159
+ 税区分
160
+ 消費税率
161
+ 消費税額
162
+ 備考
163
+ }
164
+
165
+ entity 受注データ {
166
+ ID <<PK>>
167
+ --
168
+ 受注番号 <<UK>>
169
+ 受注日
170
+ 顧客コード <<FK>>
171
+ 顧客枝番 <<FK>>
172
+ 出荷先番号 <<FK>>
173
+ 担当者コード
174
+ 希望納期
175
+ 出荷予定日
176
+ 受注金額
177
+ 消費税額
178
+ 受注合計
179
+ ステータス
180
+ 見積ID <<FK>>
181
+ 顧客注文番号
182
+ 備考
183
+ 作成日時
184
+ 更新日時
185
+ }
186
+
187
+ entity 受注明細 {
188
+ ID <<PK>>
189
+ --
190
+ 受注ID <<FK>>
191
+ 行番号
192
+ 商品コード <<FK>>
193
+ 商品名
194
+ 受注数量
195
+ 引当数量
196
+ 出荷数量
197
+ 残数量
198
+ 単位
199
+ 単価
200
+ 金額
201
+ 税区分
202
+ 消費税率
203
+ 消費税額
204
+ 倉庫コード
205
+ 希望納期
206
+ 備考
207
+ }
208
+
209
+ 見積データ ||--o{ 見積明細
210
+ 見積データ ||--o| 受注データ : "受注化"
211
+ 受注データ ||--o{ 受注明細
212
+
213
+ @enduml
214
+ ```
215
+
216
+ ### 受注ステータスの定義
217
+
218
+ ```plantuml
219
+ @startuml
220
+
221
+ title 受注ステータス遷移図
222
+
223
+ [*] --> 受付済
224
+
225
+ 受付済 --> 引当済 : 在庫引当完了
226
+ 受付済 --> 出荷指示済 : 出荷指示作成
227
+
228
+ 引当済 --> 出荷指示済 : 出荷指示作成
229
+
230
+ 出荷指示済 --> 出荷済 : 出荷完了
231
+
232
+ 出荷済 --> [*]
233
+
234
+ 受付済 --> キャンセル
235
+ 引当済 --> キャンセル
236
+ 出荷指示済 --> キャンセル
237
+
238
+ キャンセル --> [*]
239
+
240
+ @enduml
241
+ ```
242
+
243
+ | ステータス | 説明 |
244
+ |-----------|------|
245
+ | **受付済** | 受注登録が完了した状態 |
246
+ | **引当済** | 在庫引当が完了した状態 |
247
+ | **出荷指示済** | 出荷指示が作成された状態 |
248
+ | **出荷済** | 出荷が完了した状態 |
249
+ | **キャンセル** | 受注がキャンセルされた状態 |
250
+
251
+ ### マイグレーション:受注関連テーブルの作成
252
+
253
+ <details>
254
+ <summary>V007__create_quotation_order_tables.sql</summary>
255
+
256
+ ```sql
257
+ -- src/main/resources/db/migration/V007__create_quotation_order_tables.sql
258
+
259
+ -- 見積ステータス
260
+ CREATE TYPE 見積ステータス AS ENUM ('商談中', '受注確定', '失注', '期限切れ');
261
+
262
+ -- 受注ステータス
263
+ CREATE TYPE 受注ステータス AS ENUM ('受付済', '引当済', '出荷指示済', '出荷済', 'キャンセル');
264
+
265
+ -- 見積データ(ヘッダ)
266
+ CREATE TABLE "見積データ" (
267
+ "ID" SERIAL PRIMARY KEY,
268
+ "見積番号" VARCHAR(20) UNIQUE NOT NULL,
269
+ "見積日" DATE NOT NULL,
270
+ "見積有効期限" DATE,
271
+ "顧客コード" VARCHAR(20) NOT NULL,
272
+ "顧客枝番" VARCHAR(10) DEFAULT '00',
273
+ "担当者コード" VARCHAR(20),
274
+ "件名" VARCHAR(200),
275
+ "見積金額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
276
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
277
+ "見積合計" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
278
+ "ステータス" 見積ステータス DEFAULT '商談中' NOT NULL,
279
+ "備考" TEXT,
280
+ "作成日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
281
+ "作成者" VARCHAR(50),
282
+ "更新日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
283
+ "更新者" VARCHAR(50),
284
+ CONSTRAINT "fk_見積データ_顧客"
285
+ FOREIGN KEY ("顧客コード", "顧客枝番") REFERENCES "顧客マスタ"("顧客コード", "顧客枝番")
286
+ );
287
+
288
+ -- 見積明細
289
+ CREATE TABLE "見積明細" (
290
+ "ID" SERIAL PRIMARY KEY,
291
+ "見積ID" INTEGER NOT NULL,
292
+ "行番号" INTEGER NOT NULL,
293
+ "商品コード" VARCHAR(20) NOT NULL,
294
+ "商品名" VARCHAR(100) NOT NULL,
295
+ "数量" DECIMAL(15, 2) NOT NULL,
296
+ "単位" VARCHAR(10),
297
+ "単価" DECIMAL(15, 2) NOT NULL,
298
+ "金額" DECIMAL(15, 2) NOT NULL,
299
+ "税区分" 税区分 DEFAULT '外税' NOT NULL,
300
+ "消費税率" DECIMAL(5, 2) DEFAULT 10.00 NOT NULL,
301
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
302
+ "備考" TEXT,
303
+ CONSTRAINT "fk_見積明細_見積"
304
+ FOREIGN KEY ("見積ID") REFERENCES "見積データ"("ID") ON DELETE CASCADE,
305
+ CONSTRAINT "fk_見積明細_商品"
306
+ FOREIGN KEY ("商品コード") REFERENCES "商品マスタ"("商品コード"),
307
+ CONSTRAINT "uq_見積明細_行番号" UNIQUE ("見積ID", "行番号")
308
+ );
309
+
310
+ -- 受注データ(ヘッダ)
311
+ CREATE TABLE "受注データ" (
312
+ "ID" SERIAL PRIMARY KEY,
313
+ "受注番号" VARCHAR(20) UNIQUE NOT NULL,
314
+ "受注日" DATE NOT NULL,
315
+ "顧客コード" VARCHAR(20) NOT NULL,
316
+ "顧客枝番" VARCHAR(10) DEFAULT '00',
317
+ "出荷先番号" VARCHAR(10),
318
+ "担当者コード" VARCHAR(20),
319
+ "希望納期" DATE,
320
+ "出荷予定日" DATE,
321
+ "受注金額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
322
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
323
+ "受注合計" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
324
+ "ステータス" 受注ステータス DEFAULT '受付済' NOT NULL,
325
+ "見積ID" INTEGER,
326
+ "顧客注文番号" VARCHAR(50),
327
+ "備考" TEXT,
328
+ "作成日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
329
+ "作成者" VARCHAR(50),
330
+ "更新日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
331
+ "更新者" VARCHAR(50),
332
+ CONSTRAINT "fk_受注データ_顧客"
333
+ FOREIGN KEY ("顧客コード", "顧客枝番") REFERENCES "顧客マスタ"("顧客コード", "顧客枝番"),
334
+ CONSTRAINT "fk_受注データ_出荷先"
335
+ FOREIGN KEY ("顧客コード", "顧客枝番", "出荷先番号") REFERENCES "出荷先マスタ"("取引先コード", "顧客枝番", "出荷先番号"),
336
+ CONSTRAINT "fk_受注データ_見積"
337
+ FOREIGN KEY ("見積ID") REFERENCES "見積データ"("ID")
338
+ );
339
+
340
+ -- 受注明細
341
+ CREATE TABLE "受注明細" (
342
+ "ID" SERIAL PRIMARY KEY,
343
+ "受注ID" INTEGER NOT NULL,
344
+ "行番号" INTEGER NOT NULL,
345
+ "商品コード" VARCHAR(20) NOT NULL,
346
+ "商品名" VARCHAR(100) NOT NULL,
347
+ "受注数量" DECIMAL(15, 2) NOT NULL,
348
+ "引当数量" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
349
+ "出荷数量" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
350
+ "残数量" DECIMAL(15, 2) NOT NULL,
351
+ "単位" VARCHAR(10),
352
+ "単価" DECIMAL(15, 2) NOT NULL,
353
+ "金額" DECIMAL(15, 2) NOT NULL,
354
+ "税区分" 税区分 DEFAULT '外税' NOT NULL,
355
+ "消費税率" DECIMAL(5, 2) DEFAULT 10.00 NOT NULL,
356
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
357
+ "倉庫コード" VARCHAR(20),
358
+ "希望納期" DATE,
359
+ "備考" TEXT,
360
+ CONSTRAINT "fk_受注明細_受注"
361
+ FOREIGN KEY ("受注ID") REFERENCES "受注データ"("ID") ON DELETE CASCADE,
362
+ CONSTRAINT "fk_受注明細_商品"
363
+ FOREIGN KEY ("商品コード") REFERENCES "商品マスタ"("商品コード"),
364
+ CONSTRAINT "uq_受注明細_行番号" UNIQUE ("受注ID", "行番号")
365
+ );
366
+
367
+ -- インデックス
368
+ CREATE INDEX "idx_見積データ_顧客コード" ON "見積データ"("顧客コード");
369
+ CREATE INDEX "idx_見積データ_見積日" ON "見積データ"("見積日");
370
+ CREATE INDEX "idx_見積データ_ステータス" ON "見積データ"("ステータス");
371
+ CREATE INDEX "idx_受注データ_顧客コード" ON "受注データ"("顧客コード");
372
+ CREATE INDEX "idx_受注データ_受注日" ON "受注データ"("受注日");
373
+ CREATE INDEX "idx_受注データ_ステータス" ON "受注データ"("ステータス");
374
+ CREATE INDEX "idx_受注データ_希望納期" ON "受注データ"("希望納期");
375
+ ```
376
+
377
+ </details>
378
+
379
+ ### 受注ステータス Enum
380
+
381
+ <details>
382
+ <summary>OrderStatus.java</summary>
383
+
384
+ ```java
385
+ // src/main/java/com/example/sms/domain/model/sales/OrderStatus.java
386
+ package com.example.sms.domain.model.sales;
387
+
388
+ import lombok.Getter;
389
+ import lombok.RequiredArgsConstructor;
390
+
391
+ @Getter
392
+ @RequiredArgsConstructor
393
+ public enum OrderStatus {
394
+ RECEIVED("受付済"),
395
+ ALLOCATED("引当済"),
396
+ SHIPMENT_INSTRUCTED("出荷指示済"),
397
+ SHIPPED("出荷済"),
398
+ CANCELLED("キャンセル");
399
+
400
+ private final String displayName;
401
+
402
+ public static OrderStatus fromDisplayName(String displayName) {
403
+ for (OrderStatus status : values()) {
404
+ if (status.displayName.equals(displayName)) {
405
+ return status;
406
+ }
407
+ }
408
+ throw new IllegalArgumentException("Unknown order status: " + displayName);
409
+ }
410
+ }
411
+ ```
412
+
413
+ </details>
414
+
415
+ ### 受注エンティティ
416
+
417
+ <details>
418
+ <summary>SalesOrder.java</summary>
419
+
420
+ ```java
421
+ // src/main/java/com/example/sms/domain/model/sales/SalesOrder.java
422
+ package com.example.sms.domain.model.sales;
423
+
424
+ import lombok.AllArgsConstructor;
425
+ import lombok.Builder;
426
+ import lombok.Data;
427
+ import lombok.NoArgsConstructor;
428
+
429
+ import java.math.BigDecimal;
430
+ import java.time.LocalDate;
431
+ import java.time.LocalDateTime;
432
+ import java.util.ArrayList;
433
+ import java.util.List;
434
+
435
+ @Data
436
+ @Builder
437
+ @NoArgsConstructor
438
+ @AllArgsConstructor
439
+ @SuppressWarnings("PMD.RedundantFieldInitializer")
440
+ public class SalesOrder {
441
+ private Integer id;
442
+ private String orderNumber;
443
+ private LocalDate orderDate;
444
+ private String customerCode;
445
+ private String customerBranchNumber;
446
+ private String shippingDestinationNumber;
447
+ private String representativeCode;
448
+ private LocalDate requestedDeliveryDate;
449
+ private LocalDate scheduledShippingDate;
450
+ @Builder.Default
451
+ private BigDecimal orderAmount = BigDecimal.ZERO;
452
+ @Builder.Default
453
+ private BigDecimal taxAmount = BigDecimal.ZERO;
454
+ @Builder.Default
455
+ private BigDecimal totalAmount = BigDecimal.ZERO;
456
+ @Builder.Default
457
+ private OrderStatus status = OrderStatus.RECEIVED;
458
+ private Integer quotationId;
459
+ private String customerOrderNumber;
460
+ private String remarks;
461
+ private LocalDateTime createdAt;
462
+ private String createdBy;
463
+ private LocalDateTime updatedAt;
464
+ private String updatedBy;
465
+
466
+ @Builder.Default
467
+ private List<SalesOrderDetail> details = new ArrayList<>();
468
+ }
469
+ ```
470
+
471
+ </details>
472
+
473
+ <details>
474
+ <summary>SalesOrderDetail.java</summary>
475
+
476
+ ```java
477
+ // src/main/java/com/example/sms/domain/model/sales/SalesOrderDetail.java
478
+ package com.example.sms.domain.model.sales;
479
+
480
+ import com.example.sms.domain.model.product.TaxCategory;
481
+ import lombok.AllArgsConstructor;
482
+ import lombok.Builder;
483
+ import lombok.Data;
484
+ import lombok.NoArgsConstructor;
485
+
486
+ import java.math.BigDecimal;
487
+ import java.time.LocalDate;
488
+
489
+ @Data
490
+ @Builder
491
+ @NoArgsConstructor
492
+ @AllArgsConstructor
493
+ public class SalesOrderDetail {
494
+ private Integer id;
495
+ private Integer orderId;
496
+ private Integer lineNumber;
497
+ private String productCode;
498
+ private String productName;
499
+ private BigDecimal orderQuantity;
500
+ private BigDecimal allocatedQuantity;
501
+ private BigDecimal shippedQuantity;
502
+ private BigDecimal remainingQuantity;
503
+ private String unit;
504
+ private BigDecimal unitPrice;
505
+ private BigDecimal amount;
506
+ private TaxCategory taxCategory;
507
+ private BigDecimal taxRate;
508
+ private BigDecimal taxAmount;
509
+ private String warehouseCode;
510
+ private LocalDate requestedDeliveryDate;
511
+ private String remarks;
512
+ }
513
+ ```
514
+
515
+ </details>
516
+
517
+ ### TypeHandler の実装
518
+
519
+ 日本語の ENUM 値を Java の Enum に変換するための TypeHandler を実装します。
520
+
521
+ <details>
522
+ <summary>OrderStatusTypeHandler.java</summary>
523
+
524
+ ```java
525
+ // src/main/java/com/example/sms/infrastructure/out/persistence/typehandler/OrderStatusTypeHandler.java
526
+ package com.example.sms.infrastructure.out.persistence.typehandler;
527
+
528
+ import com.example.sms.domain.model.sales.OrderStatus;
529
+ import org.apache.ibatis.type.BaseTypeHandler;
530
+ import org.apache.ibatis.type.JdbcType;
531
+ import org.apache.ibatis.type.MappedTypes;
532
+
533
+ import java.sql.*;
534
+
535
+ @MappedTypes(OrderStatus.class)
536
+ public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {
537
+
538
+ @Override
539
+ public void setNonNullParameter(PreparedStatement ps, int i,
540
+ OrderStatus parameter, JdbcType jdbcType) throws SQLException {
541
+ ps.setObject(i, parameter.getDisplayName(), Types.OTHER);
542
+ }
543
+
544
+ @Override
545
+ public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
546
+ String value = rs.getString(columnName);
547
+ return value == null ? null : OrderStatus.fromDisplayName(value);
548
+ }
549
+
550
+ @Override
551
+ public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
552
+ String value = rs.getString(columnIndex);
553
+ return value == null ? null : OrderStatus.fromDisplayName(value);
554
+ }
555
+
556
+ @Override
557
+ public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
558
+ String value = cs.getString(columnIndex);
559
+ return value == null ? null : OrderStatus.fromDisplayName(value);
560
+ }
561
+ }
562
+ ```
563
+
564
+ </details>
565
+
566
+ ### TDD:受注の登録と取得
567
+
568
+ #### Red: 失敗するテストを書く
569
+
570
+ <details>
571
+ <summary>SalesOrderRepositoryTest.java</summary>
572
+
573
+ ```java
574
+ // src/test/java/com/example/sms/infrastructure/persistence/repository/SalesOrderRepositoryTest.java
575
+ package com.example.sms.infrastructure.persistence.repository;
576
+
577
+ import com.example.sms.application.port.out.SalesOrderRepository;
578
+ import com.example.sms.domain.model.product.TaxCategory;
579
+ import com.example.sms.domain.model.sales.*;
580
+ import com.example.sms.testsetup.BaseIntegrationTest;
581
+ import org.junit.jupiter.api.*;
582
+ import org.springframework.beans.factory.annotation.Autowired;
583
+
584
+ import java.math.BigDecimal;
585
+ import java.time.LocalDate;
586
+
587
+ import static org.assertj.core.api.Assertions.*;
588
+
589
+ @DisplayName("受注リポジトリ")
590
+ class SalesOrderRepositoryTest extends BaseIntegrationTest {
591
+
592
+ @Autowired
593
+ private SalesOrderRepository salesOrderRepository;
594
+
595
+ @BeforeEach
596
+ void setUp() {
597
+ salesOrderRepository.deleteAll();
598
+ }
599
+
600
+ @Nested
601
+ @DisplayName("受注の登録")
602
+ class OrderRegistration {
603
+
604
+ @Test
605
+ @DisplayName("受注を登録できる")
606
+ void canRegisterOrder() {
607
+ // Arrange
608
+ var order = SalesOrder.builder()
609
+ .orderNumber("SO-2025-0001")
610
+ .orderDate(LocalDate.of(2025, 1, 20))
611
+ .customerCode("CUST-001")
612
+ .requestedDeliveryDate(LocalDate.of(2025, 1, 30))
613
+ .subtotal(new BigDecimal("50000"))
614
+ .taxAmount(new BigDecimal("5000"))
615
+ .totalAmount(new BigDecimal("55000"))
616
+ .status(OrderStatus.RECEIVED)
617
+ .build();
618
+
619
+ // Act
620
+ salesOrderRepository.save(order);
621
+
622
+ // Assert
623
+ var result = salesOrderRepository.findByOrderNumber("SO-2025-0001");
624
+ assertThat(result).isPresent();
625
+ assertThat(result.get().getOrderNumber()).isEqualTo("SO-2025-0001");
626
+ assertThat(result.get().getCustomerCode()).isEqualTo("CUST-001");
627
+ assertThat(result.get().getTotalAmount()).isEqualByComparingTo(new BigDecimal("55000"));
628
+ assertThat(result.get().getStatus()).isEqualTo(OrderStatus.RECEIVED);
629
+ }
630
+
631
+ @Test
632
+ @DisplayName("受注明細を登録できる")
633
+ void canRegisterOrderWithDetails() {
634
+ // Arrange
635
+ var order = SalesOrder.builder()
636
+ .orderNumber("SO-2025-0002")
637
+ .orderDate(LocalDate.of(2025, 1, 20))
638
+ .customerCode("CUST-001")
639
+ .subtotal(new BigDecimal("10000"))
640
+ .taxAmount(new BigDecimal("1000"))
641
+ .totalAmount(new BigDecimal("11000"))
642
+ .status(OrderStatus.RECEIVED)
643
+ .build();
644
+ salesOrderRepository.save(order);
645
+
646
+ var detail = SalesOrderDetail.builder()
647
+ .orderId(order.getId())
648
+ .lineNumber(1)
649
+ .productCode("PROD-001")
650
+ .productName("テスト商品A")
651
+ .orderQuantity(new BigDecimal("10"))
652
+ .allocatedQuantity(BigDecimal.ZERO)
653
+ .shippedQuantity(BigDecimal.ZERO)
654
+ .remainingQuantity(new BigDecimal("10"))
655
+ .unit("個")
656
+ .unitPrice(new BigDecimal("1000"))
657
+ .amount(new BigDecimal("10000"))
658
+ .taxCategory(TaxCategory.EXCLUSIVE)
659
+ .taxRate(new BigDecimal("10.00"))
660
+ .taxAmount(new BigDecimal("1000"))
661
+ .warehouseCode("WH-001")
662
+ .build();
663
+
664
+ // Act
665
+ salesOrderRepository.saveDetail(detail);
666
+
667
+ // Assert
668
+ var result = salesOrderRepository.findByIdWithDetails(order.getId());
669
+ assertThat(result).isPresent();
670
+ assertThat(result.get().getDetails()).hasSize(1);
671
+ assertThat(result.get().getDetails().get(0).getProductCode()).isEqualTo("PROD-001");
672
+ assertThat(result.get().getDetails().get(0).getOrderQuantity())
673
+ .isEqualByComparingTo(new BigDecimal("10"));
674
+ }
675
+
676
+ @Test
677
+ @DisplayName("受注明細の数量を更新できる")
678
+ void canUpdateDetailQuantities() {
679
+ // Arrange
680
+ var order = SalesOrder.builder()
681
+ .orderNumber("SO-2025-0003")
682
+ .orderDate(LocalDate.of(2025, 1, 20))
683
+ .customerCode("CUST-001")
684
+ .subtotal(new BigDecimal("10000"))
685
+ .taxAmount(new BigDecimal("1000"))
686
+ .totalAmount(new BigDecimal("11000"))
687
+ .status(OrderStatus.RECEIVED)
688
+ .build();
689
+ salesOrderRepository.save(order);
690
+
691
+ var detail = SalesOrderDetail.builder()
692
+ .orderId(order.getId())
693
+ .lineNumber(1)
694
+ .productCode("PROD-001")
695
+ .productName("テスト商品A")
696
+ .orderQuantity(new BigDecimal("10"))
697
+ .allocatedQuantity(BigDecimal.ZERO)
698
+ .shippedQuantity(BigDecimal.ZERO)
699
+ .remainingQuantity(new BigDecimal("10"))
700
+ .unit("個")
701
+ .unitPrice(new BigDecimal("1000"))
702
+ .amount(new BigDecimal("10000"))
703
+ .taxCategory(TaxCategory.EXCLUSIVE)
704
+ .taxRate(new BigDecimal("10.00"))
705
+ .taxAmount(new BigDecimal("1000"))
706
+ .build();
707
+ salesOrderRepository.saveDetail(detail);
708
+
709
+ // Act: 5個引当
710
+ salesOrderRepository.updateDetailQuantities(
711
+ detail.getId(),
712
+ new BigDecimal("5"), // 引当数量
713
+ BigDecimal.ZERO, // 出荷数量
714
+ new BigDecimal("5") // 残数量
715
+ );
716
+
717
+ // Assert
718
+ var result = salesOrderRepository.findByIdWithDetails(order.getId());
719
+ assertThat(result).isPresent();
720
+ var updatedDetail = result.get().getDetails().get(0);
721
+ assertThat(updatedDetail.getAllocatedQuantity()).isEqualByComparingTo(new BigDecimal("5"));
722
+ assertThat(updatedDetail.getRemainingQuantity()).isEqualByComparingTo(new BigDecimal("5"));
723
+ }
724
+ }
725
+
726
+ @Nested
727
+ @DisplayName("受注の検索")
728
+ class OrderSearch {
729
+
730
+ @Test
731
+ @DisplayName("納期範囲で受注を検索できる")
732
+ void canSearchByDeliveryDateRange() {
733
+ // Arrange
734
+ var order1 = SalesOrder.builder()
735
+ .orderNumber("SO-2025-0004")
736
+ .orderDate(LocalDate.of(2025, 1, 15))
737
+ .customerCode("CUST-001")
738
+ .requestedDeliveryDate(LocalDate.of(2025, 1, 25))
739
+ .subtotal(BigDecimal.ZERO)
740
+ .taxAmount(BigDecimal.ZERO)
741
+ .totalAmount(BigDecimal.ZERO)
742
+ .status(OrderStatus.RECEIVED)
743
+ .build();
744
+ salesOrderRepository.save(order1);
745
+
746
+ var order2 = SalesOrder.builder()
747
+ .orderNumber("SO-2025-0005")
748
+ .orderDate(LocalDate.of(2025, 1, 20))
749
+ .customerCode("CUST-001")
750
+ .requestedDeliveryDate(LocalDate.of(2025, 2, 15))
751
+ .subtotal(BigDecimal.ZERO)
752
+ .taxAmount(BigDecimal.ZERO)
753
+ .totalAmount(BigDecimal.ZERO)
754
+ .status(OrderStatus.RECEIVED)
755
+ .build();
756
+ salesOrderRepository.save(order2);
757
+
758
+ // Act: 1月中の納期で検索
759
+ var januaryOrders = salesOrderRepository.findByDeliveryDateRange(
760
+ LocalDate.of(2025, 1, 1),
761
+ LocalDate.of(2025, 1, 31)
762
+ );
763
+
764
+ // Assert
765
+ assertThat(januaryOrders).hasSize(1);
766
+ assertThat(januaryOrders.get(0).getOrderNumber()).isEqualTo("SO-2025-0004");
767
+ }
768
+ }
769
+ }
770
+ ```
771
+
772
+ </details>
773
+
774
+ #### Green: テストを通す実装
775
+
776
+ <details>
777
+ <summary>SalesOrderRepository.java</summary>
778
+
779
+ ```java
780
+ // src/main/java/com/example/sms/application/port/out/SalesOrderRepository.java
781
+ package com.example.sms.application.port.out;
782
+
783
+ import com.example.sms.domain.model.sales.OrderStatus;
784
+ import com.example.sms.domain.model.sales.SalesOrder;
785
+ import com.example.sms.domain.model.sales.SalesOrderDetail;
786
+
787
+ import java.math.BigDecimal;
788
+ import java.time.LocalDate;
789
+ import java.util.List;
790
+ import java.util.Optional;
791
+
792
+ public interface SalesOrderRepository {
793
+
794
+ void save(SalesOrder order);
795
+
796
+ void saveDetail(SalesOrderDetail detail);
797
+
798
+ Optional<SalesOrder> findById(Integer id);
799
+
800
+ Optional<SalesOrder> findByOrderNumber(String orderNumber);
801
+
802
+ Optional<SalesOrder> findByIdWithDetails(Integer id);
803
+
804
+ List<SalesOrder> findByDeliveryDateRange(LocalDate from, LocalDate to);
805
+
806
+ void updateStatus(Integer id, OrderStatus status);
807
+
808
+ void updateDetailQuantities(Integer detailId,
809
+ BigDecimal allocatedQuantity,
810
+ BigDecimal shippedQuantity,
811
+ BigDecimal remainingQuantity);
812
+
813
+ void deleteAll();
814
+ }
815
+ ```
816
+
817
+ </details>
818
+
819
+ <details>
820
+ <summary>SalesOrderMapper.xml</summary>
821
+
822
+ ```xml
823
+ <?xml version="1.0" encoding="UTF-8" ?>
824
+ <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
825
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
826
+
827
+ <!-- src/main/resources/mapper/SalesOrderMapper.xml -->
828
+ <mapper namespace="com.example.sms.infrastructure.persistence.mapper.SalesOrderMapper">
829
+
830
+ <resultMap id="SalesOrderResultMap" type="com.example.sms.domain.model.sales.SalesOrder">
831
+ <id property="id" column="ID"/>
832
+ <result property="orderNumber" column="受注番号"/>
833
+ <result property="orderDate" column="受注日"/>
834
+ <result property="customerCode" column="顧客コード"/>
835
+ <result property="customerBranchNumber" column="顧客枝番"/>
836
+ <result property="shippingDestinationNumber" column="出荷先番号"/>
837
+ <result property="representativeCode" column="担当者コード"/>
838
+ <result property="requestedDeliveryDate" column="希望納期"/>
839
+ <result property="scheduledShippingDate" column="出荷予定日"/>
840
+ <result property="orderAmount" column="受注金額"/>
841
+ <result property="taxAmount" column="消費税額"/>
842
+ <result property="totalAmount" column="受注合計"/>
843
+ <result property="status" column="ステータス"
844
+ typeHandler="com.example.sms.infrastructure.out.persistence.typehandler.OrderStatusTypeHandler"/>
845
+ <result property="quotationId" column="見積ID"/>
846
+ <result property="customerOrderNumber" column="顧客注文番号"/>
847
+ <result property="remarks" column="備考"/>
848
+ <result property="createdAt" column="作成日時"/>
849
+ <result property="createdBy" column="作成者"/>
850
+ <result property="updatedAt" column="更新日時"/>
851
+ <result property="updatedBy" column="更新者"/>
852
+ <collection property="details" ofType="com.example.sms.domain.model.sales.SalesOrderDetail">
853
+ <id property="id" column="明細ID"/>
854
+ <result property="orderId" column="受注ID"/>
855
+ <result property="lineNumber" column="行番号"/>
856
+ <result property="productCode" column="商品コード"/>
857
+ <result property="productName" column="商品名"/>
858
+ <result property="orderQuantity" column="受注数量"/>
859
+ <result property="allocatedQuantity" column="引当数量"/>
860
+ <result property="shippedQuantity" column="出荷数量"/>
861
+ <result property="remainingQuantity" column="残数量"/>
862
+ <result property="unit" column="単位"/>
863
+ <result property="unitPrice" column="単価"/>
864
+ <result property="amount" column="金額"/>
865
+ <result property="taxCategory" column="税区分"
866
+ typeHandler="com.example.sms.infrastructure.out.persistence.typehandler.TaxCategoryTypeHandler"/>
867
+ <result property="taxRate" column="消費税率"/>
868
+ <result property="taxAmount" column="明細消費税額"/>
869
+ <result property="warehouseCode" column="倉庫コード"/>
870
+ <result property="requestedDeliveryDate" column="明細希望納期"/>
871
+ </collection>
872
+ </resultMap>
873
+
874
+ <insert id="insertHeader" parameterType="com.example.sms.domain.model.sales.SalesOrder"
875
+ useGeneratedKeys="true" keyProperty="id" keyColumn="ID">
876
+ INSERT INTO "受注データ" (
877
+ "受注番号", "受注日", "顧客コード", "顧客枝番", "出荷先番号",
878
+ "担当者コード", "希望納期", "出荷予定日", "受注金額", "消費税額",
879
+ "受注合計", "ステータス", "見積ID", "顧客注文番号", "備考",
880
+ "作成日時", "作成者", "更新日時", "更新者"
881
+ ) VALUES (
882
+ #{orderNumber}, #{orderDate}, #{customerCode}, #{customerBranchNumber}, #{shippingDestinationNumber},
883
+ #{representativeCode}, #{requestedDeliveryDate}, #{scheduledShippingDate}, #{orderAmount}, #{taxAmount},
884
+ #{totalAmount},
885
+ #{status, typeHandler=com.example.sms.infrastructure.out.persistence.typehandler.OrderStatusTypeHandler}::受注ステータス,
886
+ #{quotationId}, #{customerOrderNumber}, #{remarks},
887
+ CURRENT_TIMESTAMP, #{createdBy}, CURRENT_TIMESTAMP, #{updatedBy}
888
+ )
889
+ </insert>
890
+
891
+ <insert id="insertDetail" useGeneratedKeys="true" keyProperty="id" keyColumn="ID">
892
+ INSERT INTO "受注明細" (
893
+ "受注ID", "行番号", "商品コード", "商品名", "受注数量",
894
+ "引当数量", "出荷数量", "残数量", "単位", "単価", "金額",
895
+ "税区分", "消費税率", "消費税額", "倉庫コード", "希望納期"
896
+ ) VALUES (
897
+ #{orderId}, #{lineNumber}, #{productCode}, #{productName}, #{orderQuantity},
898
+ #{allocatedQuantity}, #{shippedQuantity}, #{remainingQuantity}, #{unit},
899
+ #{unitPrice}, #{amount},
900
+ #{taxCategory, typeHandler=com.example.sms.infrastructure.out.persistence.typehandler.TaxCategoryTypeHandler},
901
+ #{taxRate}, #{taxAmount}, #{warehouseCode}, #{requestedDeliveryDate}
902
+ )
903
+ </insert>
904
+
905
+ <select id="findByOrderNumber" resultMap="SalesOrderResultMap">
906
+ SELECT * FROM "受注データ" WHERE "受注番号" = #{orderNumber}
907
+ </select>
908
+
909
+ <select id="findDetailsByOrderId" resultMap="SalesOrderDetailResultMap">
910
+ SELECT * FROM "受注明細"
911
+ WHERE "受注ID" = #{orderId}
912
+ ORDER BY "行番号"
913
+ </select>
914
+
915
+ <select id="findByDeliveryDateRange" resultMap="SalesOrderResultMap">
916
+ SELECT * FROM "受注データ"
917
+ WHERE "希望納期" BETWEEN #{from} AND #{to}
918
+ ORDER BY "希望納期"
919
+ </select>
920
+
921
+ <update id="updateStatus">
922
+ UPDATE "受注データ"
923
+ SET "ステータス" = #{status, typeHandler=com.example.sms.infrastructure.out.persistence.typehandler.OrderStatusTypeHandler},
924
+ "更新日時" = CURRENT_TIMESTAMP
925
+ WHERE "ID" = #{id}
926
+ </update>
927
+
928
+ <update id="updateDetailQuantities">
929
+ UPDATE "受注明細"
930
+ SET "引当数量" = #{allocatedQuantity},
931
+ "出荷数量" = #{shippedQuantity},
932
+ "残数量" = #{remainingQuantity}
933
+ WHERE "ID" = #{detailId}
934
+ </update>
935
+
936
+ <delete id="deleteAll">
937
+ DELETE FROM "受注明細";
938
+ DELETE FROM "受注データ";
939
+ </delete>
940
+
941
+ </mapper>
942
+ ```
943
+
944
+ </details>
945
+
946
+ ---
947
+
948
+ ## 6.2 出荷業務の DB 設計
949
+
950
+ ### 出荷業務の流れ
951
+
952
+ 出荷業務は、受注データを起点として商品を顧客に届けるまでのプロセスです。
953
+
954
+ ```plantuml
955
+ @startuml
956
+
957
+ title 出荷業務フロー
958
+
959
+ |倉庫部門|
960
+ start
961
+ :受注データ確認;
962
+ :出荷指示作成;
963
+
964
+ :ピッキングリスト発行;
965
+
966
+ :ピッキング作業;
967
+ note right
968
+ 倉庫から商品を
969
+ ピックアップ
970
+ end note
971
+
972
+ :検品・梱包;
973
+
974
+ :出荷;
975
+ note right
976
+ 配送業者に引渡し
977
+ 追跡番号取得
978
+ end note
979
+
980
+ |営業部門|
981
+ :売上計上;
982
+ :納品書発行;
983
+
984
+ stop
985
+
986
+ @enduml
987
+ ```
988
+
989
+ ### 出荷指示データ・出荷明細データ
990
+
991
+ #### 出荷関連テーブルの ER 図
992
+
993
+ ```plantuml
994
+ @startuml
995
+
996
+ title 出荷関連テーブル
997
+
998
+ entity 受注データ {
999
+ ID <<PK>>
1000
+ --
1001
+ 受注番号
1002
+ 受注日
1003
+ 顧客コード
1004
+ ステータス
1005
+ ...
1006
+ }
1007
+
1008
+ entity 受注明細 {
1009
+ ID <<PK>>
1010
+ --
1011
+ 受注ID <<FK>>
1012
+ 商品コード
1013
+ 受注数量
1014
+ 出荷数量
1015
+ 残数量
1016
+ ...
1017
+ }
1018
+
1019
+ entity 出荷データ {
1020
+ ID <<PK>>
1021
+ --
1022
+ 出荷番号 <<UK>>
1023
+ 出荷日
1024
+ 受注ID <<FK>>
1025
+ 顧客コード <<FK>>
1026
+ 顧客枝番 <<FK>>
1027
+ 出荷先番号 <<FK>>
1028
+ 出荷先名
1029
+ 出荷先郵便番号
1030
+ 出荷先住所1
1031
+ 出荷先住所2
1032
+ 担当者コード
1033
+ 倉庫コード
1034
+ ステータス
1035
+ 備考
1036
+ 作成日時
1037
+ 更新日時
1038
+ }
1039
+
1040
+ entity 出荷明細 {
1041
+ ID <<PK>>
1042
+ --
1043
+ 出荷ID <<FK>>
1044
+ 行番号
1045
+ 受注明細ID <<FK>>
1046
+ 商品コード <<FK>>
1047
+ 商品名
1048
+ 出荷数量
1049
+ 単位
1050
+ 単価
1051
+ 金額
1052
+ 税区分
1053
+ 消費税率
1054
+ 消費税額
1055
+ 倉庫コード
1056
+ 備考
1057
+ }
1058
+
1059
+ 受注データ ||--o{ 受注明細
1060
+ 受注データ ||--o{ 出荷データ
1061
+ 出荷データ ||--o{ 出荷明細
1062
+ 受注明細 ||--o{ 出荷明細
1063
+
1064
+ @enduml
1065
+ ```
1066
+
1067
+ ### 出荷ステータスの定義
1068
+
1069
+ ```plantuml
1070
+ @startuml
1071
+
1072
+ title 出荷ステータス遷移図
1073
+
1074
+ [*] --> 出荷指示済
1075
+
1076
+ 出荷指示済 --> 出荷準備中 : 出荷準備開始
1077
+ 出荷準備中 --> 出荷済 : 出荷完了
1078
+
1079
+ 出荷済 --> [*]
1080
+
1081
+ 出荷指示済 --> キャンセル
1082
+ 出荷準備中 --> キャンセル
1083
+
1084
+ キャンセル --> [*]
1085
+
1086
+ @enduml
1087
+ ```
1088
+
1089
+ | ステータス | 説明 |
1090
+ |-----------|------|
1091
+ | **出荷指示済** | 出荷指示が作成された状態 |
1092
+ | **出荷準備中** | 出荷準備作業中の状態 |
1093
+ | **出荷済** | 配送業者に引き渡した状態 |
1094
+ | **キャンセル** | 出荷指示がキャンセルされた状態 |
1095
+
1096
+ ### マイグレーション:出荷関連テーブルの作成
1097
+
1098
+ <details>
1099
+ <summary>V008__create_shipment_tables.sql</summary>
1100
+
1101
+ ```sql
1102
+ -- src/main/resources/db/migration/V008__create_shipment_tables.sql
1103
+
1104
+ -- 出荷ステータス
1105
+ CREATE TYPE 出荷ステータス AS ENUM ('出荷指示済', '出荷準備中', '出荷済', 'キャンセル');
1106
+
1107
+ -- 出荷データ(ヘッダ)
1108
+ CREATE TABLE "出荷データ" (
1109
+ "ID" SERIAL PRIMARY KEY,
1110
+ "出荷番号" VARCHAR(20) UNIQUE NOT NULL,
1111
+ "出荷日" DATE NOT NULL,
1112
+ "受注ID" INTEGER NOT NULL,
1113
+ "顧客コード" VARCHAR(20) NOT NULL,
1114
+ "顧客枝番" VARCHAR(10) DEFAULT '00',
1115
+ "出荷先番号" VARCHAR(10),
1116
+ "出荷先名" VARCHAR(100),
1117
+ "出荷先郵便番号" VARCHAR(10),
1118
+ "出荷先住所1" VARCHAR(100),
1119
+ "出荷先住所2" VARCHAR(100),
1120
+ "担当者コード" VARCHAR(20),
1121
+ "倉庫コード" VARCHAR(20),
1122
+ "ステータス" 出荷ステータス DEFAULT '出荷指示済' NOT NULL,
1123
+ "備考" TEXT,
1124
+ "作成日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1125
+ "作成者" VARCHAR(50),
1126
+ "更新日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1127
+ "更新者" VARCHAR(50),
1128
+ CONSTRAINT "fk_出荷データ_受注"
1129
+ FOREIGN KEY ("受注ID") REFERENCES "受注データ"("ID"),
1130
+ CONSTRAINT "fk_出荷データ_顧客"
1131
+ FOREIGN KEY ("顧客コード", "顧客枝番") REFERENCES "顧客マスタ"("顧客コード", "顧客枝番"),
1132
+ CONSTRAINT "fk_出荷データ_出荷先"
1133
+ FOREIGN KEY ("顧客コード", "顧客枝番", "出荷先番号") REFERENCES "出荷先マスタ"("取引先コード", "顧客枝番", "出荷先番号")
1134
+ );
1135
+
1136
+ -- 出荷明細
1137
+ CREATE TABLE "出荷明細" (
1138
+ "ID" SERIAL PRIMARY KEY,
1139
+ "出荷ID" INTEGER NOT NULL,
1140
+ "行番号" INTEGER NOT NULL,
1141
+ "受注明細ID" INTEGER NOT NULL,
1142
+ "商品コード" VARCHAR(20) NOT NULL,
1143
+ "商品名" VARCHAR(100) NOT NULL,
1144
+ "出荷数量" DECIMAL(15, 2) NOT NULL,
1145
+ "単位" VARCHAR(10),
1146
+ "単価" DECIMAL(15, 2) NOT NULL,
1147
+ "金額" DECIMAL(15, 2) NOT NULL,
1148
+ "税区分" 税区分 DEFAULT '外税' NOT NULL,
1149
+ "消費税率" DECIMAL(5, 2) DEFAULT 10.00 NOT NULL,
1150
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1151
+ "倉庫コード" VARCHAR(20),
1152
+ "備考" TEXT,
1153
+ CONSTRAINT "fk_出荷明細_出荷"
1154
+ FOREIGN KEY ("出荷ID") REFERENCES "出荷データ"("ID") ON DELETE CASCADE,
1155
+ CONSTRAINT "fk_出荷明細_受注明細"
1156
+ FOREIGN KEY ("受注明細ID") REFERENCES "受注明細"("ID"),
1157
+ CONSTRAINT "fk_出荷明細_商品"
1158
+ FOREIGN KEY ("商品コード") REFERENCES "商品マスタ"("商品コード"),
1159
+ CONSTRAINT "uq_出荷明細_行番号" UNIQUE ("出荷ID", "行番号")
1160
+ );
1161
+
1162
+ -- インデックス
1163
+ CREATE INDEX "idx_出荷データ_受注ID" ON "出荷データ"("受注ID");
1164
+ CREATE INDEX "idx_出荷データ_顧客コード" ON "出荷データ"("顧客コード");
1165
+ CREATE INDEX "idx_出荷データ_出荷日" ON "出荷データ"("出荷日");
1166
+ CREATE INDEX "idx_出荷データ_ステータス" ON "出荷データ"("ステータス");
1167
+ ```
1168
+
1169
+ </details>
1170
+
1171
+ ### 出荷ステータス Enum
1172
+
1173
+ <details>
1174
+ <summary>ShipmentStatus.java</summary>
1175
+
1176
+ ```java
1177
+ // src/main/java/com/example/sms/domain/model/shipping/ShipmentStatus.java
1178
+ package com.example.sms.domain.model.shipping;
1179
+
1180
+ import lombok.Getter;
1181
+ import lombok.RequiredArgsConstructor;
1182
+
1183
+ @Getter
1184
+ @RequiredArgsConstructor
1185
+ public enum ShipmentStatus {
1186
+ INSTRUCTED("出荷指示済"),
1187
+ PREPARING("出荷準備中"),
1188
+ SHIPPED("出荷済"),
1189
+ CANCELLED("キャンセル");
1190
+
1191
+ private final String displayName;
1192
+
1193
+ public static ShipmentStatus fromDisplayName(String displayName) {
1194
+ for (ShipmentStatus status : values()) {
1195
+ if (status.displayName.equals(displayName)) {
1196
+ return status;
1197
+ }
1198
+ }
1199
+ throw new IllegalArgumentException("Unknown shipment status: " + displayName);
1200
+ }
1201
+ }
1202
+ ```
1203
+
1204
+ </details>
1205
+
1206
+ ### 出荷エンティティ
1207
+
1208
+ <details>
1209
+ <summary>Shipment.java</summary>
1210
+
1211
+ ```java
1212
+ // src/main/java/com/example/sms/domain/model/shipping/Shipment.java
1213
+ package com.example.sms.domain.model.shipping;
1214
+
1215
+ import lombok.AllArgsConstructor;
1216
+ import lombok.Builder;
1217
+ import lombok.Data;
1218
+ import lombok.NoArgsConstructor;
1219
+
1220
+ import java.time.LocalDate;
1221
+ import java.time.LocalDateTime;
1222
+ import java.util.List;
1223
+
1224
+ @Data
1225
+ @Builder
1226
+ @NoArgsConstructor
1227
+ @AllArgsConstructor
1228
+ @SuppressWarnings("PMD.RedundantFieldInitializer")
1229
+ public class Shipment {
1230
+ private Integer id;
1231
+ private String shipmentNumber;
1232
+ private LocalDate shipmentDate;
1233
+ private Integer orderId;
1234
+ private String customerCode;
1235
+ private String customerBranchNumber;
1236
+ private String shippingDestinationNumber;
1237
+ private String shippingDestinationName;
1238
+ private String shippingDestinationPostalCode;
1239
+ private String shippingDestinationAddress1;
1240
+ private String shippingDestinationAddress2;
1241
+ private String representativeCode;
1242
+ private String warehouseCode;
1243
+ @Builder.Default
1244
+ private ShipmentStatus status = ShipmentStatus.INSTRUCTED;
1245
+ private String remarks;
1246
+ private LocalDateTime createdAt;
1247
+ private String createdBy;
1248
+ private LocalDateTime updatedAt;
1249
+ private String updatedBy;
1250
+ private List<ShipmentDetail> details;
1251
+ }
1252
+ ```
1253
+
1254
+ </details>
1255
+
1256
+ <details>
1257
+ <summary>ShipmentDetail.java</summary>
1258
+
1259
+ ```java
1260
+ // src/main/java/com/example/sms/domain/model/shipping/ShipmentDetail.java
1261
+ package com.example.sms.domain.model.shipping;
1262
+
1263
+ import com.example.sms.domain.model.product.TaxCategory;
1264
+ import lombok.AllArgsConstructor;
1265
+ import lombok.Builder;
1266
+ import lombok.Data;
1267
+ import lombok.NoArgsConstructor;
1268
+
1269
+ import java.math.BigDecimal;
1270
+
1271
+ @Data
1272
+ @Builder
1273
+ @NoArgsConstructor
1274
+ @AllArgsConstructor
1275
+ public class ShipmentDetail {
1276
+ private Integer id;
1277
+ private Integer shipmentId;
1278
+ private Integer lineNumber;
1279
+ private Integer orderDetailId;
1280
+ private String productCode;
1281
+ private String productName;
1282
+ private BigDecimal shippedQuantity;
1283
+ private String unit;
1284
+ private BigDecimal unitPrice;
1285
+ private BigDecimal amount;
1286
+ private TaxCategory taxCategory;
1287
+ private BigDecimal taxRate;
1288
+ private BigDecimal taxAmount;
1289
+ private String warehouseCode;
1290
+ private String remarks;
1291
+ }
1292
+ ```
1293
+
1294
+ </details>
1295
+
1296
+ ### TDD:出荷指示の登録と取得
1297
+
1298
+ <details>
1299
+ <summary>ShipmentInstructionRepositoryTest.java</summary>
1300
+
1301
+ ```java
1302
+ // src/test/java/com/example/sms/infrastructure/persistence/repository/ShipmentInstructionRepositoryTest.java
1303
+ package com.example.sms.infrastructure.persistence.repository;
1304
+
1305
+ import com.example.sms.application.port.out.SalesOrderRepository;
1306
+ import com.example.sms.application.port.out.ShipmentInstructionRepository;
1307
+ import com.example.sms.domain.model.sales.*;
1308
+ import com.example.sms.testsetup.BaseIntegrationTest;
1309
+ import org.junit.jupiter.api.*;
1310
+ import org.springframework.beans.factory.annotation.Autowired;
1311
+
1312
+ import java.math.BigDecimal;
1313
+ import java.time.LocalDate;
1314
+
1315
+ import static org.assertj.core.api.Assertions.*;
1316
+
1317
+ @DisplayName("出荷指示リポジトリ")
1318
+ class ShipmentInstructionRepositoryTest extends BaseIntegrationTest {
1319
+
1320
+ @Autowired
1321
+ private ShipmentInstructionRepository shipmentInstructionRepository;
1322
+
1323
+ @Autowired
1324
+ private SalesOrderRepository salesOrderRepository;
1325
+
1326
+ @BeforeEach
1327
+ void setUp() {
1328
+ shipmentInstructionRepository.deleteAll();
1329
+ salesOrderRepository.deleteAll();
1330
+ setupOrderData();
1331
+ }
1332
+
1333
+ private void setupOrderData() {
1334
+ // 受注データ準備
1335
+ var order = SalesOrder.builder()
1336
+ .orderNumber("SO-2025-0001")
1337
+ .orderDate(LocalDate.of(2025, 1, 20))
1338
+ .customerCode("CUST-001")
1339
+ .requestedDeliveryDate(LocalDate.of(2025, 1, 30))
1340
+ .subtotal(new BigDecimal("50000"))
1341
+ .taxAmount(new BigDecimal("5000"))
1342
+ .totalAmount(new BigDecimal("55000"))
1343
+ .status(OrderStatus.RECEIVED)
1344
+ .build();
1345
+ salesOrderRepository.save(order);
1346
+ }
1347
+
1348
+ @Nested
1349
+ @DisplayName("出荷指示の登録")
1350
+ class ShipmentInstructionRegistration {
1351
+
1352
+ @Test
1353
+ @DisplayName("出荷指示を登録できる")
1354
+ void canRegisterShipmentInstruction() {
1355
+ // Arrange
1356
+ var order = salesOrderRepository.findByOrderNumber("SO-2025-0001").get();
1357
+ var instruction = ShipmentInstruction.builder()
1358
+ .instructionNumber("SI-2025-0001")
1359
+ .instructionDate(LocalDate.of(2025, 1, 25))
1360
+ .orderId(order.getId())
1361
+ .customerCode("CUST-001")
1362
+ .scheduledShipDate(LocalDate.of(2025, 1, 28))
1363
+ .warehouseCode("WH-001")
1364
+ .status(ShipmentStatus.INSTRUCTED)
1365
+ .build();
1366
+
1367
+ // Act
1368
+ shipmentInstructionRepository.save(instruction);
1369
+
1370
+ // Assert
1371
+ var result = shipmentInstructionRepository.findByInstructionNumber("SI-2025-0001");
1372
+ assertThat(result).isPresent();
1373
+ assertThat(result.get().getOrderId()).isEqualTo(order.getId());
1374
+ assertThat(result.get().getStatus()).isEqualTo(ShipmentStatus.INSTRUCTED);
1375
+ }
1376
+
1377
+ @Test
1378
+ @DisplayName("出荷指示ステータスを更新できる")
1379
+ void canUpdateShipmentStatus() {
1380
+ // Arrange
1381
+ var order = salesOrderRepository.findByOrderNumber("SO-2025-0001").get();
1382
+ var instruction = ShipmentInstruction.builder()
1383
+ .instructionNumber("SI-2025-0002")
1384
+ .instructionDate(LocalDate.of(2025, 1, 25))
1385
+ .orderId(order.getId())
1386
+ .customerCode("CUST-001")
1387
+ .scheduledShipDate(LocalDate.of(2025, 1, 28))
1388
+ .warehouseCode("WH-001")
1389
+ .status(ShipmentStatus.INSTRUCTED)
1390
+ .build();
1391
+ shipmentInstructionRepository.save(instruction);
1392
+
1393
+ // Act: ピッキング開始
1394
+ shipmentInstructionRepository.updateStatus(instruction.getId(), ShipmentStatus.PICKING);
1395
+
1396
+ // Assert
1397
+ var result = shipmentInstructionRepository.findById(instruction.getId());
1398
+ assertThat(result).isPresent();
1399
+ assertThat(result.get().getStatus()).isEqualTo(ShipmentStatus.PICKING);
1400
+ }
1401
+
1402
+ @Test
1403
+ @DisplayName("追跡番号を登録できる")
1404
+ void canRegisterTrackingNumber() {
1405
+ // Arrange
1406
+ var order = salesOrderRepository.findByOrderNumber("SO-2025-0001").get();
1407
+ var instruction = ShipmentInstruction.builder()
1408
+ .instructionNumber("SI-2025-0003")
1409
+ .instructionDate(LocalDate.of(2025, 1, 25))
1410
+ .orderId(order.getId())
1411
+ .customerCode("CUST-001")
1412
+ .scheduledShipDate(LocalDate.of(2025, 1, 28))
1413
+ .warehouseCode("WH-001")
1414
+ .status(ShipmentStatus.SHIPPED)
1415
+ .carrierCode("YAMATO")
1416
+ .trackingNumber("1234-5678-9012")
1417
+ .build();
1418
+
1419
+ // Act
1420
+ shipmentInstructionRepository.save(instruction);
1421
+
1422
+ // Assert
1423
+ var result = shipmentInstructionRepository.findByInstructionNumber("SI-2025-0003");
1424
+ assertThat(result).isPresent();
1425
+ assertThat(result.get().getCarrierCode()).isEqualTo("YAMATO");
1426
+ assertThat(result.get().getTrackingNumber()).isEqualTo("1234-5678-9012");
1427
+ }
1428
+ }
1429
+ }
1430
+ ```
1431
+
1432
+ </details>
1433
+
1434
+ ### 受注から出荷指示を作成するサービス
1435
+
1436
+ <details>
1437
+ <summary>ShipmentService.java</summary>
1438
+
1439
+ ```java
1440
+ // src/main/java/com/example/sms/application/service/ShipmentService.java
1441
+ package com.example.sms.application.service;
1442
+
1443
+ import com.example.sms.application.port.out.*;
1444
+ import com.example.sms.domain.model.sales.*;
1445
+ import lombok.RequiredArgsConstructor;
1446
+ import org.springframework.stereotype.Service;
1447
+ import org.springframework.transaction.annotation.Transactional;
1448
+
1449
+ import java.math.BigDecimal;
1450
+ import java.time.LocalDate;
1451
+
1452
+ @Service
1453
+ @RequiredArgsConstructor
1454
+ public class ShipmentService {
1455
+
1456
+ private final SalesOrderRepository salesOrderRepository;
1457
+ private final ShipmentInstructionRepository shipmentInstructionRepository;
1458
+
1459
+ /**
1460
+ * 受注から出荷指示を作成する
1461
+ */
1462
+ @Transactional
1463
+ public ShipmentInstruction createShipmentInstruction(Integer orderId,
1464
+ LocalDate scheduledShipDate,
1465
+ String warehouseCode) {
1466
+ var order = salesOrderRepository.findByIdWithDetails(orderId)
1467
+ .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
1468
+
1469
+ if (order.getStatus() != OrderStatus.RECEIVED && order.getStatus() != OrderStatus.ALLOCATED) {
1470
+ throw new IllegalStateException("Cannot create shipment instruction for order with status: "
1471
+ + order.getStatus());
1472
+ }
1473
+
1474
+ // 出荷指示ヘッダ作成
1475
+ var instruction = ShipmentInstruction.builder()
1476
+ .instructionNumber(generateInstructionNumber(LocalDate.now()))
1477
+ .instructionDate(LocalDate.now())
1478
+ .orderId(orderId)
1479
+ .customerCode(order.getCustomerCode())
1480
+ .shipToCode(order.getShipToCode())
1481
+ .scheduledShipDate(scheduledShipDate)
1482
+ .warehouseCode(warehouseCode)
1483
+ .status(ShipmentStatus.INSTRUCTED)
1484
+ .build();
1485
+ shipmentInstructionRepository.save(instruction);
1486
+
1487
+ // 出荷指示明細作成
1488
+ int lineNumber = 1;
1489
+ for (var orderDetail : order.getDetails()) {
1490
+ if (orderDetail.getRemainingQuantity().compareTo(BigDecimal.ZERO) > 0) {
1491
+ var instructionDetail = ShipmentInstructionDetail.builder()
1492
+ .shipmentInstructionId(instruction.getId())
1493
+ .lineNumber(lineNumber++)
1494
+ .orderDetailId(orderDetail.getId())
1495
+ .productCode(orderDetail.getProductCode())
1496
+ .productName(orderDetail.getProductName())
1497
+ .instructedQuantity(orderDetail.getRemainingQuantity())
1498
+ .shippedQuantity(BigDecimal.ZERO)
1499
+ .unit(orderDetail.getUnit())
1500
+ .build();
1501
+ shipmentInstructionRepository.saveDetail(instructionDetail);
1502
+ }
1503
+ }
1504
+
1505
+ // 受注ステータスを更新
1506
+ salesOrderRepository.updateStatus(orderId, OrderStatus.SHIPMENT_INSTRUCTED);
1507
+
1508
+ return instruction;
1509
+ }
1510
+
1511
+ private String generateInstructionNumber(LocalDate date) {
1512
+ return String.format("SI-%d-%04d", date.getYear(), System.currentTimeMillis() % 10000);
1513
+ }
1514
+ }
1515
+ ```
1516
+
1517
+ </details>
1518
+
1519
+ ---
1520
+
1521
+ ## 6.3 売上業務の DB 設計
1522
+
1523
+ ### 売上データ・売上明細データの構造
1524
+
1525
+ #### 売上関連テーブルの ER 図
1526
+
1527
+ ```plantuml
1528
+ @startuml
1529
+
1530
+ title 売上関連テーブル
1531
+
1532
+ entity 出荷データ {
1533
+ ID <<PK>>
1534
+ --
1535
+ 出荷番号
1536
+ 受注ID <<FK>>
1537
+ ステータス
1538
+ ...
1539
+ }
1540
+
1541
+ entity 売上データ {
1542
+ ID <<PK>>
1543
+ --
1544
+ 売上番号 <<UK>>
1545
+ 売上日
1546
+ 受注ID <<FK>>
1547
+ 出荷ID <<FK>>
1548
+ 顧客コード <<FK>>
1549
+ 顧客枝番 <<FK>>
1550
+ 担当者コード
1551
+ 売上金額
1552
+ 消費税額
1553
+ 売上合計
1554
+ ステータス
1555
+ 請求ID
1556
+ 備考
1557
+ 作成日時
1558
+ 更新日時
1559
+ }
1560
+
1561
+ entity 売上明細 {
1562
+ ID <<PK>>
1563
+ --
1564
+ 売上ID <<FK>>
1565
+ 行番号
1566
+ 受注明細ID <<FK>>
1567
+ 出荷明細ID <<FK>>
1568
+ 商品コード <<FK>>
1569
+ 商品名
1570
+ 売上数量
1571
+ 単位
1572
+ 単価
1573
+ 金額
1574
+ 税区分
1575
+ 消費税率
1576
+ 消費税額
1577
+ 備考
1578
+ }
1579
+
1580
+ entity 返品データ {
1581
+ ID <<PK>>
1582
+ --
1583
+ 返品番号 <<UK>>
1584
+ 返品日
1585
+ 売上ID <<FK>>
1586
+ 顧客コード <<FK>>
1587
+ 返品理由
1588
+ 返品金額
1589
+ 消費税額
1590
+ 返品合計
1591
+ 倉庫コード
1592
+ 備考
1593
+ 作成日時
1594
+ 更新日時
1595
+ }
1596
+
1597
+ entity 返品明細 {
1598
+ ID <<PK>>
1599
+ --
1600
+ 返品ID <<FK>>
1601
+ 行番号
1602
+ 売上明細ID <<FK>>
1603
+ 商品コード <<FK>>
1604
+ 商品名
1605
+ 返品数量
1606
+ 単位
1607
+ 単価
1608
+ 金額
1609
+ 税区分
1610
+ 消費税率
1611
+ 消費税額
1612
+ 備考
1613
+ }
1614
+
1615
+ 出荷データ ||--o| 売上データ
1616
+ 売上データ ||--o{ 売上明細
1617
+ 売上データ ||--o{ 返品データ
1618
+ 返品データ ||--o{ 返品明細
1619
+
1620
+ @enduml
1621
+ ```
1622
+
1623
+ ### 出荷完了から売上計上への流れ
1624
+
1625
+ ```plantuml
1626
+ @startuml
1627
+
1628
+ title 出荷完了から売上計上への流れ
1629
+
1630
+ |倉庫部門|
1631
+ start
1632
+ :出荷完了報告;
1633
+ note right
1634
+ 配送業者に引渡し
1635
+ 追跡番号登録
1636
+ end note
1637
+
1638
+ |システム|
1639
+ :出荷ステータス更新;
1640
+ note right
1641
+ 出荷済に変更
1642
+ end note
1643
+
1644
+ :売上データ自動生成;
1645
+ note right
1646
+ 出荷指示データを元に
1647
+ 売上ヘッダ・明細を作成
1648
+ end note
1649
+
1650
+ :受注明細の出荷数量更新;
1651
+
1652
+ if (全明細出荷完了?) then (yes)
1653
+ :受注ステータスを出荷済に更新;
1654
+ else (no)
1655
+ :一部出荷として継続;
1656
+ endif
1657
+
1658
+ |営業部門|
1659
+ :納品書発行;
1660
+
1661
+ stop
1662
+
1663
+ @enduml
1664
+ ```
1665
+
1666
+ ### 売上ステータスの定義
1667
+
1668
+ | ステータス | 説明 |
1669
+ |-----------|------|
1670
+ | **計上済** | 売上が計上された状態 |
1671
+ | **請求済** | 請求書が発行された状態 |
1672
+ | **入金済** | 入金が確認された状態 |
1673
+ | **取消** | 売上が取り消された状態 |
1674
+
1675
+ ### マイグレーション:売上関連テーブルの作成
1676
+
1677
+ <details>
1678
+ <summary>V009__create_sales_tables.sql</summary>
1679
+
1680
+ ```sql
1681
+ -- src/main/resources/db/migration/V009__create_sales_tables.sql
1682
+
1683
+ -- 売上ステータス
1684
+ CREATE TYPE 売上ステータス AS ENUM ('計上済', '請求済', '入金済', 'キャンセル');
1685
+
1686
+ -- 売上データ(ヘッダ)
1687
+ CREATE TABLE "売上データ" (
1688
+ "ID" SERIAL PRIMARY KEY,
1689
+ "売上番号" VARCHAR(20) UNIQUE NOT NULL,
1690
+ "売上日" DATE NOT NULL,
1691
+ "受注ID" INTEGER NOT NULL,
1692
+ "出荷ID" INTEGER,
1693
+ "顧客コード" VARCHAR(20) NOT NULL,
1694
+ "顧客枝番" VARCHAR(10) DEFAULT '00',
1695
+ "担当者コード" VARCHAR(20),
1696
+ "売上金額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1697
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1698
+ "売上合計" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1699
+ "ステータス" 売上ステータス DEFAULT '計上済' NOT NULL,
1700
+ "請求ID" INTEGER,
1701
+ "備考" TEXT,
1702
+ "作成日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1703
+ "作成者" VARCHAR(50),
1704
+ "更新日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1705
+ "更新者" VARCHAR(50),
1706
+ CONSTRAINT "fk_売上データ_受注"
1707
+ FOREIGN KEY ("受注ID") REFERENCES "受注データ"("ID"),
1708
+ CONSTRAINT "fk_売上データ_出荷"
1709
+ FOREIGN KEY ("出荷ID") REFERENCES "出荷データ"("ID"),
1710
+ CONSTRAINT "fk_売上データ_顧客"
1711
+ FOREIGN KEY ("顧客コード", "顧客枝番") REFERENCES "顧客マスタ"("顧客コード", "顧客枝番")
1712
+ );
1713
+
1714
+ -- 売上明細
1715
+ CREATE TABLE "売上明細" (
1716
+ "ID" SERIAL PRIMARY KEY,
1717
+ "売上ID" INTEGER NOT NULL,
1718
+ "行番号" INTEGER NOT NULL,
1719
+ "受注明細ID" INTEGER NOT NULL,
1720
+ "出荷明細ID" INTEGER,
1721
+ "商品コード" VARCHAR(20) NOT NULL,
1722
+ "商品名" VARCHAR(100) NOT NULL,
1723
+ "売上数量" DECIMAL(15, 2) NOT NULL,
1724
+ "単位" VARCHAR(10),
1725
+ "単価" DECIMAL(15, 2) NOT NULL,
1726
+ "金額" DECIMAL(15, 2) NOT NULL,
1727
+ "税区分" 税区分 DEFAULT '外税' NOT NULL,
1728
+ "消費税率" DECIMAL(5, 2) DEFAULT 10.00 NOT NULL,
1729
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1730
+ "備考" TEXT,
1731
+ CONSTRAINT "fk_売上明細_売上"
1732
+ FOREIGN KEY ("売上ID") REFERENCES "売上データ"("ID") ON DELETE CASCADE,
1733
+ CONSTRAINT "fk_売上明細_受注明細"
1734
+ FOREIGN KEY ("受注明細ID") REFERENCES "受注明細"("ID"),
1735
+ CONSTRAINT "fk_売上明細_出荷明細"
1736
+ FOREIGN KEY ("出荷明細ID") REFERENCES "出荷明細"("ID"),
1737
+ CONSTRAINT "fk_売上明細_商品"
1738
+ FOREIGN KEY ("商品コード") REFERENCES "商品マスタ"("商品コード"),
1739
+ CONSTRAINT "uq_売上明細_行番号" UNIQUE ("売上ID", "行番号")
1740
+ );
1741
+
1742
+ -- インデックス
1743
+ CREATE INDEX "idx_売上データ_受注ID" ON "売上データ"("受注ID");
1744
+ CREATE INDEX "idx_売上データ_出荷ID" ON "売上データ"("出荷ID");
1745
+ CREATE INDEX "idx_売上データ_顧客コード" ON "売上データ"("顧客コード");
1746
+ CREATE INDEX "idx_売上データ_売上日" ON "売上データ"("売上日");
1747
+ CREATE INDEX "idx_売上データ_ステータス" ON "売上データ"("ステータス");
1748
+ ```
1749
+
1750
+ </details>
1751
+
1752
+ ### 売上ステータス Enum
1753
+
1754
+ <details>
1755
+ <summary>SalesStatus.java</summary>
1756
+
1757
+ ```java
1758
+ // src/main/java/com/example/sms/domain/model/sales/SalesStatus.java
1759
+ package com.example.sms.domain.model.sales;
1760
+
1761
+ import lombok.Getter;
1762
+ import lombok.RequiredArgsConstructor;
1763
+
1764
+ @Getter
1765
+ @RequiredArgsConstructor
1766
+ public enum SalesStatus {
1767
+ RECORDED("計上済"),
1768
+ INVOICED("請求済"),
1769
+ COLLECTED("入金済"),
1770
+ CANCELLED("キャンセル");
1771
+
1772
+ private final String displayName;
1773
+
1774
+ public static SalesStatus fromDisplayName(String displayName) {
1775
+ for (SalesStatus status : values()) {
1776
+ if (status.displayName.equals(displayName)) {
1777
+ return status;
1778
+ }
1779
+ }
1780
+ throw new IllegalArgumentException("Unknown sales status: " + displayName);
1781
+ }
1782
+ }
1783
+ ```
1784
+
1785
+ </details>
1786
+
1787
+ ### 売上エンティティ
1788
+
1789
+ <details>
1790
+ <summary>Sales.java</summary>
1791
+
1792
+ ```java
1793
+ // src/main/java/com/example/sms/domain/model/sales/Sales.java
1794
+ package com.example.sms.domain.model.sales;
1795
+
1796
+ import lombok.AllArgsConstructor;
1797
+ import lombok.Builder;
1798
+ import lombok.Data;
1799
+ import lombok.NoArgsConstructor;
1800
+
1801
+ import java.math.BigDecimal;
1802
+ import java.time.LocalDate;
1803
+ import java.time.LocalDateTime;
1804
+ import java.util.ArrayList;
1805
+ import java.util.List;
1806
+
1807
+ @Data
1808
+ @Builder
1809
+ @NoArgsConstructor
1810
+ @AllArgsConstructor
1811
+ @SuppressWarnings("PMD.RedundantFieldInitializer")
1812
+ public class Sales {
1813
+ private Integer id;
1814
+ private String salesNumber;
1815
+ private LocalDate salesDate;
1816
+ private Integer orderId;
1817
+ private Integer shipmentId;
1818
+ private String customerCode;
1819
+ private String customerBranchNumber;
1820
+ private String representativeCode;
1821
+ @Builder.Default
1822
+ private BigDecimal salesAmount = BigDecimal.ZERO;
1823
+ @Builder.Default
1824
+ private BigDecimal taxAmount = BigDecimal.ZERO;
1825
+ @Builder.Default
1826
+ private BigDecimal totalAmount = BigDecimal.ZERO;
1827
+ @Builder.Default
1828
+ private SalesStatus status = SalesStatus.RECORDED;
1829
+ private Integer invoiceId;
1830
+ private String remarks;
1831
+ private LocalDateTime createdAt;
1832
+ private String createdBy;
1833
+ private LocalDateTime updatedAt;
1834
+ private String updatedBy;
1835
+
1836
+ @Builder.Default
1837
+ private List<SalesDetail> details = new ArrayList<>();
1838
+ }
1839
+ ```
1840
+
1841
+ </details>
1842
+
1843
+ <details>
1844
+ <summary>SalesDetail.java</summary>
1845
+
1846
+ ```java
1847
+ // src/main/java/com/example/sms/domain/model/sales/SalesDetail.java
1848
+ package com.example.sms.domain.model.sales;
1849
+
1850
+ import com.example.sms.domain.model.product.TaxCategory;
1851
+ import lombok.AllArgsConstructor;
1852
+ import lombok.Builder;
1853
+ import lombok.Data;
1854
+ import lombok.NoArgsConstructor;
1855
+
1856
+ import java.math.BigDecimal;
1857
+
1858
+ @Data
1859
+ @Builder
1860
+ @NoArgsConstructor
1861
+ @AllArgsConstructor
1862
+ public class SalesDetail {
1863
+ private Integer id;
1864
+ private Integer salesId;
1865
+ private Integer lineNumber;
1866
+ private Integer orderDetailId;
1867
+ private Integer shipmentDetailId;
1868
+ private String productCode;
1869
+ private String productName;
1870
+ private BigDecimal salesQuantity;
1871
+ private String unit;
1872
+ private BigDecimal unitPrice;
1873
+ private BigDecimal amount;
1874
+ private TaxCategory taxCategory;
1875
+ private BigDecimal taxRate;
1876
+ private BigDecimal taxAmount;
1877
+ private String remarks;
1878
+ }
1879
+ ```
1880
+
1881
+ </details>
1882
+
1883
+ ### 返品処理の設計
1884
+
1885
+ 返品処理は、売上データに対してマイナスの取引を記録する処理です。
1886
+
1887
+ #### 返品業務の流れ
1888
+
1889
+ ```plantuml
1890
+ @startuml
1891
+
1892
+ title 返品業務フロー
1893
+
1894
+ |営業担当|
1895
+ start
1896
+ :返品依頼受付;
1897
+ note right
1898
+ 顧客からの返品依頼
1899
+ 理由の確認
1900
+ end note
1901
+
1902
+ :返品データ登録;
1903
+
1904
+ |倉庫担当|
1905
+ :返品商品受入;
1906
+ :検品;
1907
+
1908
+ if (商品状態OK?) then (yes)
1909
+ :在庫戻入;
1910
+ else (no)
1911
+ :廃棄処理;
1912
+ endif
1913
+
1914
+ |経理担当|
1915
+ :売上取消処理;
1916
+ note right
1917
+ 返品金額分の
1918
+ 売上をマイナス計上
1919
+ end note
1920
+
1921
+ stop
1922
+
1923
+ @enduml
1924
+ ```
1925
+
1926
+ ### マイグレーション:返品関連テーブルの作成
1927
+
1928
+ <details>
1929
+ <summary>V009__create_return_tables.sql</summary>
1930
+
1931
+ ```sql
1932
+ -- src/main/resources/db/migration/V009__create_return_tables.sql
1933
+
1934
+ -- 返品データ
1935
+ CREATE TABLE "返品データ" (
1936
+ "ID" SERIAL PRIMARY KEY,
1937
+ "返品番号" VARCHAR(20) UNIQUE NOT NULL,
1938
+ "返品日" DATE NOT NULL,
1939
+ "売上ID" INTEGER NOT NULL,
1940
+ "顧客コード" VARCHAR(20) NOT NULL,
1941
+ "返品理由" VARCHAR(200),
1942
+ "返品金額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1943
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1944
+ "返品合計" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1945
+ "倉庫コード" VARCHAR(20),
1946
+ "備考" TEXT,
1947
+ "作成日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1948
+ "作成者" VARCHAR(50),
1949
+ "更新日時" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
1950
+ "更新者" VARCHAR(50),
1951
+ CONSTRAINT "fk_返品データ_売上"
1952
+ FOREIGN KEY ("売上ID") REFERENCES "売上データ"("ID"),
1953
+ CONSTRAINT "fk_返品データ_顧客"
1954
+ FOREIGN KEY ("顧客コード") REFERENCES "顧客マスタ"("顧客コード")
1955
+ );
1956
+
1957
+ -- 返品明細
1958
+ CREATE TABLE "返品明細" (
1959
+ "ID" SERIAL PRIMARY KEY,
1960
+ "返品ID" INTEGER NOT NULL,
1961
+ "行番号" INTEGER NOT NULL,
1962
+ "売上明細ID" INTEGER NOT NULL,
1963
+ "商品コード" VARCHAR(20) NOT NULL,
1964
+ "商品名" VARCHAR(100) NOT NULL,
1965
+ "返品数量" DECIMAL(15, 2) NOT NULL,
1966
+ "単位" VARCHAR(10),
1967
+ "単価" DECIMAL(15, 2) NOT NULL,
1968
+ "金額" DECIMAL(15, 2) NOT NULL,
1969
+ "税区分" 税区分 DEFAULT '外税' NOT NULL,
1970
+ "消費税率" DECIMAL(5, 2) DEFAULT 10.00 NOT NULL,
1971
+ "消費税額" DECIMAL(15, 2) DEFAULT 0 NOT NULL,
1972
+ "備考" TEXT,
1973
+ CONSTRAINT "fk_返品明細_返品"
1974
+ FOREIGN KEY ("返品ID") REFERENCES "返品データ"("ID") ON DELETE CASCADE,
1975
+ CONSTRAINT "fk_返品明細_売上明細"
1976
+ FOREIGN KEY ("売上明細ID") REFERENCES "売上明細"("ID"),
1977
+ CONSTRAINT "fk_返品明細_商品"
1978
+ FOREIGN KEY ("商品コード") REFERENCES "商品マスタ"("商品コード"),
1979
+ CONSTRAINT "uq_返品明細_行番号" UNIQUE ("返品ID", "行番号")
1980
+ );
1981
+
1982
+ -- インデックス
1983
+ CREATE INDEX "idx_返品データ_売上ID" ON "返品データ"("売上ID");
1984
+ CREATE INDEX "idx_返品データ_返品日" ON "返品データ"("返品日");
1985
+ ```
1986
+
1987
+ </details>
1988
+
1989
+ ### 赤黒処理の考え方
1990
+
1991
+ 赤黒処理とは、誤った伝票を訂正するための会計処理手法です。
1992
+
1993
+ ```plantuml
1994
+ @startuml
1995
+
1996
+ title 赤黒処理の流れ
1997
+
1998
+ participant "元伝票" as original
1999
+ participant "赤伝票" as red
2000
+ participant "黒伝票" as black
2001
+
2002
+ note over original
2003
+ 誤った内容の伝票
2004
+ 金額: +10,000円
2005
+ end note
2006
+
2007
+ original -> red : 取消処理
2008
+ note over red
2009
+ 赤伝票(マイナス)
2010
+ 金額: -10,000円
2011
+ end note
2012
+
2013
+ red -> black : 訂正処理
2014
+ note over black
2015
+ 黒伝票(正しい内容)
2016
+ 金額: +12,000円
2017
+ end note
2018
+
2019
+ note over original, black
2020
+ 最終結果:
2021
+ 10,000 - 10,000 + 12,000 = 12,000円
2022
+ end note
2023
+
2024
+ @enduml
2025
+ ```
2026
+
2027
+ #### 赤黒処理の特徴
2028
+
2029
+ | 項目 | 説明 |
2030
+ |-----|------|
2031
+ | **元伝票** | 訂正対象となる誤った伝票 |
2032
+ | **赤伝票** | 元伝票をマイナスで打ち消す伝票 |
2033
+ | **黒伝票** | 正しい内容を記載した新しい伝票 |
2034
+
2035
+ #### 赤黒処理のメリット
2036
+
2037
+ - **監査証跡の保持**: 元の伝票を削除せず、訂正の履歴が残る
2038
+ - **会計基準への準拠**: 伝票の修正ではなく、新規伝票で訂正
2039
+ - **トレーサビリティ**: いつ、誰が、なぜ訂正したかを追跡可能
2040
+
2041
+ ---
2042
+
2043
+ ## 6.4 全体の ER 図
2044
+
2045
+ 受注から売上までの全体的な関連を示します。
2046
+
2047
+ ```plantuml
2048
+ @startuml
2049
+
2050
+ title 受注・出荷・売上の全体ER図
2051
+
2052
+ entity 顧客マスタ {
2053
+ 顧客コード <<PK>>
2054
+ --
2055
+ 顧客名
2056
+ ...
2057
+ }
2058
+
2059
+ entity 商品マスタ {
2060
+ 商品コード <<PK>>
2061
+ --
2062
+ 商品名
2063
+ 単価
2064
+ ...
2065
+ }
2066
+
2067
+ entity 見積データ {
2068
+ ID <<PK>>
2069
+ 見積番号 <<UK>>
2070
+ --
2071
+ 見積日
2072
+ 顧客コード <<FK>>
2073
+ ステータス
2074
+ ...
2075
+ }
2076
+
2077
+ entity 見積明細 {
2078
+ ID <<PK>>
2079
+ --
2080
+ 見積ID <<FK>>
2081
+ 商品コード <<FK>>
2082
+ 数量
2083
+ 単価
2084
+ ...
2085
+ }
2086
+
2087
+ entity 受注データ {
2088
+ ID <<PK>>
2089
+ 受注番号 <<UK>>
2090
+ --
2091
+ 受注日
2092
+ 顧客コード <<FK>>
2093
+ 見積ID <<FK>>
2094
+ ステータス
2095
+ ...
2096
+ }
2097
+
2098
+ entity 受注明細 {
2099
+ ID <<PK>>
2100
+ --
2101
+ 受注ID <<FK>>
2102
+ 商品コード <<FK>>
2103
+ 受注数量
2104
+ 引当数量
2105
+ 出荷数量
2106
+ 残数量
2107
+ ...
2108
+ }
2109
+
2110
+ entity 出荷指示データ {
2111
+ ID <<PK>>
2112
+ 出荷指示番号 <<UK>>
2113
+ --
2114
+ 受注ID <<FK>>
2115
+ 顧客コード <<FK>>
2116
+ ステータス
2117
+ ...
2118
+ }
2119
+
2120
+ entity 出荷指示明細 {
2121
+ ID <<PK>>
2122
+ --
2123
+ 出荷指示ID <<FK>>
2124
+ 受注明細ID <<FK>>
2125
+ 商品コード <<FK>>
2126
+ 指示数量
2127
+ 出荷数量
2128
+ ...
2129
+ }
2130
+
2131
+ entity 売上データ {
2132
+ ID <<PK>>
2133
+ 売上番号 <<UK>>
2134
+ --
2135
+ 受注ID <<FK>>
2136
+ 出荷指示ID <<FK>>
2137
+ 顧客コード <<FK>>
2138
+ ステータス
2139
+ ...
2140
+ }
2141
+
2142
+ entity 売上明細 {
2143
+ ID <<PK>>
2144
+ --
2145
+ 売上ID <<FK>>
2146
+ 受注明細ID <<FK>>
2147
+ 商品コード <<FK>>
2148
+ 売上数量
2149
+ 原価
2150
+ 粗利
2151
+ ...
2152
+ }
2153
+
2154
+ entity 返品データ {
2155
+ ID <<PK>>
2156
+ 返品番号 <<UK>>
2157
+ --
2158
+ 売上ID <<FK>>
2159
+ 顧客コード <<FK>>
2160
+ 返品理由
2161
+ ...
2162
+ }
2163
+
2164
+ entity 返品明細 {
2165
+ ID <<PK>>
2166
+ --
2167
+ 返品ID <<FK>>
2168
+ 売上明細ID <<FK>>
2169
+ 商品コード <<FK>>
2170
+ 返品数量
2171
+ ...
2172
+ }
2173
+
2174
+ 顧客マスタ ||--o{ 見積データ
2175
+ 顧客マスタ ||--o{ 受注データ
2176
+ 顧客マスタ ||--o{ 出荷指示データ
2177
+ 顧客マスタ ||--o{ 売上データ
2178
+ 顧客マスタ ||--o{ 返品データ
2179
+
2180
+ 商品マスタ ||--o{ 見積明細
2181
+ 商品マスタ ||--o{ 受注明細
2182
+ 商品マスタ ||--o{ 出荷指示明細
2183
+ 商品マスタ ||--o{ 売上明細
2184
+ 商品マスタ ||--o{ 返品明細
2185
+
2186
+ 見積データ ||--o{ 見積明細
2187
+ 見積データ ||--o| 受注データ : "受注化"
2188
+
2189
+ 受注データ ||--o{ 受注明細
2190
+ 受注データ ||--o{ 出荷指示データ
2191
+ 受注データ ||--o{ 売上データ
2192
+
2193
+ 受注明細 ||--o{ 出荷指示明細
2194
+ 受注明細 ||--o{ 売上明細
2195
+
2196
+ 出荷指示データ ||--o{ 出荷指示明細
2197
+ 出荷指示データ ||--o| 売上データ
2198
+
2199
+ 売上データ ||--o{ 売上明細
2200
+ 売上データ ||--o{ 返品データ
2201
+
2202
+ 売上明細 ||--o{ 返品明細
2203
+
2204
+ 返品データ ||--o{ 返品明細
2205
+
2206
+ @enduml
2207
+ ```
2208
+
2209
+ ---
2210
+
2211
+ ## 6.5 リレーションと楽観ロックの設計
2212
+
2213
+ ### MyBatis ネストした ResultMap によるリレーション設定
2214
+
2215
+ 受注データは、受注(ヘッダ)→ 受注明細の2層構造を持ちます。MyBatis でこの親子関係を効率的に取得するためのリレーション設定を実装します。
2216
+
2217
+ #### ネストした ResultMap の定義
2218
+
2219
+ <details>
2220
+ <summary>SalesOrderMapper.xml(リレーション設定)</summary>
2221
+
2222
+ ```xml
2223
+ <?xml version="1.0" encoding="UTF-8" ?>
2224
+ <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
2225
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
2226
+
2227
+ <!-- src/main/resources/mapper/SalesOrderMapper.xml -->
2228
+ <mapper namespace="com.example.sms.infrastructure.persistence.mapper.SalesOrderMapper">
2229
+
2230
+ <!-- 受注(ヘッダ)の ResultMap -->
2231
+ <resultMap id="salesOrderWithDetailsResultMap" type="com.example.sms.domain.model.sales.SalesOrder">
2232
+ <id property="id" column="o_id"/>
2233
+ <result property="orderNumber" column="o_受注番号"/>
2234
+ <result property="orderDate" column="o_受注日"/>
2235
+ <result property="customerCode" column="o_顧客コード"/>
2236
+ <result property="shipToCode" column="o_出荷先コード"/>
2237
+ <result property="salesRepCode" column="o_担当者コード"/>
2238
+ <result property="requestedDeliveryDate" column="o_希望納期"/>
2239
+ <result property="scheduledShipDate" column="o_出荷予定日"/>
2240
+ <result property="subtotal" column="o_受注金額"/>
2241
+ <result property="taxAmount" column="o_消費税額"/>
2242
+ <result property="totalAmount" column="o_受注合計"/>
2243
+ <result property="status" column="o_ステータス"
2244
+ typeHandler="com.example.sms.infrastructure.out.persistence.typehandler.OrderStatusTypeHandler"/>
2245
+ <result property="version" column="o_バージョン"/>
2246
+ <result property="createdAt" column="o_作成日時"/>
2247
+ <result property="updatedAt" column="o_更新日時"/>
2248
+ <!-- 受注明細との1:N関連 -->
2249
+ <collection property="details" ofType="com.example.sms.domain.model.sales.SalesOrderDetail"
2250
+ resultMap="salesOrderDetailNestedResultMap"/>
2251
+ </resultMap>
2252
+
2253
+ <!-- 受注明細のネスト ResultMap -->
2254
+ <resultMap id="salesOrderDetailNestedResultMap" type="com.example.sms.domain.model.sales.SalesOrderDetail">
2255
+ <id property="id" column="d_id"/>
2256
+ <result property="orderId" column="d_受注ID"/>
2257
+ <result property="lineNumber" column="d_行番号"/>
2258
+ <result property="productCode" column="d_商品コード"/>
2259
+ <result property="productName" column="d_商品名"/>
2260
+ <result property="orderQuantity" column="d_受注数量"/>
2261
+ <result property="allocatedQuantity" column="d_引当数量"/>
2262
+ <result property="shippedQuantity" column="d_出荷数量"/>
2263
+ <result property="remainingQuantity" column="d_残数量"/>
2264
+ <result property="unit" column="d_単位"/>
2265
+ <result property="unitPrice" column="d_単価"/>
2266
+ <result property="amount" column="d_金額"/>
2267
+ <result property="taxCategory" column="d_税区分"
2268
+ typeHandler="com.example.sms.infrastructure.out.persistence.typehandler.TaxCategoryTypeHandler"/>
2269
+ <result property="taxRate" column="d_消費税率"/>
2270
+ <result property="taxAmount" column="d_消費税額"/>
2271
+ <result property="version" column="d_バージョン"/>
2272
+ </resultMap>
2273
+
2274
+ <!-- JOIN による一括取得クエリ -->
2275
+ <select id="findWithDetailsByOrderNumber" resultMap="salesOrderWithDetailsResultMap">
2276
+ SELECT
2277
+ o."ID" AS o_id,
2278
+ o."受注番号" AS o_受注番号,
2279
+ o."受注日" AS o_受注日,
2280
+ o."顧客コード" AS o_顧客コード,
2281
+ o."出荷先コード" AS o_出荷先コード,
2282
+ o."担当者コード" AS o_担当者コード,
2283
+ o."希望納期" AS o_希望納期,
2284
+ o."出荷予定日" AS o_出荷予定日,
2285
+ o."受注金額" AS o_受注金額,
2286
+ o."消費税額" AS o_消費税額,
2287
+ o."受注合計" AS o_受注合計,
2288
+ o."ステータス" AS o_ステータス,
2289
+ o."バージョン" AS o_バージョン,
2290
+ o."作成日時" AS o_作成日時,
2291
+ o."更新日時" AS o_更新日時,
2292
+ d."ID" AS d_id,
2293
+ d."受注ID" AS d_受注ID,
2294
+ d."行番号" AS d_行番号,
2295
+ d."商品コード" AS d_商品コード,
2296
+ d."商品名" AS d_商品名,
2297
+ d."受注数量" AS d_受注数量,
2298
+ d."引当数量" AS d_引当数量,
2299
+ d."出荷数量" AS d_出荷数量,
2300
+ d."残数量" AS d_残数量,
2301
+ d."単位" AS d_単位,
2302
+ d."単価" AS d_単価,
2303
+ d."金額" AS d_金額,
2304
+ d."税区分" AS d_税区分,
2305
+ d."消費税率" AS d_消費税率,
2306
+ d."消費税額" AS d_消費税額,
2307
+ d."バージョン" AS d_バージョン
2308
+ FROM "受注データ" o
2309
+ LEFT JOIN "受注明細" d
2310
+ ON o."ID" = d."受注ID"
2311
+ WHERE o."受注番号" = #{orderNumber}
2312
+ ORDER BY d."行番号"
2313
+ </select>
2314
+
2315
+ </mapper>
2316
+ ```
2317
+
2318
+ </details>
2319
+
2320
+ #### リレーション設定のポイント
2321
+
2322
+ | 設定項目 | 説明 |
2323
+ |---------|------|
2324
+ | `<collection>` | 1:N 関連のマッピング |
2325
+ | `<id>` | 主キーの識別(MyBatis が重複排除に使用) |
2326
+ | `resultMap` | ネストした ResultMap の参照 |
2327
+ | エイリアス(AS) | カラム名の重複を避けるためのプレフィックス |
2328
+ | `ORDER BY` | コレクションの順序を保証 |
2329
+
2330
+ ### 楽観ロックの実装
2331
+
2332
+ 複数ユーザーが同時に受注データを編集する場合、データの整合性を保つために楽観ロック(Optimistic Locking)を実装します。
2333
+
2334
+ #### Flyway マイグレーション: バージョンカラム追加
2335
+
2336
+ <details>
2337
+ <summary>V010__add_version_columns.sql</summary>
2338
+
2339
+ ```sql
2340
+ -- src/main/resources/db/migration/V010__add_version_columns.sql
2341
+
2342
+ -- 受注データテーブルにバージョンカラムを追加
2343
+ ALTER TABLE "受注データ" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2344
+
2345
+ -- 受注明細テーブルにバージョンカラムを追加
2346
+ ALTER TABLE "受注明細" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2347
+
2348
+ -- 出荷指示データテーブルにバージョンカラムを追加
2349
+ ALTER TABLE "出荷指示データ" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2350
+
2351
+ -- 出荷指示明細テーブルにバージョンカラムを追加
2352
+ ALTER TABLE "出荷指示明細" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2353
+
2354
+ -- 売上データテーブルにバージョンカラムを追加
2355
+ ALTER TABLE "売上データ" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2356
+
2357
+ -- 売上明細テーブルにバージョンカラムを追加
2358
+ ALTER TABLE "売上明細" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2359
+
2360
+ -- 返品データテーブルにバージョンカラムを追加
2361
+ ALTER TABLE "返品データ" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2362
+
2363
+ -- 返品明細テーブルにバージョンカラムを追加
2364
+ ALTER TABLE "返品明細" ADD COLUMN "バージョン" INTEGER DEFAULT 1 NOT NULL;
2365
+
2366
+ -- コメント追加
2367
+ COMMENT ON COLUMN "受注データ"."バージョン" IS '楽観ロック用バージョン番号';
2368
+ COMMENT ON COLUMN "受注明細"."バージョン" IS '楽観ロック用バージョン番号';
2369
+ COMMENT ON COLUMN "出荷指示データ"."バージョン" IS '楽観ロック用バージョン番号';
2370
+ COMMENT ON COLUMN "出荷指示明細"."バージョン" IS '楽観ロック用バージョン番号';
2371
+ COMMENT ON COLUMN "売上データ"."バージョン" IS '楽観ロック用バージョン番号';
2372
+ COMMENT ON COLUMN "売上明細"."バージョン" IS '楽観ロック用バージョン番号';
2373
+ COMMENT ON COLUMN "返品データ"."バージョン" IS '楽観ロック用バージョン番号';
2374
+ COMMENT ON COLUMN "返品明細"."バージョン" IS '楽観ロック用バージョン番号';
2375
+ ```
2376
+
2377
+ </details>
2378
+
2379
+ #### エンティティへのバージョンフィールド追加
2380
+
2381
+ <details>
2382
+ <summary>SalesOrder.java(バージョンフィールド追加)</summary>
2383
+
2384
+ ```java
2385
+ // src/main/java/com/example/sms/domain/model/sales/SalesOrder.java
2386
+ package com.example.sms.domain.model.sales;
2387
+
2388
+ import lombok.AllArgsConstructor;
2389
+ import lombok.Builder;
2390
+ import lombok.Data;
2391
+ import lombok.NoArgsConstructor;
2392
+
2393
+ import java.math.BigDecimal;
2394
+ import java.time.LocalDate;
2395
+ import java.time.LocalDateTime;
2396
+ import java.util.ArrayList;
2397
+ import java.util.List;
2398
+
2399
+ @Data
2400
+ @Builder
2401
+ @NoArgsConstructor
2402
+ @AllArgsConstructor
2403
+ public class SalesOrder {
2404
+ private Integer id;
2405
+ private String orderNumber;
2406
+ private LocalDate orderDate;
2407
+ private String customerCode;
2408
+ private String shipToCode;
2409
+ private String salesRepCode;
2410
+ private LocalDate requestedDeliveryDate;
2411
+ private LocalDate scheduledShipDate;
2412
+ private BigDecimal subtotal;
2413
+ private BigDecimal taxAmount;
2414
+ private BigDecimal totalAmount;
2415
+ private OrderStatus status;
2416
+ private Integer quotationId;
2417
+ private String customerOrderNumber;
2418
+ private String remarks;
2419
+ private LocalDateTime createdAt;
2420
+ private String createdBy;
2421
+ private LocalDateTime updatedAt;
2422
+ private String updatedBy;
2423
+
2424
+ // 楽観ロック用バージョン
2425
+ @Builder.Default
2426
+ private Integer version = 1;
2427
+
2428
+ // リレーション
2429
+ @Builder.Default
2430
+ private List<SalesOrderDetail> details = new ArrayList<>();
2431
+ }
2432
+ ```
2433
+
2434
+ </details>
2435
+
2436
+ #### MyBatis Mapper: 楽観ロック対応の更新
2437
+
2438
+ <details>
2439
+ <summary>SalesOrderMapper.xml(楽観ロック対応 UPDATE)</summary>
2440
+
2441
+ ```xml
2442
+ <!-- 楽観ロック対応の更新(バージョンチェック付き) -->
2443
+ <update id="updateWithOptimisticLock" parameterType="com.example.sms.domain.model.sales.SalesOrder">
2444
+ UPDATE "受注データ"
2445
+ SET
2446
+ "受注日" = #{orderDate},
2447
+ "顧客コード" = #{customerCode},
2448
+ "出荷先コード" = #{shipToCode},
2449
+ "担当者コード" = #{salesRepCode},
2450
+ "希望納期" = #{requestedDeliveryDate},
2451
+ "出荷予定日" = #{scheduledShipDate},
2452
+ "受注金額" = #{subtotal},
2453
+ "消費税額" = #{taxAmount},
2454
+ "受注合計" = #{totalAmount},
2455
+ "ステータス" = #{status, typeHandler=com.example.sms.infrastructure.out.persistence.typehandler.OrderStatusTypeHandler},
2456
+ "更新日時" = CURRENT_TIMESTAMP,
2457
+ "バージョン" = "バージョン" + 1
2458
+ WHERE "ID" = #{id}
2459
+ AND "バージョン" = #{version}
2460
+ </update>
2461
+ ```
2462
+
2463
+ </details>
2464
+
2465
+ #### 楽観ロック例外クラス
2466
+
2467
+ <details>
2468
+ <summary>OptimisticLockException.java</summary>
2469
+
2470
+ ```java
2471
+ // src/main/java/com/example/sms/domain/exception/OptimisticLockException.java
2472
+ package com.example.sms.domain.exception;
2473
+
2474
+ public class OptimisticLockException extends RuntimeException {
2475
+
2476
+ private final String entityName;
2477
+ private final Integer entityId;
2478
+ private final Integer expectedVersion;
2479
+ private final Integer actualVersion;
2480
+
2481
+ public OptimisticLockException(String entityName, Integer entityId) {
2482
+ super(String.format("%s (ID: %d) は既に削除されています", entityName, entityId));
2483
+ this.entityName = entityName;
2484
+ this.entityId = entityId;
2485
+ this.expectedVersion = null;
2486
+ this.actualVersion = null;
2487
+ }
2488
+
2489
+ public OptimisticLockException(String entityName, Integer entityId,
2490
+ Integer expectedVersion, Integer actualVersion) {
2491
+ super(String.format("%s (ID: %d) は他のユーザーによって更新されています。" +
2492
+ "期待バージョン: %d, 実際のバージョン: %d",
2493
+ entityName, entityId, expectedVersion, actualVersion));
2494
+ this.entityName = entityName;
2495
+ this.entityId = entityId;
2496
+ this.expectedVersion = expectedVersion;
2497
+ this.actualVersion = actualVersion;
2498
+ }
2499
+
2500
+ public String getEntityName() {
2501
+ return entityName;
2502
+ }
2503
+
2504
+ public Integer getEntityId() {
2505
+ return entityId;
2506
+ }
2507
+
2508
+ public Integer getExpectedVersion() {
2509
+ return expectedVersion;
2510
+ }
2511
+
2512
+ public Integer getActualVersion() {
2513
+ return actualVersion;
2514
+ }
2515
+ }
2516
+ ```
2517
+
2518
+ </details>
2519
+
2520
+ #### Repository 実装: 楽観ロック対応
2521
+
2522
+ <details>
2523
+ <summary>SalesOrderRepositoryImpl.java(楽観ロック対応)</summary>
2524
+
2525
+ ```java
2526
+ // src/main/java/com/example/sms/infrastructure/persistence/repository/SalesOrderRepositoryImpl.java
2527
+ package com.example.sms.infrastructure.persistence.repository;
2528
+
2529
+ import com.example.sms.application.port.out.SalesOrderRepository;
2530
+ import com.example.sms.domain.exception.OptimisticLockException;
2531
+ import com.example.sms.domain.model.sales.OrderStatus;
2532
+ import com.example.sms.domain.model.sales.SalesOrder;
2533
+ import com.example.sms.domain.model.sales.SalesOrderDetail;
2534
+ import com.example.sms.infrastructure.persistence.mapper.SalesOrderMapper;
2535
+ import lombok.RequiredArgsConstructor;
2536
+ import org.springframework.stereotype.Repository;
2537
+ import org.springframework.transaction.annotation.Transactional;
2538
+
2539
+ import java.math.BigDecimal;
2540
+ import java.time.LocalDate;
2541
+ import java.util.List;
2542
+ import java.util.Optional;
2543
+
2544
+ @Repository
2545
+ @RequiredArgsConstructor
2546
+ public class SalesOrderRepositoryImpl implements SalesOrderRepository {
2547
+
2548
+ private final SalesOrderMapper mapper;
2549
+
2550
+ @Override
2551
+ @Transactional
2552
+ public void update(SalesOrder order) {
2553
+ int updatedCount = mapper.updateWithOptimisticLock(order);
2554
+
2555
+ if (updatedCount == 0) {
2556
+ // バージョン不一致または削除済み
2557
+ Integer currentVersion = mapper.findVersionById(order.getId());
2558
+ if (currentVersion == null) {
2559
+ throw new OptimisticLockException("受注", order.getId());
2560
+ } else {
2561
+ throw new OptimisticLockException("受注", order.getId(),
2562
+ order.getVersion(), currentVersion);
2563
+ }
2564
+ }
2565
+ }
2566
+
2567
+ @Override
2568
+ public Optional<SalesOrder> findByIdWithDetails(Integer id) {
2569
+ return Optional.ofNullable(mapper.findByIdWithDetails(id));
2570
+ }
2571
+
2572
+ @Override
2573
+ public Optional<SalesOrder> findByOrderNumber(String orderNumber) {
2574
+ return Optional.ofNullable(mapper.findByOrderNumber(orderNumber));
2575
+ }
2576
+
2577
+ // その他のメソッド...
2578
+ }
2579
+ ```
2580
+
2581
+ </details>
2582
+
2583
+ #### TDD: 楽観ロックのテスト
2584
+
2585
+ <details>
2586
+ <summary>SalesOrderRepositoryOptimisticLockTest.java</summary>
2587
+
2588
+ ```java
2589
+ // src/test/java/com/example/sms/infrastructure/persistence/repository/SalesOrderRepositoryOptimisticLockTest.java
2590
+ package com.example.sms.infrastructure.persistence.repository;
2591
+
2592
+ import com.example.sms.application.port.out.SalesOrderRepository;
2593
+ import com.example.sms.domain.exception.OptimisticLockException;
2594
+ import com.example.sms.domain.model.sales.OrderStatus;
2595
+ import com.example.sms.domain.model.sales.SalesOrder;
2596
+ import com.example.sms.testsetup.BaseIntegrationTest;
2597
+ import org.junit.jupiter.api.*;
2598
+ import org.springframework.beans.factory.annotation.Autowired;
2599
+
2600
+ import java.math.BigDecimal;
2601
+ import java.time.LocalDate;
2602
+
2603
+ import static org.assertj.core.api.Assertions.*;
2604
+
2605
+ @DisplayName("受注リポジトリ - 楽観ロック")
2606
+ class SalesOrderRepositoryOptimisticLockTest extends BaseIntegrationTest {
2607
+
2608
+ @Autowired
2609
+ private SalesOrderRepository salesOrderRepository;
2610
+
2611
+ @BeforeEach
2612
+ void setUp() {
2613
+ salesOrderRepository.deleteAll();
2614
+ }
2615
+
2616
+ @Nested
2617
+ @DisplayName("楽観ロック")
2618
+ class OptimisticLocking {
2619
+
2620
+ @Test
2621
+ @DisplayName("同じバージョンで更新できる")
2622
+ void canUpdateWithSameVersion() {
2623
+ // Arrange
2624
+ var order = SalesOrder.builder()
2625
+ .orderNumber("SO-2025-0001")
2626
+ .orderDate(LocalDate.of(2025, 1, 20))
2627
+ .customerCode("CUST-001")
2628
+ .subtotal(new BigDecimal("50000"))
2629
+ .taxAmount(new BigDecimal("5000"))
2630
+ .totalAmount(new BigDecimal("55000"))
2631
+ .status(OrderStatus.RECEIVED)
2632
+ .build();
2633
+ salesOrderRepository.save(order);
2634
+
2635
+ // Act
2636
+ var fetched = salesOrderRepository.findByOrderNumber("SO-2025-0001").get();
2637
+ fetched.setSubtotal(new BigDecimal("60000"));
2638
+ salesOrderRepository.update(fetched);
2639
+
2640
+ // Assert
2641
+ var updated = salesOrderRepository.findByOrderNumber("SO-2025-0001").get();
2642
+ assertThat(updated.getSubtotal()).isEqualByComparingTo(new BigDecimal("60000"));
2643
+ assertThat(updated.getVersion()).isEqualTo(2); // バージョンがインクリメント
2644
+ }
2645
+
2646
+ @Test
2647
+ @DisplayName("異なるバージョンで更新すると楽観ロック例外が発生する")
2648
+ void throwsExceptionWhenVersionMismatch() {
2649
+ // Arrange
2650
+ var order = SalesOrder.builder()
2651
+ .orderNumber("SO-2025-0002")
2652
+ .orderDate(LocalDate.of(2025, 1, 20))
2653
+ .customerCode("CUST-001")
2654
+ .subtotal(new BigDecimal("50000"))
2655
+ .taxAmount(new BigDecimal("5000"))
2656
+ .totalAmount(new BigDecimal("55000"))
2657
+ .status(OrderStatus.RECEIVED)
2658
+ .build();
2659
+ salesOrderRepository.save(order);
2660
+
2661
+ // ユーザーAが取得
2662
+ var orderA = salesOrderRepository.findByOrderNumber("SO-2025-0002").get();
2663
+ // ユーザーBが取得
2664
+ var orderB = salesOrderRepository.findByOrderNumber("SO-2025-0002").get();
2665
+
2666
+ // ユーザーAが更新(成功)
2667
+ orderA.setSubtotal(new BigDecimal("60000"));
2668
+ salesOrderRepository.update(orderA);
2669
+
2670
+ // Act & Assert: ユーザーBが古いバージョンで更新(失敗)
2671
+ orderB.setSubtotal(new BigDecimal("70000"));
2672
+ assertThatThrownBy(() -> salesOrderRepository.update(orderB))
2673
+ .isInstanceOf(OptimisticLockException.class)
2674
+ .hasMessageContaining("他のユーザーによって更新されています");
2675
+ }
2676
+ }
2677
+ }
2678
+ ```
2679
+
2680
+ </details>
2681
+
2682
+ #### 楽観ロックのベストプラクティス
2683
+
2684
+ | ポイント | 説明 |
2685
+ |---------|------|
2686
+ | **バージョンカラム** | INTEGER 型で十分(オーバーフローは実用上問題なし) |
2687
+ | **WHERE 条件** | 必ず `AND "バージョン" = #{version}` を含める |
2688
+ | **インクリメント** | `"バージョン" = "バージョン" + 1` でアトミックに更新 |
2689
+ | **例外処理** | 更新件数が0の場合は楽観ロック例外をスロー |
2690
+ | **リトライ** | 必要に応じて再取得・再更新のリトライロジックを実装 |
2691
+
2692
+ ---
2693
+
2694
+ ## まとめ
2695
+
2696
+ 本章では、販売管理システムの中核となるトランザクションデータの設計を行いました。
2697
+
2698
+ ### 設計のポイント
2699
+
2700
+ 1. **ヘッダ・明細構造**: すべてのトランザクションデータは、ヘッダ(親)と明細(子)の1対多構造を持つ
2701
+
2702
+ 2. **ステータス管理**: 各トランザクションは状態遷移を持ち、PostgreSQL の ENUM 型で管理
2703
+
2704
+ 3. **トレーサビリティ**: 見積→受注→出荷→売上の流れを追跡可能な外部キー設計
2705
+
2706
+ 4. **数量管理**: 受注明細では受注数量・引当数量・出荷数量・残数量を分離管理
2707
+
2708
+ 5. **返品・赤黒処理**: 売上の訂正は削除ではなく、返品データによる追記で対応
2709
+
2710
+ 6. **リレーション設定**: MyBatis のネスト ResultMap で親子関係を効率的に取得
2711
+
2712
+ 7. **楽観ロック**: バージョンカラムによる同時更新制御で整合性を保証
2713
+
2714
+ ### 次章の予告
2715
+
2716
+ 第7章では、売上データを起点とした債権管理(請求・入金)の設計を行います。