【PL/SQL】締め処理・締め戻し設計|月次確定・再締め・締め状態管理まで

【PL/SQL】締め処理・締め戻し設計|月次確定・再締め・締め状態管理まで PL/SQL

月次締めは、単に集計SQLを実行して終わりではありません。締め対象月を確定し、締め前チェックを通し、処理中の排他を取り、集計結果を固定し、必要なときは締め戻しと再締めができるようにする必要があります。この流れが曖昧なままだと、締め後にデータが変わる、再締めで二重集計する、誰が締め戻したか分からない、といった問題が起きます。

この記事では、PL/SQLで締め処理・締め戻しを設計する方法を整理します。OPENCLOSINGCLOSEDREOPENEDの締め状態を管理し、月次確定、締め前検証、排他制御、履歴ログ、再締めまでを実務で使える形にします。業務日付は 業務日付・営業日カレンダー設計、集計バッチは サマリーテーブル・集計バッチ設計 と組み合わせると効果的です。

この記事で扱うこと

  • 締め処理が必要になる場面
  • OPEN、CLOSING、CLOSED、REOPENEDの状態設計
  • 締め管理テーブルと履歴テーブルのDDL
  • 締め前チェックとデータ品質検証
  • 締め中の排他制御
  • 月次集計の確定と再作成
  • 締め戻し、再締め、監査ログ
スポンサーリンク

締め処理が必要になる場面

締め処理は、一定期間のデータを確定し、後続工程へ渡すための処理です。売上、入金、在庫、請求、会計連携、ポイント付与など、締め後に値が変わると困る処理で必要になります。締める対象は月次だけとは限らず、日次締め、週次締め、店舗別締め、部門別締めの場合もあります。

月次売上締め月内の売上と返品を確定し、請求や会計連携へ渡します。
入金締め入金消込の対象を確定し、未入金や過入金を確認します。
在庫締め棚卸や月末在庫を確定し、後から数量が変わらないようにします。
会計連携締め済みデータだけを外部システムへ連携します。
再締め補正後に集計や連携データを作り直します。

締め対象日や月内最終営業日の考え方は 業務日付・営業日カレンダー設計 と合わせて決めます。処理対象レコード単位の状態管理は 処理ステータス管理テーブル設計 が参考になります。

締め状態を定義する

締め状態は少なく、しかし運用判断に必要な粒度で定義します。OPENは未締め、CLOSINGは締め処理中、CLOSEDは締め済み、REOPENEDは締め戻し済みとして扱います。

OPENデータ登録や補正を受け付ける状態です。
CLOSING締め処理中です。二重実行や更新を防ぎます。
CLOSED締め済みです。原則として対象期間の更新を禁止します。
REOPENED締め戻し済みです。補正後に再締めします。

状態を増やしすぎると、どの状態からどの状態へ進めるのか分かりにくくなります。まずはこの4状態にして、必要ならCANCELLEDFAILEDを追加します。

締め管理テーブルを設計する

締め対象月、状態、締めバージョン、締め実行者、締め戻し理由を管理するテーブルを作ります。締め状態は期間ごとに1行で管理し、再締めのたびにclose_versionを増やします。

monthly-closing-period-ddl.sql
CREATE TABLE monthly_closing_period (
  period_month     VARCHAR2(6) PRIMARY KEY,
  target_from      DATE NOT NULL,
  target_to        DATE NOT NULL,
  status           VARCHAR2(20) DEFAULT 'OPEN' NOT NULL,
  close_version    NUMBER DEFAULT 0 NOT NULL,
  closing_started_at TIMESTAMP,
  closed_at        TIMESTAMP,
  closed_by        VARCHAR2(100),
  reopened_at      TIMESTAMP,
  reopened_by      VARCHAR2(100),
  reopen_reason    VARCHAR2(1000),
  lock_owner       VARCHAR2(100),
  lock_at          TIMESTAMP,
  updated_at       TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
  CONSTRAINT ck_mcp_status
    CHECK (status IN ('OPEN','CLOSING','CLOSED','REOPENED')),
  CONSTRAINT ck_mcp_period
    CHECK (target_from < target_to)
);

CREATE INDEX ix_mcp_status
  ON monthly_closing_period(status, period_month);

target_toは終了日そのものではなく、検索条件で< target_toとして使う境界日にします。2026年5月の締めなら、target_from = DATE '2026-05-01'target_to = DATE '2026-06-01'のように持ちます。

締め履歴テーブルを設計する

締め処理は、誰がいつ実行したか、なぜ締め戻したかを後から説明できる必要があります。現在状態だけでなく、状態遷移の履歴を残します。

monthly-closing-history-ddl.sql
CREATE TABLE monthly_closing_history (
  history_id      NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  period_month    VARCHAR2(6) NOT NULL,
  old_status      VARCHAR2(20),
  new_status      VARCHAR2(20) NOT NULL,
  close_version   NUMBER NOT NULL,
  changed_by      VARCHAR2(100),
  changed_at      TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
  reason          VARCHAR2(1000),
  message         VARCHAR2(2000)
);

CREATE INDEX ix_mch_period
  ON monthly_closing_history(period_month, changed_at);

ジョブ全体の実行履歴は ジョブ実行履歴テーブル設計、締め状態の遷移はこの履歴テーブル、というように役割を分けます。

締め対象月を登録する

締め処理を行う前に、対象月の管理行を作成します。月次締めなら対象月ごとに1行、店舗別や部門別に締める場合はキーを追加します。

create-closing-period.sql
INSERT INTO monthly_closing_period (
  period_month,
  target_from,
  target_to,
  status
)
VALUES (
  '202605',
  DATE '2026-05-01',
  DATE '2026-06-01',
  'OPEN'
);

締め対象月の決定は、営業日カレンダーや業務月に合わせます。会計月が暦月と違う場合は、締め対象範囲をテーブル値として確定させると、SQLごとに日付計算を散らさずに済みます。

締め前チェックを通す

締め処理を始める前に、未処理データ、失敗データ、データ不整合が残っていないかを確認します。締め前チェックを通さずに締めると、後から締め戻しが必要になりやすくなります。

pre-closing-check.sql
SELECT 'FAILED_WORK_ITEMS' AS check_name,
       COUNT(*) AS issue_count
FROM batch_work_item
WHERE target_date >= :target_from
  AND target_date <  :target_to
  AND status IN ('FAILED','RETRY_WAIT','RUNNING')
UNION ALL
SELECT 'QUALITY_ERRORS',
       COUNT(*)
FROM data_quality_result q
JOIN data_quality_rule r
  ON r.rule_id = q.rule_id
WHERE q.detected_at >= :target_from
  AND q.detected_at <  :target_to
  AND r.severity = 'ERROR';

品質チェックの設計は データ品質チェック・整合性検証バッチ設計 と組み合わせます。締め前チェックは、失敗件数が0であることだけでなく、確認済みの例外をどう扱うかも決めておきます。

締め処理を開始する

締め処理を開始するときは、締め管理行をロックし、状態をCLOSINGに変更します。同じ月を二重に締めないように、SELECT FOR UPDATEで対象行をロックします。

begin-monthly-closing.sql
CREATE OR REPLACE PROCEDURE begin_monthly_closing (
  p_period_month IN VARCHAR2,
  p_operator     IN VARCHAR2
)
IS
  v_status monthly_closing_period.status%TYPE;
  v_version monthly_closing_period.close_version%TYPE;
BEGIN
  SELECT status, close_version
    INTO v_status, v_version
    FROM monthly_closing_period
   WHERE period_month = p_period_month
   FOR UPDATE;

  IF v_status NOT IN ('OPEN','REOPENED') THEN
    raise_application_error(
      -20030,
      '締め開始できない状態です: ' || v_status
    );
  END IF;

  UPDATE monthly_closing_period
  SET
    status = 'CLOSING',
    close_version = close_version + 1,
    closing_started_at = SYSTIMESTAMP,
    lock_owner = p_operator,
    lock_at = SYSTIMESTAMP,
    updated_at = SYSTIMESTAMP
  WHERE period_month = p_period_month;

  INSERT INTO monthly_closing_history (
    period_month,
    old_status,
    new_status,
    close_version,
    changed_by,
    message
  )
  VALUES (
    p_period_month,
    v_status,
    'CLOSING',
    v_version + 1,
    p_operator,
    '締め処理を開始しました。'
  );

  COMMIT;
END;
/

ジョブ全体の二重起動防止には DBMS_LOCKで二重起動防止する方法 も有効です。締め対象月単位の排他は、締め管理行のロックと状態管理で行います。

締め対象の更新を防ぐ

締め済み期間や締め処理中の期間のデータが後から更新されると、締め結果と明細がずれます。更新処理側で締め状態を確認するか、必要に応じてトリガーで防ぎます。

prevent-update-after-close.sql
CREATE OR REPLACE TRIGGER trg_sales_no_update_closed
BEFORE INSERT OR UPDATE OR DELETE ON sales_detail
FOR EACH ROW
DECLARE
  v_status monthly_closing_period.status%TYPE;
  v_sales_date DATE;
BEGIN
  v_sales_date := TRUNC(
    CASE
      WHEN INSERTING OR UPDATING THEN :NEW.sales_date
      ELSE :OLD.sales_date
    END
  );

  SELECT status
    INTO v_status
    FROM monthly_closing_period
   WHERE v_sales_date >= target_from
     AND v_sales_date <  target_to;

  IF v_status IN ('CLOSING','CLOSED') THEN
    raise_application_error(-20031, '締め中または締め済み期間の売上データは更新できません。');
  END IF;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    NULL;
END;
/

トリガーは強力ですが、すべてをトリガーに寄せると原因調査が難しくなることもあります。更新処理側で締め状態を確認する設計を基本にし、重要なテーブルだけ防御的にトリガーを使うのが現実的です。

月次集計を作り直す

締め処理では、対象月の集計結果を作成します。この記事では、締め開始時にclose_versionを増やし、その締め中バージョンの集計結果を作り直す例にします。同じバージョンを再実行する場合は削除して再作成し、締め戻し後の再締めでは新しいバージョンを残します。

rebuild-monthly-summary.sql
DELETE FROM sales_monthly_summary
WHERE period_month = :period_month
  AND close_version = :close_version;

INSERT INTO sales_monthly_summary (
  period_month,
  close_version,
  store_id,
  sales_amount,
  return_amount,
  created_at
)
SELECT
  :period_month,
  :close_version,
  s.store_id,
  SUM(CASE WHEN s.sales_type = 'SALE' THEN s.amount ELSE 0 END),
  SUM(CASE WHEN s.sales_type = 'RETURN' THEN s.amount ELSE 0 END),
  SYSTIMESTAMP
FROM sales_detail s
JOIN monthly_closing_period p
  ON p.period_month = :period_month
WHERE s.sales_date >= p.target_from
  AND s.sales_date <  p.target_to
GROUP BY s.store_id;

集計バッチの基本設計は サマリーテーブル・集計バッチ設計 と同じです。締め処理では、再締め時にどの集計結果が最新なのかを説明できるように、close_versionを持たせるのがポイントです。

締め結果を検算する

締め集計を作ったら、明細合計と締めサマリーが一致しているか確認します。締め済みにする前に検算を通すことで、締め戻しの回数を減らせます。

validate-closing-summary.sql
WITH detail_sum AS (
  SELECT
    s.store_id,
    SUM(CASE WHEN s.sales_type = 'SALE' THEN s.amount ELSE 0 END) AS sales_amount,
    SUM(CASE WHEN s.sales_type = 'RETURN' THEN s.amount ELSE 0 END) AS return_amount
  FROM sales_detail s
  JOIN monthly_closing_period p
    ON p.period_month = :period_month
  WHERE s.sales_date >= p.target_from
    AND s.sales_date <  p.target_to
  GROUP BY s.store_id
)
SELECT
  NVL(d.store_id, m.store_id) AS store_id,
  NVL(d.sales_amount, 0) - NVL(m.sales_amount, 0) AS sales_diff,
  NVL(d.return_amount, 0) - NVL(m.return_amount, 0) AS return_diff
FROM detail_sum d
FULL OUTER JOIN sales_monthly_summary m
  ON m.period_month = :period_month
 AND m.close_version = :close_version
 AND m.store_id = d.store_id
WHERE NVL(d.sales_amount, 0) <> NVL(m.sales_amount, 0)
   OR NVL(d.return_amount, 0) <> NVL(m.return_amount, 0);

検算で差異が出た場合は、CLOSEDにせず、原因を調べます。差異検出を定型化するなら、データ品質チェックのルールとして登録しても構いません。

締め処理を完了する

締め前チェック、集計、検算が通ったら、状態をCLOSEDに変更します。検算が通っていない状態で誤って締め完了しないように、完了処理には検算OKの引数を渡します。締め完了時にはロック情報をクリアします。

finish-monthly-closing.sql
CREATE OR REPLACE PROCEDURE finish_monthly_closing (
  p_period_month IN VARCHAR2,
  p_operator     IN VARCHAR2,
  p_validation_ok IN CHAR
)
IS
  v_version monthly_closing_period.close_version%TYPE;
BEGIN
  IF p_validation_ok <> 'Y' THEN
    raise_application_error(
      -20032,
      '締め結果の検算が完了していないため、締め完了できません。'
    );
  END IF;

  SELECT close_version
    INTO v_version
    FROM monthly_closing_period
   WHERE period_month = p_period_month
     AND status = 'CLOSING'
   FOR UPDATE;

  UPDATE monthly_closing_period
  SET
    status = 'CLOSED',
    closed_at = SYSTIMESTAMP,
    closed_by = p_operator,
    lock_owner = NULL,
    lock_at = NULL,
    updated_at = SYSTIMESTAMP
  WHERE period_month = p_period_month;

  INSERT INTO monthly_closing_history (
    period_month,
    old_status,
    new_status,
    close_version,
    changed_by,
    message
  )
  VALUES (
    p_period_month,
    'CLOSING',
    'CLOSED',
    v_version,
    p_operator,
    '締め処理を完了しました。'
  );

  COMMIT;
END;
/

この設計では、締め開始時に決まったclose_versionを、集計結果と締め履歴の両方で使います。大切なのは、集計結果と締め履歴が同じバージョンを指すことです。

締め戻しを設計する

締め後に補正が必要になった場合は、締め戻しを行います。締め戻しは、締め済みデータを無条件に更新可能にする操作ではありません。理由、実行者、対象月、影響範囲を履歴に残してからREOPENEDにします。

reopen-monthly-closing.sql
CREATE OR REPLACE PROCEDURE reopen_monthly_closing (
  p_period_month IN VARCHAR2,
  p_operator     IN VARCHAR2,
  p_reason       IN VARCHAR2
)
IS
  v_version monthly_closing_period.close_version%TYPE;
BEGIN
  SELECT close_version
    INTO v_version
    FROM monthly_closing_period
   WHERE period_month = p_period_month
     AND status = 'CLOSED'
   FOR UPDATE;

  UPDATE monthly_closing_period
  SET
    status = 'REOPENED',
    reopened_at = SYSTIMESTAMP,
    reopened_by = p_operator,
    reopen_reason = p_reason,
    updated_at = SYSTIMESTAMP
  WHERE period_month = p_period_month;

  INSERT INTO monthly_closing_history (
    period_month,
    old_status,
    new_status,
    close_version,
    changed_by,
    reason,
    message
  )
  VALUES (
    p_period_month,
    'CLOSED',
    'REOPENED',
    v_version,
    p_operator,
    p_reason,
    '締め戻しを実行しました。'
  );

  COMMIT;
END;
/

締め戻し後に本番データを補正する場合は 本番データ補正スクリプトの作り方 と同じく、対象条件、バックアップ、検証SQLを用意してから実行します。

再締めの流れを決める

締め戻し後は、補正、品質チェック、集計再作成、検算、締め完了という流れで再締めします。初回締めと同じ手順を通すことで、締め戻し後だけチェックが甘くなることを防ぎます。

reclose-monthly-flow.sql
BEGIN
  begin_monthly_closing(
    p_period_month => '202605',
    p_operator     => :operator_name
  );

  -- 1. 締め前チェック
  -- 2. 月次集計の再作成
  -- 3. 締め結果の検算

  finish_monthly_closing(
    p_period_month => '202605',
    p_operator     => :operator_name,
    p_validation_ok => 'Y'
  );
END;
/

再締めでは、前回の締め結果を削除して置き換えるのか、締めバージョンを増やして履歴として残すのかを決めます。監査や会計連携が関わる場合は、古い締め結果も残せる設計の方が説明しやすくなります。

CLOSINGのまま残った状態を検出する

締め処理中にジョブが落ちると、状態がCLOSINGのまま残ることがあります。締め処理中の滞留は、通常の処理ステータスより影響が大きいため、監視SQLを用意します。

monitor-stale-closing.sql
SELECT
  period_month,
  status,
  closing_started_at,
  lock_owner,
  lock_at
FROM monthly_closing_period
WHERE status = 'CLOSING'
  AND lock_at < SYSTIMESTAMP - INTERVAL '2' HOUR;

CLOSINGを自動でOPENへ戻すかどうかは慎重に決めます。締め処理は影響が大きいため、自動戻しよりも、運用者がログと集計結果を確認してから戻す方が安全な場合が多いです。

やってはいけない締め処理

最後に、実務で避けたい締め処理を整理します。締め処理は障害時の説明責任が大きいため、曖昧な設計のまま運用に乗せないことが重要です。

締め状態がない締め済みかどうかを日付やファイル名だけで判断すると、運用ミスが起きます。
締め中の排他がない二重実行や更新競合で集計結果が壊れる可能性があります。
締め前チェックがない未処理や不整合が残ったまま締めてしまいます。
締め戻し理由が残らない誰がなぜ戻したのか後から説明できません。
再締め手順が違う初回締めと再締めでチェック内容が変わると品質がぶれます。
締め済み期間を更新できる明細と締め結果がずれます。

設計チェックリスト

PL/SQLで締め処理・締め戻しを設計するときは、次の点を確認します。

  • 締め対象期間をテーブルで管理している
  • OPEN、CLOSING、CLOSED、REOPENEDを区別している
  • 締め前チェックを通してから締めている
  • 締め中の二重実行を防いでいる
  • 締め済み期間の更新を防いでいる
  • 締め履歴と締め戻し理由を残している
  • 集計結果と締めバージョンが紐づいている
  • 再締め時も初回締めと同じ検証を通している
  • CLOSING滞留を検出できる
  • 補正、検算、再締めの手順が決まっている

まとめ

PL/SQLで締め処理を設計するときは、集計SQLだけでなく、締め状態、排他、締め前チェック、履歴、締め戻し、再締めまでを一つの流れとして考えることが重要です。締め済みデータは後続工程の前提になるため、いつ誰が何を確定したのかを説明できる設計にします。

特に大切なのは、締め対象期間を固定すること、CLOSINGで二重実行を防ぐこと、締め戻し理由を履歴に残すことです。締め処理は地味ですが、業務データの信頼性を支える最後の関門です。