🏛️

持続可能なプロダクト設計 ― 販売管理実践(3)与信チェックと受注承認

に公開

はじめに

前回は、受注明細の単価を決定するプロセスを設計しました。Config・Extension・ルールエンジンの境界を「変更主体・変更履歴の監査・変更の即時性」の3基準で整理し、PricingStrategy で切り出しました。単価が決まり、line_amount(確定値)が sales_order_line に永続化されています。

https://zenn.dev/k_mt/articles/e3639117691375

受注ステータスはまだ 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ステータス(DRAFTPENDINGAPPROVEDSHIPPEDCLOSED)に、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                    (完了)

PENDINGAWAITING_APPROVAL の区別を明確にします。

  • PENDING:システムが自動処理する対象。人の判断を待たない状態
  • AWAITING_APPROVAL:人の判断を待つ状態。与信枠の引当が計上されている

REJECTED → PENDING(DRAFTには戻らない)を選んだ理由です。却下は承認レベルの問題であり、受注データ(品目・数量・単価)の問題ではありません。line_amount は確定済みです。受注データを再取り込みする理由がないため、DRAFTには戻しません。与信チェックから再実行(PENDINGから再開)します。

与信引当のライフサイクル:

与信引当は、ステータス自体で管理します。別途引当テーブルは設けません。

  • PENDING → AWAITING_APPROVAL:この受注の line_amount が、以降の与信残高計算の reservedExposure(引当済み使用額)に含まれるようになる
  • AWAITING_APPROVAL → APPROVEDreservedExposure から currentExposure(確定使用額)へ振替。APPROVED受注として計上される
  • AWAITING_APPROVAL → REJECTEDreservedExposure から除外。与信枠が解放される

この仕組みにより、承認待ちで滞留している受注が与信枠を押さえます。滞留中に別の受注が承認されても、引当済み分が枠を消費しているため、限度額超過を構造的に防止できます。

コアの責任

コアが担うのは3つです。

  • 状態遷移の整合性:不正な遷移(APPROVED → PENDING 等)を拒否する
  • 与信枠との比較:Strategyから取得した使用額と限度額を比較する。比較式は projectedExposure = currentExposure + reservedExposure + currentOrderAmountCreditCheckStrategycurrentExposurereservedExposurelimitAmount を返し、OrderConfirmationServicecurrentOrderAmount(当該受注の金額合計)を加算して比較する
  • 承認履歴の記録: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回と同じパターンです。CreditCheckStrategyFactoryApprovalStrategyFactory がそれぞれ @CreditType Qualifierと @ApprovalType Qualifierで実装を選択します

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