持続可能なプロダクト設計 ― 販売管理実践(3)与信チェックと受注承認
はじめに
前回は、受注明細の単価を決定するプロセスを設計しました。Config・Extension・ルールエンジンの境界を「変更主体・変更履歴の監査・変更の即時性」の3基準で整理し、PricingStrategy で切り出しました。単価が決まり、line_amount(確定値)が sales_order_line に永続化されています。
受注ステータスはまだ PENDING のままです。「この受注を確定してよいか」の判断がまだ行われていません。
今回は、受注を確定する前に通過すべき2つのプロセスを設計します。
受注ステータスの状態遷移は、コアの責任としてどこまで守り、テナント差分はどこから切り出すか?
第1回はIntegrationとConfigの境界を、第2回はConfigとExtensionとルールエンジンの境界を問いました。第3回はコアとExtensionの境界を問います。
与信チェックと承認の業務
受注確定の前に、2つの関門があります。
与信チェックは、取引先の信用限度額を超えないかを確認するプロセスです。B2B取引では取引先に代金回収リスクがあります。与信限度額(取引先に許容する最大の未回収残高)を設け、それを超えた受注は受け付けない運用が一般的です。
承認は、社内の承認権限者が受注内容を確認し、確定を許可するプロセスです。金額が大きい受注や特定の取引先との取引については、担当者以上の判断を求める運用があります。
2つのプロセスは直列です。与信チェックを通過してから、承認の要否を判定します。与信チェックで弾かれた受注は承認フローに進みません。また、どちらを実行するかがテナントによって異なります。
テナント差分の実態
第1回・第2回と同様に、3テナントの差分を示します。
| A社 | B社 | C社 | |
|---|---|---|---|
| 与信チェック | なし | あり | あり |
| 与信残高の算出対象 | — | APPROVED + 引当(AWAITING_APPROVAL) | APPROVED + SHIPPED + 引当(AWAITING_APPROVAL) |
| 承認 | なし(自動確定) | 金額基準で部長承認 | 金額×取引先ランク×製品カテゴリ |
A社(シンプル):
与信管理なし。取引先への信頼ベースで運用しています。承認プロセスもなく、与信チェックをスキップして自動確定します。
B社(中程度):
信用限度額を設定しています。与信残高は、確定済み受注(APPROVED)の金額合計と、承認待ち受注(AWAITING_APPROVAL)の金額合計の和です。承認待ちの受注も与信枠を消費するとみなし、承認が下りる前から枠を押さえます。受注金額が閾値(例:100万円)を超えた場合、部長承認が必要です。
C社(高い複雑さ):
与信残高にSHIPPED(出荷済み・未回収)も含めます。未回収の出荷済み受注も与信枠を消費しているとみなす運用です。承認条件は金額・取引先ランク・製品カテゴリの組み合わせで決まります。
ありがちな直書き設計
2つの悪い例を示します。
与信チェックの直書き:
// ❌ 直書き:テナントごとに残高計算方法を分岐
public boolean checkCredit(String tenantId,
String customerCode,
BigDecimal orderAmount) {
if ("TENANT_A".equals(tenantId)) {
return true; // 与信チェックなし
} else if ("TENANT_B".equals(tenantId)) {
BigDecimal limit = creditLimitDao.getLimit(tenantId, customerCode);
BigDecimal usage =
salesOrderDao.sumByStatus(tenantId, customerCode, "APPROVED")
.add(salesOrderDao.sumByStatus(
tenantId, customerCode, "AWAITING_APPROVAL"));
return limit.compareTo(usage.add(orderAmount)) >= 0;
} else if ("TENANT_C".equals(tenantId)) {
BigDecimal limit = creditLimitDao.getLimit(tenantId, customerCode);
BigDecimal usage =
salesOrderDao.sumByStatus(tenantId, customerCode, "APPROVED")
.add(salesOrderDao.sumByStatus(tenantId, customerCode, "SHIPPED"))
.add(salesOrderDao.sumByStatus(
tenantId, customerCode, "AWAITING_APPROVAL"));
return limit.compareTo(usage.add(orderAmount)) >= 0;
}
throw new IllegalArgumentException("未対応のテナント: " + tenantId);
}
承認ワークフローの直書き:
// ❌ 直書き:ステータスをフラグで管理
public class SalesOrder {
private boolean approvalRequired;
private boolean approved;
private boolean rejected;
// 3つのフラグの組み合わせで状態を表現
// 承認済み: approved=true, rejected=false
// 却下: approved=false, rejected=true
// 承認待ち: approved=false, rejected=false, approvalRequired=true
// approved=true かつ rejected=true は不正状態だが型で防げない
}
この設計が破綻する理由は3つです。
- 残高計算方法の変更が全テナントのテストに波及します。B社の計算対象ステータスを1つ追加するだけで、テナント判定ロジック全体を触ることになります
- フラグの組み合わせで状態を表現しているため、不正な状態(
approved=trueかつrejected=true等)を型として防げません。状態の整合性がコード全体に散在します - 新しい承認パターンのたびに
if-elseが伸びます。あるテナントの条件変更が、他テナントの判定に影響するリスクがあります
設計判断の展開
ステータス遷移の再設計(コアの責任)
第1回で定義した5ステータス(DRAFT・PENDING・APPROVED・SHIPPED・CLOSED)に、AWAITING_APPROVAL(承認待ち)と REJECTED(却下)を追加します。
DRAFT → PENDING (受注登録完了。単価は第2回で設定済み)
PENDING → APPROVED (与信OK & 承認不要 → 自動確定)
PENDING → AWAITING_APPROVAL (与信OK & 承認要)
AWAITING_APPROVAL → APPROVED (承認 → 引当を確定使用額へ振替)
AWAITING_APPROVAL → REJECTED (却下 → 与信引当を解放)
REJECTED → PENDING (再申請 → 与信・承認のみ再実行)
APPROVED → SHIPPED (出荷)
SHIPPED → CLOSED (完了)
PENDING と AWAITING_APPROVAL の区別を明確にします。
-
PENDING:システムが自動処理する対象。人の判断を待たない状態 -
AWAITING_APPROVAL:人の判断を待つ状態。与信枠の引当が計上されている
REJECTED → PENDING(DRAFTには戻らない)を選んだ理由です。却下は承認レベルの問題であり、受注データ(品目・数量・単価)の問題ではありません。line_amount は確定済みです。受注データを再取り込みする理由がないため、DRAFTには戻しません。与信チェックから再実行(PENDINGから再開)します。
与信引当のライフサイクル:
与信引当は、ステータス自体で管理します。別途引当テーブルは設けません。
-
PENDING → AWAITING_APPROVAL:この受注のline_amountが、以降の与信残高計算のreservedExposure(引当済み使用額)に含まれるようになる -
AWAITING_APPROVAL → APPROVED:reservedExposureからcurrentExposure(確定使用額)へ振替。APPROVED受注として計上される -
AWAITING_APPROVAL → REJECTED:reservedExposureから除外。与信枠が解放される
この仕組みにより、承認待ちで滞留している受注が与信枠を押さえます。滞留中に別の受注が承認されても、引当済み分が枠を消費しているため、限度額超過を構造的に防止できます。
コアの責任
コアが担うのは3つです。
-
状態遷移の整合性:不正な遷移(
APPROVED → PENDING等)を拒否する -
与信枠との比較:Strategyから取得した使用額と限度額を比較する。比較式は
projectedExposure = currentExposure + reservedExposure + currentOrderAmount。CreditCheckStrategyがcurrentExposure・reservedExposure・limitAmountを返し、OrderConfirmationServiceがcurrentOrderAmount(当該受注の金額合計)を加算して比較する -
承認履歴の記録:AWAITING_APPROVAL進入(REQUEST)・承認(APPROVE)・却下(REJECT)の3アクションを
approval_historyに記録する。ルール固有の情報を含まないため、コア層に配置する
「何を与信残高に含めるか」と「与信限度額の取得」はコアの責任ではありません。テナントによって算出方法が異なるため、Extension(CreditCheckStrategy)に委ねます。
Config で扱う部分
-
与信チェックの有無:
strategy_type = 'none'で表現します。is_credit_check_enabledのようなフラグは設けません。strategy_typeの値がそのまま使用するStrategyを指示します。第2回のpricing_config.strategy_typeと同じ設計です -
承認の金額閾値:
threshold_amount。この値を変えても処理の意味は変わりません
Extension で扱う部分
与信残高の算出方法(CreditCheckStrategy):
| テナント | 実装クラス | 確定使用額の対象 |
|---|---|---|
| A社 | NoCreditCheckStrategy |
skipComparison=true を返す。コアは比較処理をスキップする |
| B社 | ApprovedOnlyCreditStrategy |
APPROVED受注の金額合計 |
| C社 | IncludeShippedCreditStrategy |
APPROVED + SHIPPED受注の金額合計 |
A社のスキップ実装をStrategyに載せる理由は、第2回の SimplePriceLookupStrategy と同じです。コアはテナントを意識せず、同じインターフェースを通る設計にします。sentinel値(Long.MAX_VALUE 等)を使って型の境界を壊す設計は避けます。
承認ルート決定ロジック(ApprovalStrategy):
| テナント | 実装クラス | 判定方法 |
|---|---|---|
| A社 | AutoApprovalStrategy |
常に自動承認 |
| B社 | AmountBasedApprovalStrategy |
threshold_amount との比較 |
| C社 | MultiCriteriaApprovalStrategy |
金額×取引先ランク×製品カテゴリ |
ルールエンジン候補(C社の承認条件)
第2回で示した移行判断基準3つを適用します。
| 基準 | C社の承認条件 | 判定 |
|---|---|---|
| 変更主体 | 管理部門が変更する | 該当 |
| 変更履歴の監査 | 内部統制上必要。変更履歴は approval_route_rule_history で管理する |
該当 |
| 変更の即時性 | 四半期に1回。デプロイで対応可能 | 非該当 |
「変更の即時性」が非該当のため、現時点ではExtensionに留めます。
第2回でC社の単価計算が3基準すべてに該当してルールエンジンに移行したのと、同じ判断基準を使って異なる結論になります。3基準は「1つでも該当すれば移行を検討する」閾値であり、基準ごとの重みや業務上のコストも踏まえた総合判断です。
実装
スキーマ変更
第1回で定義した sales_order.status のCHECK制約に、新ステータス2つを追加します。
-- 既存のCHECK制約を削除(制約名は環境に応じて確認する)
ALTER TABLE sales_order DROP CONSTRAINT ck_sales_order_status;
-- 新しいCHECK制約を追加
ALTER TABLE sales_order
ADD CONSTRAINT ck_sales_order_status
CHECK (status IN (
'DRAFT', 'PENDING', 'AWAITING_APPROVAL',
'APPROVED', 'REJECTED', 'SHIPPED', 'CLOSED'
));
OrderStatus(コア)
状態遷移の許可リストをenum内に閉じ込めます。validateTransitionTo が不正な遷移を検知します。
public enum OrderStatus {
DRAFT, PENDING, AWAITING_APPROVAL, APPROVED, REJECTED, SHIPPED, CLOSED;
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED;
static {
Map<OrderStatus, Set<OrderStatus>> m = new EnumMap<>(OrderStatus.class);
m.put(DRAFT, EnumSet.of(PENDING));
m.put(PENDING, EnumSet.of(APPROVED, AWAITING_APPROVAL));
m.put(AWAITING_APPROVAL, EnumSet.of(APPROVED, REJECTED));
m.put(REJECTED, EnumSet.of(PENDING));
m.put(APPROVED, EnumSet.of(SHIPPED));
m.put(SHIPPED, EnumSet.of(CLOSED));
m.put(CLOSED, EnumSet.noneOf(OrderStatus.class));
ALLOWED = Collections.unmodifiableMap(m);
}
public void validateTransitionTo(OrderStatus next) {
if (!ALLOWED.get(this).contains(next)) {
throw new IllegalStateException(
"不正な遷移: " + this + " → " + next);
}
}
}
状態遷移の定義と検証が OrderStatus に集約されます。サービス側が if-else で遷移先を制御する必要はありません。
CreditCheckStrategy と CreditCheckResult
/**
* 与信使用額を算出するStrategy。
* skipComparison=true の場合、コアは限度額との比較をスキップする。
*/
public interface CreditCheckStrategy {
CreditCheckResult calculate(String tenantId, String customerCode);
}
/**
* 与信使用額の算出結果。
* OK/NG判定はコアが行う。Strategyは使用額と限度額の取得に徹する。
*/
public class CreditCheckResult {
private final BigDecimal currentExposure; // 確定使用額
private final BigDecimal reservedExposure; // 引当済み使用額
private final BigDecimal limitAmount; // 与信限度額(skipComparison=trueの場合はnull)
private final boolean skipComparison;
public CreditCheckResult(BigDecimal currentExposure,
BigDecimal reservedExposure,
BigDecimal limitAmount,
boolean skipComparison) {
this.currentExposure = currentExposure;
this.reservedExposure = reservedExposure;
this.limitAmount = limitAmount;
this.skipComparison = skipComparison;
}
// getter 省略
}
limitAmount をStrategyの結果に含める理由です。コア(OrderConfirmationService)が比較ロジックを持ちますが、与信限度額はExtension層のテーブル(credit_limit)にあります。Extension(Strategy)が取得して結果に含めることで、コアがExtension層のDAOに依存せずに済みます。
A社向け:NoCreditCheckStrategy
@ApplicationScoped
@CreditType("none")
public class NoCreditCheckStrategy implements CreditCheckStrategy {
@Override
public CreditCheckResult calculate(String tenantId, String customerCode) {
return new CreditCheckResult(
BigDecimal.ZERO, BigDecimal.ZERO, null, true);
}
}
skipComparison=true を返します。コアはこのフラグを見て比較処理をスキップします。limitAmount=null はスキップ時のみ許容されます。
B社向け:ApprovedOnlyCreditStrategy
@ApplicationScoped
@CreditType("approved-only")
public class ApprovedOnlyCreditStrategy implements CreditCheckStrategy {
@Inject
private OrderRepository orderRepository; // 使用額の集計(コア層のリポジトリ)
@Inject
private CreditLimitDao creditLimitDao; // 与信限度額の取得(Extension層のDAO)
@Override
public CreditCheckResult calculate(String tenantId, String customerCode) {
// まず与信限度額テーブルの取引先行をロックする(UPDLOCK/HOLDLOCK)
// ロック取得後に使用額を集計することで、並行実行時の与信超過を防ぐ
BigDecimal limit = creditLimitDao.getLimit(
tenantId, customerCode, LocalDate.now());
// ロックを保持したまま使用額を集計する
// 確定使用額: APPROVED受注のline_amount合計
BigDecimal currentExposure = orderRepository.sumLineAmount(
tenantId, customerCode,
Collections.singletonList(OrderStatus.APPROVED));
// 引当済み使用額: AWAITING_APPROVAL受注のline_amount合計
BigDecimal reservedExposure = orderRepository.sumLineAmount(
tenantId, customerCode,
Collections.singletonList(OrderStatus.AWAITING_APPROVAL));
return new CreditCheckResult(
currentExposure, reservedExposure, limit, false);
}
}
与信限度額の取得基準日は実行時点(LocalDate.now())です。第2回の単価計算で使った受注日基準とは異なります。与信チェックは「今この受注を受けて大丈夫か」のリアルタイム判断であり、再計算の冪等性よりも現時点の状態を正確に反映することが優先されます。
ApprovalStrategy と ApprovalRoute
/**
* 承認ルートを決定するStrategy。
*/
public interface ApprovalStrategy {
ApprovalRoute determine(String tenantId, SalesOrder order);
}
/**
* 承認ルートを表すクラス。
* 自動承認か、どのロールの承認が必要かを保持する。
*/
public class ApprovalRoute {
public static final ApprovalRoute AUTO = new ApprovalRoute(true, null);
private final boolean autoApprove;
private final String approverRole; // null は自動承認
public ApprovalRoute(boolean autoApprove, String approverRole) {
this.autoApprove = autoApprove;
this.approverRole = approverRole;
}
// getter 省略
}
B社向け:AmountBasedApprovalStrategy
@ApplicationScoped
@ApprovalType("amount-based")
public class AmountBasedApprovalStrategy implements ApprovalStrategy {
@Inject
private ApprovalConfigDao approvalConfigDao;
@Override
public ApprovalRoute determine(String tenantId, SalesOrder order) {
ApprovalConfig config = approvalConfigDao.get(tenantId);
// DDLのCHECK制約で保証されているが、コード側でも確認する
if (config.getThresholdAmount() == null) {
throw new IllegalStateException(
"threshold_amount が未設定です: tenant=" + tenantId);
}
BigDecimal totalAmount = order.getTotalLineAmount();
if (totalAmount.compareTo(config.getThresholdAmount()) > 0) {
return new ApprovalRoute(false, "MANAGER");
}
return ApprovalRoute.AUTO;
}
}
テーブル設計
与信関連(Config・Extension)
-- 与信限度額マスタ(有効期間付き時系列。実行時点で有効な版を使用)
CREATE TABLE credit_limit (
tenant_id VARCHAR(20) NOT NULL,
customer_code VARCHAR(50) NOT NULL,
limit_amount DECIMAL(14,2) NOT NULL
CHECK (limit_amount > 0),
effective_from DATE NOT NULL,
effective_to DATE NULL, -- NULLは無期限
PRIMARY KEY (tenant_id, customer_code, effective_from)
);
-- 与信チェック設定(Config)
CREATE TABLE credit_check_config (
tenant_id VARCHAR(20) PRIMARY KEY,
strategy_type VARCHAR(30) NOT NULL
CHECK (strategy_type IN ('none', 'approved-only', 'include-shipped'))
);
承認関連(Config・コア・Extension)
-- 承認設定(Config)
CREATE TABLE approval_config (
tenant_id VARCHAR(20) PRIMARY KEY,
strategy_type VARCHAR(30) NOT NULL
CHECK (strategy_type IN ('auto', 'amount-based', 'multi-criteria')),
threshold_amount DECIMAL(14,2) NULL, -- amount-based の場合のみ使用
-- amount-based のとき threshold_amount は必須かつ正値
-- それ以外のとき threshold_amount は NULL
CONSTRAINT ck_approval_config_threshold CHECK (
(strategy_type = 'amount-based'
AND threshold_amount IS NOT NULL AND threshold_amount > 0)
OR
(strategy_type != 'amount-based' AND threshold_amount IS NULL)
)
);
-- 承認履歴(コア層)
CREATE TABLE approval_history (
history_id BIGINT IDENTITY(1,1) PRIMARY KEY,
order_id BIGINT NOT NULL,
action VARCHAR(10) NOT NULL
CHECK (action IN ('REQUEST', 'APPROVE', 'REJECT')),
actor VARCHAR(50) NOT NULL,
acted_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
comment NVARCHAR(500) NULL,
CONSTRAINT fk_approval_history_order
FOREIGN KEY (order_id) REFERENCES sales_order(order_id)
);
CREATE INDEX ix_approval_history_order
ON approval_history (order_id, acted_at);
-- C社向け承認ルート定義(Extension)
CREATE TABLE approval_route_rule (
rule_id BIGINT IDENTITY(1,1) PRIMARY KEY,
tenant_id VARCHAR(20) NOT NULL,
amount_from DECIMAL(14,2) NULL,
amount_to DECIMAL(14,2) NULL,
customer_rank VARCHAR(20) NULL,
product_category VARCHAR(50) NULL,
approver_role VARCHAR(50) NOT NULL,
priority INT NOT NULL DEFAULT 0,
effective_from DATE NOT NULL,
effective_to DATE NULL,
enabled BIT NOT NULL DEFAULT 1
);
CREATE INDEX ix_approval_route_rule_tenant
ON approval_route_rule (tenant_id, enabled);
-- C社向け承認ルール変更履歴(Extension)
CREATE TABLE approval_route_rule_history (
history_id BIGINT IDENTITY(1,1) PRIMARY KEY,
rule_id BIGINT NOT NULL,
changed_by VARCHAR(50) NOT NULL,
changed_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
change_type VARCHAR(10) NOT NULL
CHECK (change_type IN ('CREATE', 'UPDATE', 'DISABLE')),
snapshot NVARCHAR(MAX) NOT NULL,
CONSTRAINT fk_approval_route_rule_history
FOREIGN KEY (rule_id) REFERENCES approval_route_rule(rule_id)
);
approval_history はコア層です。承認の事実(誰がいつ何をしたか)を記録しますが、どの条件に基づいて判断したかは含みません。approval_route_rule_history はExtension層です。「どの承認ルールが変わったか」を記録します。この2つの関心の分離は、第2回の変更履歴監査と適用結果監査の分離と同じ構造です。
OrderConfirmationService(コア)
与信チェック・承認ルート決定・ステータス遷移を調整するサービスです。
@ApplicationScoped
public class OrderConfirmationService {
@Inject
private CreditCheckStrategyFactory creditCheckFactory;
@Inject
private ApprovalStrategyFactory approvalFactory;
@Inject
private OrderRepository orderRepository;
@Inject
private ApprovalHistoryDao approvalHistoryDao;
/**
* 受注を確定プロセスに通す。
* 与信OK & 承認不要: PENDING → APPROVED
* 与信OK & 承認要 : PENDING → AWAITING_APPROVAL
*/
@Transactional
public void confirm(String tenantId, SalesOrder order) {
// confirm() は PENDING 受注専用。AWAITING_APPROVAL の確定は approve() で行う
if (order.getStatus() != OrderStatus.PENDING) {
throw new IllegalStateException(
"confirm対象は PENDING のみです: orderId=" + order.getOrderId()
+ ", 現在ステータス=" + order.getStatus());
}
// --- 与信チェック ---
CreditCheckStrategy creditStrategy =
creditCheckFactory.resolve(tenantId);
CreditCheckResult creditResult =
creditStrategy.calculate(tenantId, order.getCustomerCode());
if (!creditResult.isSkipComparison()) {
BigDecimal projectedExposure =
creditResult.getCurrentExposure()
.add(creditResult.getReservedExposure())
.add(order.getTotalLineAmount());
if (projectedExposure.compareTo(creditResult.getLimitAmount()) > 0) {
throw new CreditLimitExceededException(
"与信限度額超過: projected=" + projectedExposure
+ ", limit=" + creditResult.getLimitAmount());
}
}
// --- 承認ルート決定 ---
ApprovalStrategy approvalStrategy =
approvalFactory.resolve(tenantId);
ApprovalRoute route =
approvalStrategy.determine(tenantId, order);
if (route.isAutoApprove()) {
order.getStatus().validateTransitionTo(OrderStatus.APPROVED);
order.setStatus(OrderStatus.APPROVED);
approvalHistoryDao.record(
order.getOrderId(), "SYSTEM", "APPROVE", null);
} else {
order.getStatus().validateTransitionTo(
OrderStatus.AWAITING_APPROVAL);
order.setStatus(OrderStatus.AWAITING_APPROVAL);
approvalHistoryDao.record(
order.getOrderId(), "SYSTEM", "REQUEST", null);
}
orderRepository.updateStatus(order);
}
/** AWAITING_APPROVAL → APPROVED(引当を確定使用額へ振替) */
@Transactional
public void approve(String tenantId, long orderId,
String actor, String comment) {
SalesOrder order = orderRepository.findById(tenantId, orderId);
// PENDING → APPROVED は confirm() の自動承認分岐だけに閉じ込める
if (order.getStatus() != OrderStatus.AWAITING_APPROVAL) {
throw new IllegalStateException(
"承認対象は AWAITING_APPROVAL のみです: orderId=" + orderId
+ ", 現在ステータス=" + order.getStatus());
}
order.getStatus().validateTransitionTo(OrderStatus.APPROVED);
order.setStatus(OrderStatus.APPROVED);
orderRepository.updateStatus(order);
approvalHistoryDao.record(orderId, actor, "APPROVE", comment);
}
/** AWAITING_APPROVAL → REJECTED(与信引当を解放) */
@Transactional
public void reject(String tenantId, long orderId,
String actor, String comment) {
SalesOrder order = orderRepository.findById(tenantId, orderId);
if (order.getStatus() != OrderStatus.AWAITING_APPROVAL) {
throw new IllegalStateException(
"却下対象は AWAITING_APPROVAL のみです: orderId=" + orderId
+ ", 現在ステータス=" + order.getStatus());
}
order.getStatus().validateTransitionTo(OrderStatus.REJECTED);
order.setStatus(OrderStatus.REJECTED);
orderRepository.updateStatus(order);
approvalHistoryDao.record(orderId, actor, "REJECT", comment);
}
}
ポイントは4つです。
-
creditStrategy.calculate()は使用額と限度額を返すだけです。OK/NG判定(projectedExposure > limitAmount)はコアが行います。コアは比較ロジックを持ちますが、何を残高に含めるかは知りません -
approve()とreject()がステータスを更新すると、与信引当の振替・解放は自動的に反映されます。APPROVEDになった受注は次回のcurrentExposure計算の対象になり、REJECTEDになった受注はreservedExposureの対象から外れます。別途引当レコードを操作する必要はありません。PENDING → APPROVEDという遷移も状態機械上は存在する(confirm()の自動確定パス)ため、approve()とreject()には明示的なAWAITING_APPROVAL確認チェックを設けています。対称的に、confirm()自身にもPENDING以外を拒否する入口チェックを設け、AWAITING_APPROVAL受注がconfirm()を通じて承認される経路を封じています -
confirm()の与信判定からステータス更新までは、同一取引先に対して同時実行されると与信枠の超過を検知できない場合があります。CreditLimitDao.getLimit()の内部クエリにWITH (UPDLOCK, HOLDLOCK)(SQL Server)ヒントを付与します。ApprovedOnlyCreditStrategy.calculate()ではロック取得(getLimit())を使用額集計(sumLineAmount())より先に実行する設計としており、ロックを保持したまま使用額を読み込むことで直列化を担保します - CDIによるStrategy切り替えは第1回・第2回と同じパターンです。
CreditCheckStrategyFactoryとApprovalStrategyFactoryがそれぞれ@CreditTypeQualifierと@ApprovalTypeQualifierで実装を選択します
REJECTED → PENDINGの再申請(再送信)は、ステータスをPENDINGに戻したうえで confirm() を再度呼び出します。line_amount は確定済みのため再計算しません。
C社との差分
B社とC社の主な差分を整理します。
| 項目 | B社 | C社 |
|---|---|---|
| 与信残高の対象ステータス | APPROVED + AWAITING_APPROVAL | APPROVED + SHIPPED + AWAITING_APPROVAL |
| 承認ルール決定の次元数 | 1次元(金額のみ) | 3次元(金額×取引先ランク×製品カテゴリ) |
| 承認ルール変更履歴 | なし |
approval_route_rule_history で管理 |
| ルールエンジン移行 | 不要 | 「変更の即時性」非該当のため現時点は不要 |
設計判断の整理
第3回で確定した設計判断を整理します。
| 判断対象 | 分類 | 理由 |
|---|---|---|
| 状態遷移の許可/拒否 | コア | プロダクトの不変条件 |
| 与信チェックの有無 | Config |
strategy_type = 'none' で表現 |
| 承認の金額閾値 | Config | 値の変更。処理の意味は変わらない |
| 与信残高の計算方法 | Extension | 何を残高に含めるかで処理の意味が変わる |
| 承認ルート決定ロジック | Extension | 承認ルートの決定方法で処理の意味が変わる |
| C社の承認条件 | Extension(ルールエンジン候補) | 移行基準「変更の即時性」に非該当 |
| 与信引当の管理 | コア | ステータス遷移に連動する枠の確保・解放はプロダクトの不変条件 |
| 与信残高の計算基準日 | 実行時点 | 与信はリアルタイム判断。単価計算の受注日基準とは異なる |
| 承認履歴の層配置 | コア | 状態遷移の事実記録。ルール固有情報を含まない |
| REJECTEDからの復帰先 | PENDING | 受注データの問題ではなく承認レベルの問題。再取り込み不要 |
第2回と比較すると、設計判断の性質が異なる3点があります。
基準日の判断: 第2回は受注日基準、今回は実行時点基準。「基準日をどう設定するか」という設計選択でも、業務の性質(冪等性の担保 vs リアルタイム判断)で結論が変わります。
監査ログの配置: 第2回の適用ログはExtension(ルール固有情報を含む)。今回の承認履歴はコア(状態遷移の事実のみ)。「履歴を残す」という同じ要件でも、何を記録するかで層が変わります。
ルールエンジン移行の結論: 第2回はC社の単価計算が3基準すべてに該当してルールエンジンへ移行。今回はC社の承認条件がExtensionに留まります。同じ基準を使って異なる結論になります。
次回予告
受注が APPROVED になりました。出荷可能な状態です。
次回は出荷と請求を扱います。出荷指示の発行から請求書の生成まで、帳票出力というテナント差分の扱い方が設計の中心になります。
Discussion