Linux 版の LibreOffice Calc で ExcelLikeUno を使う

結論から言うと、ちょっとトリッキーな設定が必要ですが、できます。

Linux 版といっても色々あるので、詳しい検証は後からになるのですが、ひとまず自分の環境である Ubuntus 24.04 の LibreOffice のダウンロード版(snap版じゃないほう)で通りました。

LibreOffice のインストール

sudo apt install libreoffice
sudo apt install python3-uno

作業環境を Windows/Linux/macOS のどれに寄せるのかは、たびたび議論になるところでしょうが、要は “自分のところで動けばいい” ってのが優先させます。あと、仕事で使うので “客先の環境で動かないといけない” ってのがありますね。

Ubuntu 24.04 の制限

それ以前の環境と Python の使い方が変わっているので、注意が必要です。

  • ダウンロード版の LibreOffice には Python が含まれない。このために OS 側の Python を使う
  • OS 側の Python はセキュリティ上 pip ができない。

という訳で一気に詰み…というわけでもなく、ローカルスクリプトのほうに pip できたり、LibreOffice が使う Python を指定したりできればいいのですが…後者の LibreOffice が使う Python のほうはそういう設定がありません(たぶん)。

ちなみに、うちの Ubuntu はこんな劣悪な環境で動いています。Hyper-V でもいいのだけど、ちょっと mini pc が余ってしまったので、練習用に。

LibreOffice 関係のパス

LibreOffice の python マクロのパス(古いのだと LibreOffice のように大文字になっていますが、最新版は libreoffice のように小文字です)

~/.config/libreoffice/4/user/Scripts/python

Python3 の site-packages のパス

/usr/lib/python3/dist-packages

要は、OS で使っている python3 の site-packages/dist-packages に pip のライブラリを突っ込めばいいのです。どうせ、自分だけの環境(あるいは客先だけの環境)なのですから、セキュリティ関連は自前もちということで。

なので、

  • python3 -m venv .venv で 仮想開発環境を作成する。
  • .venv 環境内で pip install exellikeuno する
  • .venv/lib/python3.12/site-packages 内に excellikeuno がインストールされる
  • .venv/lib/python3.12/site-packages/exellikeuno を /usr/lib/python3/dist-packages 内に sudo でシンボリックリンク(ln -s)する

という方式です。フォルダ名を ~/libre にしていますが、これは適当に作って大丈夫です。

mkdir libre
cd libre
python -m venv .venv
.venv/bin/pip install exellikeuno
ls .venv/lib/python3.12/site-packages
sudo ~/libre/.venv/lib/python3.12/site-packages/excellikeuno /usr/lib/python3/dist-packages/

のような感じで動かします。最後だけ sudo なので注意してください。

リモート vscode で python マクロを編集する

Ubuntu 上で vscode で編集すればよいのですが、私の場合はメイン環境が Windows なので Windows から Ubuntu へ vscode でリモート接続します。

モジュールとかのコード補完が効くように .vscode/settings.json も設定しておきます。

{
    "python.analysis.autoImportCompletions": true,
    "python.analysis.extraPaths": [
        "~/.config/libreoffice/4/user/Scripts/python/excellikeuno",
    ]
}

ExcelLikeUno 関係のコード補完は pip でインストールしてあるので、自動で補完されます。

Calc のマクロで動かす場合はこんな感じ。

マクロが Basic なのか Python なのかは、自動判別してくれます(これ、Windows 版が自動判別じゃないのがおかしいw)

LibreOffice Calc の外部から起動したい場合は、サーバーを起動します。

soffice --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo

OS 付属の Python3 を動かせば ok です。

python3 calc_sample_shogiban.py

まあ、UI を使う場合は Ubuntu の Python の制限もあり、ややこしいことになっていますが、Docker や WSL のヘッドレス(GUI がないモード)の場合はもうちょっとやりやすいはずです。LibreOffice を使って PDF 化したり整形するようなサーバー処理を作りたい場合は、Docker のほうが便利だと思うので、これを後ほど試します。

カテゴリー: 開発, LibreOffice | Linux 版の LibreOffice Calc で ExcelLikeUno を使う はコメントを受け付けていません

LibreOffice の Python macro で pip を使う

長年…といっても、半月ほどですが、懸念であった LibreOffice の Python マクロで pip を使える方法がわかりました。

moonmile/ExcelLikeUno: LibreOffice の Python マクロを Excel VBA ライクに操作 https://github.com/moonmile/ExcelLikeUno

というか、普通に LibreOffice Python が使っているところに pip できます。

& 'C:\Program Files\LibreOffice\program\python' -m pip install excellikeuno

このように LibreOffice が使っている Python を -m スイッチを付けてインストールすると以下のフォルダに配置されます。

C:\Users\masuda\AppData\Roaming\Python\Python311\site-packages\

共有の Program Files 配下には入れられないので、代わりにユーザーの AppData の中に入ります。現在の LibreOffice Python は 3.11 を使っているので、Python311 フォルダーになります。将来的にバージョンが変わると、ここも変わる可能性は大です。

これで、Calc のマクロからも excellikeuno パッケージが参照できるようになるので、以下のように書けます。

"""LibreOffice Calc sample macro."""
from re import X
from typing import Any, Tuple
from excellikeuno import connect_calc_script
from excellikeuno.table.sheet import Sheet 

def hello_to_cell():
    ( _, _, sheet ) = connect_calc_script(XSCRIPTCONTEXT)
    sheet.cell(0, 0).text = "Hello Excel Like for Python!"
    sheet.cell(0, 1).text = "こんにちは、Excel Like for Python!"
    sheet.cell(0,0).column_width = 10000  # 幅を設定

    cell = sheet.cell(0,1)
    cell.CellBackColor = 0x006400  # 濃い緑に設定
    cell.CharColor = 0xFFFFFF  # 文字色を白に設定

g_exportedScripts = (
    hello_to_cell,
)

XSCRIPTCONTEXT の接続部分を隠蔽化させるために、connect_calc_script 関数を使って sheet を取得します。このあたりは、もっと簡素化して Excel VBA のように ActiveWorksheet, ActiveWorkbook として取得できるようにする予定です。

Python マクロ自体は、以下に保存されているので vscode で編集することができます。

C:\Users\{ユーザー名}\AppData\Roaming\LibreOffice\4\user\Scripts\python\

ただし、このままだと vscode のコード補完が効かない、パッケージのコード補完が効かないので Scripts/python/.vscode/settings.json を作成します。

{
    "python.analysis.autoImportCompletions": true,
    "python.analysis.extraPaths": [
        "C:/Users/masuda/AppData/Roaming/Python/Python311/site-packages"
    ]
}

“python.analysis.extraPaths” に、先の site-packages フォルダーを設定しておきます。

そうすると、ExcelLikeUno パッケージで定義してある Sheet クラスなどで、コード補完が働くようになります。

これだと、uno api 単体よりははるかにコーディングしやすいし、VBA オプションを付けたかんじで Excel VBA 風にコーディングができるはずです。

あとは、Excel VBA と uno api の対応表を作りながら、ちまちまとクラス設計をしていけば良いはず。uno api のほうが Java 的に綺麗に整理されはいるのだけれど、Excel VBA の構造に慣れていると、いまいち発想が届かないのです。まあ、いったん、Excel VBA に直してから、あらためて UNO api 風に戻していけばよいかなと。

ところで LibreOffice Python でも自由に pip ができる(多分、仮想環境では無理だろうけど)ことがわかったので、通常の pip パッケージも Calc 内部から使えるはずです。

  • OpenAI 関係の API を呼び出し
  • PyNum 関係で数式を使ってグラフを作成
  • Python マクロ内から Web API 呼び出し

あたりが可能です。このあたりは、別途ためしてみる予定。

カテゴリー: 開発, LibreOffice, python | LibreOffice の Python macro で pip を使う はコメントを受け付けていません

LibreOffice Calc で使う Excel Like UNO を仮リリース

正月にちまちま整備していた Excel Like Uno が、まあ動くようになったので仮リリースしておきます。

https://github.com/moonmile/ExcelLikeUno

moonmile/ExcelLikeUno: LibreOffice の Python マクロを Excel VBA ライクに操作 github.com/moonmile/Exc…一旦、仮リリース。

Tomoaki Masuda (@moonmile.bsky.social) 2026-01-05T03:04:00.398Z

12月に LibreOffice Calce の Python マクロを使って、諸々を試していたのですが、現状の uno api だけでは限界がありそうなので、更にラップをするライブラリを作って試しています。

目的としては、

  • UNO API の複雑さを隠蔽し、Excel/VBA に近いメソッド・プロパティ名でする
  • 型定義を充実させ、IDE 補完と静的解析をサポート

なところで、従来の Excel VBA に似せています。

実は LibreOffice Calc でも Option VBASupport 1 のオプションを付けると VBA 互換モードで動かせます。かなり Excel VBA に近いところまでエミュレートするので Excel VBA から移行する場合はこっちのほうが楽かもしれません。
ただ、いまさら VBA でやりたくないし、どうせならば Python か何か別の言語でやりたいところです。

サンプル calc_sample_cell.py

from excellikeuno import connect_calc
from excellikeuno.typing.calc import CellHoriJustify, CellVertJustify

(desktop, doc, sheet) = connect_calc() 
cell = sheet.cell(0, 0)  # A1 セルを取得
cell.text = "Hello, World!"  # 値を設定
sheet.range("A1:C1").merge(True)  # A1:C1 を結合

cell.font_size = 16
cell.font_name = "Arial"
cell.font_color = 0xFF0000  # フォント色を赤に

cell.row_height = 2000  # 行の高さを設定 20 mm
cell.HoriJustify = CellHoriJustify.CENTER
cell.VertJustify = CellVertJustify.CENTER


sheet.cell(0,1).text = "id"
sheet.cell(1,1).text = "name"
sheet.cell(2,1).text = "address"
sheet.range("A2:C2").CellBackColor = 0xFFBF00  # A2:C2 の背景色を設定

data = [
    [1, "masuda", "tokyo"],
    [2, "suzuki", "osaka"],
    [3, "takahashi", "nagoya"],
]
sheet.range("A3:C5").value = data  # 範囲にデータを一括設定

こんな感じで、最初に connect_calc() で接続してから、

  • sheet.cell( column, row ) でセルを取得
  • cell.font_size などのプロパティを使う

といいうことができます。テキストや値は、getText() や setText() を使うのではなく、text プロパティを使います。実際には、数値は value プロパティ、テキストが text プロパティにしてあります。

この Python コードを実行すると、LibreOffice Calc だと、こんな表示になります。
まあ、一般的な表ぐらいはできそうな状態です。

サンプル calc_sample_shogiban.py

罫線のサンプルを見てみましょう。

# 将棋盤を作る
from excellikeuno import connect_calc
from excellikeuno.typing.calc import CellHoriJustify, CellVertJustify
from excellikeuno.typing.structs import BorderLine

(desktop, doc, sheet) = connect_calc()
# sheet.name = "将棋盤"
ban = sheet.range("A1:I9");
ban.CellBackColor = 0xFFFACD  # 背景色を薄い黄色に設定
ban.row_height = 1000  # 行の高さを設定 20 mm
ban.column_width = 1000  # 列の幅を設定 20 mm
# 罫線を設定
for cell in [c for row in ban.cells for c in row]:
    borderline = BorderLine()
    borderline.Color = 0x000000
    borderline.OuterLineWidth = 50
    borderline.InnerLineWidth = 0
    borderline.LineDistance = 0

    cell.TopBorder = borderline
    cell.BottomBorder = borderline
    cell.LeftBorder = borderline
    cell.RightBorder = borderline
    # センタリング
    cell.HoriJustify = CellHoriJustify.CENTER
    cell.VertJustify = CellVertJustify.CENTER
    # フォントサイズを大きく
    cell.font_size = 16.0
    cell.CharColor = 0x000000  # 黒色に設定
    

# 駒を配置
pieces = [
    ["香", "桂", "銀", "金", "王", "金", "銀", "桂", "香"],
    ["", "飛", "", "", "", "", "", "角", ""],
    ["歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩"],
    ["", "", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", "", ""],
    ["歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩"],
    ["", "角", "", "", "", "", "", "飛", ""],
    ["香", "桂", "銀", "金", "王", "金", "銀", "桂", "香"],
]
ban.value = pieces  # 一括で駒を配置
# 相手の駒を反転表示
for r in range(9):
    for c in range(9):
        cell = ban.cell(c, r)
        if pieces[r][c] != "" and r < 3:
            cell.CharRotation = 180  # 180度回転

セルの大きさを変えて(いわゆる将棋盤にします)、駒をセルに書いていきます。
罫線をつけるところと、文字が180度回転させることができます。

まあ、これを Excel でやるかどうかは別ですが、Excel 方眼紙ぐらいならば作れそうです。

サンプル calc_sample_tsurukame.py

これは図形を配置する例です。

# 鶴亀オセロのサンプル
from excellikeuno import connect_calc
from excellikeuno.typing.structs import BorderLine

(desktop, doc, sheet) = connect_calc()
# sheet.name = "鶴亀オセロ"
ban = sheet.range("A1:H8");
ban.CellBackColor = 0x006400  # 背景色を濃い緑色に
ban.row_height = 1000  # 行の高さを設定 10 mm
ban.column_width = 1000  # 列の幅を設定 10 mm
# 罫線を設定
for cell in [c for row in ban.cells for c in row]:
    borderline = BorderLine()
    borderline.Color = 0x000000
    borderline.OuterLineWidth = 50
    borderline.InnerLineWidth = 0
    borderline.LineDistance = 0
    cell.TopBorder = cell.BottomBorder = cell.LeftBorder = cell.RightBorder = borderline

# オセロの駒を Shape で配置
pieces = [
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "黒", "白", "", "", ""],
    ["", "", "", "白", "黒", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
]

for r in range(8):
    for c in range(8):
        cell = ban.cell(c, r)
        piece = pieces[r][c]
        if piece == "":
            continue
        sheet.shapes.add_ellipse_shape(
            x=cell.position.X + 200,
            y=cell.position.Y + 200,
            width=600,
            height=600,
            fill_color=0x000000 if piece == "黒" else 0xFFFFFF,
            line_color=0x000000,
        )

セルに罫線と背景色をつけて、オセロの駒は EllipseShape(楕円)を使います。

サンプル calc_sample_mahjong.py

フォント指定と TextShape を使った例です。

# 麻雀牌を並べる
from excellikeuno import connect_calc
from excellikeuno.typing.calc import LineStyle

(desktop, doc, sheet) = connect_calc()
# sheet.name = "麻雀牌"

hai = ['1','1','1', '2','2','2', '3','3','3', '4','4','4', 'a',' ','a']

# 13個の TextShape をシートに追加
for i in range(hai.__len__()):
    shape = sheet.shapes.add_text_shape(
        x=i * 1600,
        y=1000,
        width=1500,
        height=2000,
        text=str(hai[i]),
        fill_color=0xFFFFE0  # 薄い黄色
    )
    # 背景色を設定
    shape.FillColor = 0xFFFFE0  # 薄い黄色
    # 枠線の色を設定
    shape.LineStyle = LineStyle.SOLID  # SOLID
    shape.LineWidth = 50  # 線の太さを50 (1/100 mm)
    shape.LineColor = 0x008000  # 緑
    # テキストを中央揃え
    shape.HoriJustify = 1  # CENTER
    shape.VertJustify = 1  # CENTER
    # フォントサイズを大きく
    shape.font_size = 48.0
    # フォントの指定
    shape.CharFontName = "GL-MahjongTile"

# 麻雀牌を並べる
for i, shape in enumerate(sheet.shapes):
    shape.PositionX = i * 1600  # X位置をずらす
    shape.PositionY = 1000  # Y位置を10 mmに設定

# 完成

麻雀用のフォント “GL-MahjongTile” を使って TextShape を使った例です。
フォント自体は PC にインストールしないと駄目なので、埋め込みはできません。

図形が貼り付けられると、フローチャートとかネットワーク図とかの生成ができるようになります。

Calc 内部からマクロで呼び出す

いままでの例は外部から Python コードを使って呼び出すのですが、Calc 内部から Python コードを呼び出すには、XSCRIPTCONTEXT を使わないといけません。ただし、ちょっと初期化コードが外部呼出しからとは異なるのがややこしいのですが、以下のように書けます。

import inspect
import os
import sys
from typing import Any, Tuple

# Ensure this script's directory is importable so the local excellike package resolves
BASE_DIR = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
if BASE_DIR not in sys.path:
    sys.path.append(BASE_DIR)

from excellikeuno.table.sheet import Sheet 

# XSCRIPTCONTEXT に接続する
def connect_calc_script() -> Tuple[Any, Any, Sheet]:
    desktop = XSCRIPTCONTEXT.getDesktop()
    doc = desktop.getCurrentComponent()
    controller = doc.getCurrentController()
    sheet = Sheet(controller.getActiveSheet())
    return desktop, doc, sheet

def hello_to_cell():
    ( _, _, sheet ) = connect_calc_script()
    sheet.cell(0, 0).text = "Hello Excel Like for Python!"
    sheet.cell(0, 1).text = "こんにちは、Excel Like for Python!"
    sheet.cell(0,0).column_width = 10000  # 幅を設定

    cell = sheet.cell(0,1)
    cell.CellBackColor = 0x006400  # 濃い緑に設定
    cell.CharColor = 0xFFFFFF  # 文字色を白に設定

g_exportedScripts = (
    hello_to_cell,
)

こんな風にマクロを実行します。

connect_calc_script 部分を自前で書かないといけないのが難点なので、これは connect_calc にうまく吸収させる予定です。Calc と接続した後は、uno api は共通化されているので同じコードが動きます。つまり ExcelLikeUno ライブラリも外部からでも内部からもで動くということです。

内部で使える Python マクロで何が良くなるかと言うと、こんな風に vscode 使って Python コードが書けるという点です。Excel VBA 互換や、独自 Basic のコードだと付属のエディタでしか書けませんが、Python コードならば vscode で書けます。さらに言えば、git と連携もできるので、コードの保存が可能です。

ただし、コード保存の場所が

C:\Users\<ユーザー名>\AppData\Roaming\LibreOffice\4\user\Scripts\python\

というややこしい場所になっているのが難点で、excellikeuno ライブラリもここに配置しなければいけません。これも今後なんとかしていこうと考えています。

参照先

https://github.com/moonmile/ExcelLikeUno

README.md にもう少し詳しい説明があります。Linux 版や Docker 版などは、準備中。

カテゴリー: 開発, LibreOffice | LibreOffice Calc で使う Excel Like UNO を仮リリース はコメントを受け付けていません

論理削除と物理削除の続き 部品受注システムを Rust + SQL 文で解いてみる

前回の 論理削除と物理削除の続き(工場の部品受注システムの例)https://www.moonmile.net/blog/archives/11888 では、論理削除と物理削除の違いであまり差が出て来なかったので、もう少し踏み込んでみます。いわゆる SQL 文を直接書いて編集する例を見ていきましょう。

結論から言えば、SQL 文を直接書くときには deleted_at カラムを使った論理削除は面倒くさいし結構不利です。「論理削除がアンチパターン」と称される所以がここにあります。ただし、現在においては ORM を使って deleted_at カラムをうまく隠蔽する方法があるので、ORM を使っている限りはあまり不便を感じません。そういう意味では「論理削除はアンチパターン」と断言してしまうのは、ちょっと過去の話かもしれません。

論理削除パターン

-- 部品マスター
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 部品補助情報
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の調達先
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 受注情報
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 部品生産管理
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の組み合わせテーブル(BOM)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);
 
-- BOM マスター
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 受注組み合わせ番号管理
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);
 
-- 顧客発注履歴
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);
 
-- 顧客マスター
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
 
-- サプライヤーマスター
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

物理削除パターン

-- 部品マスター(物理削除)
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 部品マスター履歴
CREATE TABLE parts_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    part_number VARCHAR(64) NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    version INT NOT NULL,
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- BOM マスター(物理削除)
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- BOM マスター履歴
CREATE TABLE bom_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    product_number VARCHAR(64) NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT NOT NULL,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 部品補助情報(物理削除)
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の調達先(物理削除)
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 受注情報(物理削除)
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 部品生産管理(物理削除)
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の組み合わせテーブル(BOM)(物理削除)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);

-- 受注組み合わせ番号管理(物理削除)
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 顧客発注履歴(物理削除)
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);

-- 顧客マスター(物理削除)
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- サプライヤーマスター(物理削除)
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

これをベースにして、Rust を使って SQL 文を直接書くコードを生成してみましょう。幸い?にして Rust では softdelete 系のライブラリが存在しないので、この手のコードは SQL 文を直接書くしかありません。まあ、探せば ORM があるんでしょうが、ここでは悪手のために犠牲になってもらいましょう。

ケース1

顧客から古い発注書に基づいて部品の発注を受けた。残念ながら過去の発注のために、発注書の中にある、部品番号は現在扱っていない。この部品番号を新しくして見積もり書を作成したい。

このときに、過去の発注書と合計金額を照合して、差分を計算しておきたい。

論理削除パターン

use sqlx::{MySql, Pool, Row};
use sqlx::mysql::MySqlPoolOptions;
use decimal::Decimal;

#[derive(Debug, Clone)]
struct OrderItem {
    part_number: String,
    part_name: String,
    quantity: i32,
    unit_price: Decimal,
    total_price: Decimal,
}

#[derive(Debug)]
struct OrderComparison {
    old_items: Vec<OrderItem>,
    new_items: Vec<OrderItem>,
    old_total: Decimal,
    new_total: Decimal,
    difference: Decimal,
}

async fn compare_orders_with_replacement(
    pool: &Pool<MySql>,
    old_bom_id: i64,
    old_part_id_b: i64,
    new_part_id_d: i64,
) -> Result<OrderComparison, sqlx::Error> {
    // 古い発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? 
         ORDER BY bi.sequence"
    )
    .bind(old_bom_id)
    .fetch_all(pool)
    .await?
    .into_iter()
    .map(|(part_number, part_name, quantity, unit_price)| {
        let total_price = unit_price * Decimal::from(quantity);
        OrderItem {
            part_number,
            part_name,
            quantity,
            unit_price,
            total_price,
        }
    })
    .collect();

    // 新しい発注書用に部品Bを部品Dに置き換え
    let mut new_items = old_items.clone();
    for item in &mut new_items {
        if item.part_number == "B" {
            // 部品Dの情報を取得
            let new_part: (String, String, Decimal) = sqlx::query_as(
                "SELECT part_number, part_name, unit_price FROM parts WHERE id = ? AND deleted_at IS NULL"
            )
            .bind(new_part_id_d)
            .fetch_one(pool)
            .await?;

            item.part_number = new_part.0;
            item.part_name = new_part.1;
            item.unit_price = new_part.2;
            item.total_price = new_part.2 * Decimal::from(item.quantity);
        }
    }

    let old_total: Decimal = old_items.iter().map(|i| i.total_price).sum();
    let new_total: Decimal = new_items.iter().map(|i| i.total_price).sum();
    let difference = new_total - old_total;

    Ok(OrderComparison {
        old_items,
        new_items,
        old_total,
        new_total,
        difference,
    })
}

※ 2025/12/30 追記
最初に AI に作成して貰ったときにここのSQL文には deleted_at IS NULL が付いていました。この場合、”古い部品調達まで調べる” ことが必要なので deleted_at の状態に関係なく=新旧関係なくデータを検索しなければいけません。この部分 AI でも間違えるので注意が必要です。

物理削除パターン

use sqlx::{MySql, Pool, Row};
use sqlx::mysql::MySqlPoolOptions;
use decimal::Decimal;

#[derive(Debug, Clone)]
struct OrderItem {
    part_number: String,
    part_name: String,
    quantity: i32,
    unit_price: Decimal,
    total_price: Decimal,
}

#[derive(Debug)]
struct OrderComparison {
    old_items: Vec<OrderItem>,
    new_items: Vec<OrderItem>,
    old_total: Decimal,
    new_total: Decimal,
    difference: Decimal,
}

async fn compare_orders_with_replacement(
    pool: &Pool<MySql>,
    old_bom_id: i64,
    old_part_number_b: &str,
    new_part_id_d: i64,
) -> Result<OrderComparison, sqlx::Error> {
    // 古い発注書から部品情報を取得。parts に存在しない場合は履歴テーブルの最新版を使用する
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        r#"
        WITH latest_history AS (
            SELECT ph.*, ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name) AS part_name,
            bi.quantity,
            COALESCE(p.unit_price, lh.unit_price) AS unit_price
        FROM bom_items bi
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE bi.bom_id = ?
        ORDER BY bi.sequence
        "#
    )
    .bind(old_bom_id)
    .fetch_all(pool)
    .await?
    .into_iter()
    .map(|(part_number, part_name, quantity, unit_price)| {
        let total_price = unit_price * Decimal::from(quantity);
        OrderItem {
            part_number,
            part_name,
            quantity,
            unit_price,
            total_price,
        }
    })
    .collect();

    // 新しい発注書用に部品Bを部品Dに置き換え
    let mut new_items = old_items.clone();
    for item in &mut new_items {
        if item.part_number == old_part_number_b {
            let new_part: (String, String, Decimal) = sqlx::query_as(
                "SELECT part_number, part_name, unit_price FROM parts WHERE id = ?"
            )
            .bind(new_part_id_d)
            .fetch_one(pool)
            .await?;

            item.part_number = new_part.0;
            item.part_name = new_part.1;
            item.unit_price = new_part.2;
            item.total_price = new_part.2 * Decimal::from(item.quantity);
        }
    }

    let old_total: Decimal = old_items.iter().map(|i| i.total_price).sum();
    let new_total: Decimal = new_items.iter().map(|i| i.total_price).sum();
    let difference = new_total - old_total;

    Ok(OrderComparison {
        old_items,
        new_items,
        old_total,
        new_total,
        difference,
    })
}

さて、2つの SQL でどちらが複雑でしょうか?

    // 古い発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? 
         ORDER BY bi.sequence"
    )

    // 古い発注書から部品情報を取得。parts に存在しない場合は履歴テーブルの最新版を使用する
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        r#"
        WITH latest_history AS (
            SELECT ph.*, ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name) AS part_name,
            bi.quantity,
            COALESCE(p.unit_price, lh.unit_price) AS unit_price
        FROM bom_items bi
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE bi.bom_id = ?
        ORDER BY bi.sequence
        "#
    )

見てわかる通り、物理削除パターンのほうが複雑です。まあ、これはあまりフェアではなくて、古いデータを取得しようとしたときには物理削除パターンのほうが手がかかるのは当たり前のことです。

これを通常の現状あるパーツから検索する SQL 文に置き換えると

論理削除パターン

    // 通常の発注書から部品情報を取得
    let items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? AND p.deleted_at IS NULL
         ORDER BY bi.sequence"
    )

物理削除パターン

    // 通常発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ?
         ORDER BY bi.sequence"
    )

となるので、論理削除の場合は必ず deleted_at IS NULL の条件を付ける必要があります。これは論理削除の場合は弊害ですよね。
deleted_at IS NULL を付けるのを忘れると大変なことになります。逆に言えば、AI エージェントなどを使った場合には AI は忘れないので問題が出ないし、ORM を使う場合も問題が出ません。

たまに、データ量が多くなると deleted_at IS NULL の条件がパフォーマンスに影響を与えることがあるという記事がありますが、それはそもそもテーブル設計が悪いです。その場合は、逆にログやジャーナル用のテーブルから検索用のテーブルと現在用のテーブルに分けるという設計にすべきでしょう。
このあたり、架空の議論に陥りがちなので、このように具体例を示しています。

ケース2

もうひとつケースを考えてみましょう。

客先から発注書を受けたが、発注書に記載されている部品番号をシステムで検索すると出て来ない。どうやら、以前の見積もりを出した時には、調達可能だったもので組み合わせ番号を作成していたのだが、その後に廃盤になってしまったらしい。組み合わせ番号の整備が追い付かず、部品の組み合わせのほうが有効になっているが、部品番号を調べると画面に出て来ないという状況になってしまっている。

発注書に組み合わせ番号が書いてあったときに、その組み合わせに含まれる部品番号が有効であるかどうかのチェックを行いたい。1回目のチェックでは、現状調達できる部品だけでチェックをするが、2回目のチェックでは、履歴テーブルも参照して、過去に存在した部品番号も含めてチェックを行いたい。
1回目と2回目のチェックのメソッドを作成して欲しい。
入力は customer_orders.order_number としたい。

論理削除パターン

use sqlx::{mysql::MySqlPoolOptions, MySql, Pool, Row};

#[derive(Debug)]
struct PartCheck {
    part_number: String,
    part_name: Option<String>,
    quantity: i32,
    status: String, // "available" | "deleted" | "missing"
}

// 現在調達可能な部品だけを見る
async fn check_order_parts_current_logical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
          AND p.deleted_at IS NULL
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: Some(row.get("part_name")),
            quantity: row.get("quantity"),
            status: "available".to_string(),
        })
        .collect())
}

// 削除済みも含めて有効性を判定(論理削除なので履歴は無く、現行テーブルのdeleted_atで判断)
async fn check_order_parts_with_deleted_logical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity,
               p.deleted_at IS NULL AS is_available,
               p.deleted_at IS NOT NULL AS is_deleted
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        LEFT JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| {
            let part_number: Option<String> = row.try_get("part_number").ok();
            let status = if part_number.is_none() {
                "missing"
            } else if row.get::<bool, _>("is_available") {
                "available"
            } else if row.get::<bool, _>("is_deleted") {
                "deleted"
            } else {
                "missing"
            };
            PartCheck {
                part_number: part_number.unwrap_or_else(|| "(none)".to_string()),
                part_name: row.try_get("part_name").ok(),
                quantity: row.get("quantity"),
                status: status.to_string(),
            }
        })
        .collect())
}

物理削除パターン

use sqlx::{mysql::MySqlPoolOptions, MySql, Pool, Row};

#[derive(Debug)]
struct PartCheck {
    part_number: String,
    part_name: Option<String>,
    quantity: i32,
    status: String, // "available" | "historical" | "missing"
}

// 現在調達可能な部品だけを見る(partsのみ)
async fn check_order_parts_current_physical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: Some(row.get("part_name")),
            quantity: row.get("quantity"),
            status: "available".to_string(),
        })
        .collect())
}

// 履歴も参照して有効性を判定(最新履歴を採用)
async fn check_order_parts_with_history_physical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        WITH latest_history AS (
            SELECT ph.*,
                   ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name)   AS part_name,
            bi.quantity,
            CASE
                WHEN p.id IS NOT NULL THEN 'available'
                WHEN lh.history_id IS NOT NULL THEN 'historical'
                ELSE 'missing'
            END AS status
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: row.try_get("part_name").ok(),
            quantity: row.get("quantity"),
            status: row.get("status"),
        })
        .collect())
}

見ての通り、過去のデータを調べる場合は、物理削除パターンのほうが SQL 文が複雑です。特に履歴テーブルを参照する場合には、最新の履歴を取得するためにウィンドウ関数を使う必要があり、SQL 文が大幅に複雑化します。
逆に言えば、過去のデータを調べない限り、物理削除のほうが有利というわけです。
つまりは、要件として「過去のデータを遡って調べることがあるのか?」をチェックしておけば、データ構造を決める上で論理削除にするか物理削除にするかという判断材料になります。
蛇足を言えば、「過去のデータを調べるか否か?」の条件を付けずに、テーブル構造の設計に「論理削除か物理削除か?」を持ち込むこと自体がアンチパターンという訳ですね。

余談ですが、複数テーブルを検索するときに is null チェックが各テーブルに付加されて面倒くさい/間違いが置きやすいという指摘がたびたびありますが、現実的にはそうはならないです。

select *
from A inner join B on A.id = B.a_id and B.deleted_at is null
     B inner join C on B.id = C.b_id and C.deleted_at is null
     C inner join D on C.id = D.c_id and D.deleted_at is null
where A.deleted_at is null;

一見すると、A,B,C,D の全てに is null の条件を付けないような感じがしますが、

select *
from A inner join B on A.id = B.a_id 
     B inner join C on B.id = C.b_id 
     C inner join D on C.id = D.c_id 
where A.deleted_at is null;

運用上は、A.deleted_at is null の条件だけで十分です。

これは SQL の結合の仕組みによるもので、A.deleted_at is null の条件で A とは関係ないレコードを、B,C,D から拾ってくることはないからです。逆に B.deleted_at is not null のレコードを参照していたら、それは運用上のミスか、画面設計でのガード掛かっていないためです。

このあたりも考察していくと、実は論理削除をするにしても、メインとなるテーブルだけに注目すればよいという結論です。

カテゴリー: 開発, データベース | 論理削除と物理削除の続き 部品受注システムを Rust + SQL 文で解いてみる はコメントを受け付けていません

Docker で LibreOffice を動かす

LibreOfficeでHTMLをPDFに変換する #wkhtmltopdf – Qiita
https://qiita.com/sonota88/items/011394d89f370fb3aaac

なところで、Docker で Linux 版の LibreOffice が動くことを知りました。ここ2週間ほど、Excel VBA から離脱して、LibreOffice + Python で何かできないかと模索しているわけですが、どうやら Linux 版は

apt-get install -y libreoffice python3-uno

で、LibreOffice がインストールできます。ちなみに、GUI 部分はちょっとややこしいですが、WSL 上にもインストールできるので、Linux 版の LibreOffice + uno api 開発をするときに便利です…というのが何処かにあったので、今度やってみます。

で、Docker で動かす場合は https://hub.docker.com/r/linuxserver/libreoffice/ があるので、これを使うと便利です。

ひとまず、以下で Docker が動くようになります。

docker run -d \
  --name=libreoffice \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Asia/Tokyo \
  -p 3000:3000 \
  -p 3001:3001 \
  -v /path/to/config:/config \
  --shm-size="1gb" \
  --restart unless-stopped \
  lscr.io/linuxserver/libreoffice:latest

3000 番ポートで、ブラウザ上で動作できます。

ブラウザ上で。

ただし、問題があって、Calc だけ日本語入力が変になります。私の環境では漢字変換状態のときに a キーを打つとリターンキーと同じ状態になるらしく、空白になってしまいます。なんか、二文字目が変なのです。

ちなみに、Writer や Impress だと正常に打てるので、Calc だけのバグのようです。デスクトップ版だと問題なく動いているので、ブラウザ版だけのバグかもしれません。

「名前を付けて保存」ダイアログの拡張子を選択するときのコンボボックスもおかしな動きをするので、なにか環境がおかしいのかもしれません。

カテゴリー: 開発, LibreOffice | Docker で LibreOffice を動かす はコメントを受け付けていません

LibreOffice Calc で内部から自作ライブラリの Python コードを使う

ちまちまと LibreOffice Calc を UNO API 経由でアクセスするライブラリ作成中です。UNO API の構造が複雑怪奇なのは、私が Excel の DOM 構造に慣れているからだと思うのですが、いやぁ、なかなか想像に至らないのが難点です。

さて、ちらほら検索すると Calc を Python でアクセスするという記事はいくつか見つかるのですが、Excel VBA のように使えないのが難点です。Excel VBA のように、「ボタンを押したらマクロが動いて、計算をチェックする」なんてのは、どうやるんでしょうね?

まず、Calc にボタンを配置します。「ボタン」というのがないんで、四角の Shape を使っています。

ボタンを右クリックして「マクロの割り当て」を選択します。

マイマクロの中から目的の Python 関数を選んで(ここでは sample001.py にある write_hello_to_cell 関数)ダブルクリックします。ここで割り当てができます。

この状態で、マウスでボタンを押すと “A1” に時刻が表示されます。

Python マクロの位置は?

Calc ファイルにマクロを埋め込むことができるのですが、編集しやすくするために、ローカルスクリプトの位置に保存します。

C:\Users\masuda\AppData\Roaming\LibreOffice\4\user\Scripts\python\

Windows の場合は、ログインユーザーの AppData の下にあります。python フォルダーがない場合は、作成してください。
LibreOffice 自体は Python マクロを編集する機能がないので、vscode で編集します。

Python マクロの書き方

import datetime as dt

def write_hello_to_cell():
    # LibreOffice のコンテキストを取得
    desktop = XSCRIPTCONTEXT.getDesktop()
    doc = desktop.getCurrentComponent()

    # Calc 以外の文書で実行された場合は無視
    if not hasattr(doc, "Sheets"):
        return

    sheet = doc.Sheets[0]  # 最初のシート
    cell = sheet.getCellByPosition(0, 0)  # A1

    cell.String = "Hello, " + dt.datetime.now().strftime("%H:%M:%S")

g_exportedScripts = (
    write_hello_to_cell,
)

LibreOffice の Python マクロを書くときにいくつかルールがあって、

  1. XSCRIPTCONTEXT から、最初の desktop, doc などを取得する
  2. Python は C:\Program Files\LibreOffice\program\python.exe 固定となる
  3. マクロを登録する関数は g_exportedScripts に追加しておく

Python が LibreOffice のものに固定されているため pip などのライブラリ追加がうまくいかないかもしれません。このあたりは、後で調べてみるのですが、バージョンとしては 3.11.14 なので、それなりに使えるでしょう。Python に詳しくないのでよくわからないのですが。

コード自体は AI エージェント(Copilot、ChatGPTなど)に作って貰うことができます。
最終的には Sheet を扱うことになるので、そのあたりのコード補完ができたらいいのですが、すべてが Any 型になってしまっているのでここをなんとかしないといけません。
で、現在、コード補完用のクラスを作っているわけです。

独自ライブラリの読み込みはできるのか?

できます。ちょっと、コツがいるようですが、下記のような excellike というライブラリを作って、読み込ませることができます。

import パスを追加しないといけないのが難点ですが(これは後でもうちょっと調査)、通常の Python マクロと同様に書けます。

"""LibreOffice Calc sample macro."""

import inspect
import os
import sys

# Ensure this script's directory is importable so the local excellike package resolves
BASE_DIR = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
if BASE_DIR not in sys.path:
    sys.path.append(BASE_DIR)

from excellike.calc.sheet import Sheet

def excellike_write_hello_to_cell():
    # LibreOffice のコンテキストを取得
    desktop = XSCRIPTCONTEXT.getDesktop()
    doc = desktop.getCurrentComponent()

    # Calc 以外の文書で実行された場合は無視
    if not hasattr(doc, "Sheets"):
        return

    sheet = doc.Sheets[0]  # 最初のシート
    sheet = Sheet(sheet)
    sheet.setText(0, 0, "Hello from Python!")



g_exportedScripts = (
    excellike_write_hello_to_cell,
)

ここでは Sheet という独自のラッパークラスを作っています。

from __future__ import annotations
from ..core import InterfaceNames, UnoObject

class Sheet(UnoObject):
    def __init__(self, obj):
        self._obj = obj

    def setText(self, column: int, row: int, text: str) -> None:
        cell = self._obj.getCellByPosition(column, row)
        # LibreOffice Calc cells expose setString/String, not setText
        if hasattr(cell, "setString"):
            cell.setString(text)
        else:
            cell.String = text

マクロを直接実行する

マクロを直接実行する場合は、「ツール」→「マクロ」→「マクロの実行」を選択します。

先の Scripts/python フォルダーにあるマクロが選択できます。

Calc の内から Python マクロを使う時は、XSCRIPTCONTEXT
Calc の外から Python マクロを使う時は、UNO API をポート経由で、

ということになっていて、その後の操作は全く同じです。
なので、この初期処理部分を統一化しておけば、マクロ開発も楽なわけですが。ひとまず、Excel VBA エディタでちまちま開発するよりは、VSCode + Python で開発するのが楽なのでは?というぐらいまでは到達しています。ただし、そのまま UNO API のオブジェクトを使うと全て Any 型なので、入力候補が全くでません。
このあたりのサポートは必須ですね。

カテゴリー: 開発, LibreOffice | LibreOffice Calc で内部から自作ライブラリの Python コードを使う はコメントを受け付けていません

論理削除と物理削除の続き(工場の部品受注システムの例)

論理削除と物理削除の比較のためのテーブル設計がひと通りできたので、具体的にどのように使われるのかを考えてみましょう。

この手のやつは、いちばん複雑だと思われる「工場の部品受注システム」を例にとるとよいです。下手に、やさしい例をだすと何処で問題が起こるのかが分かりにくくなります。

仮説として、つぎのような反論が見つかればよいのです。

  • 論理削除を使ったときに、複雑なクエリを動かしたときに破綻する。
  • 物理削除を使ったときに、過去のデータが検索できなくて大変なことになる。

のどちらかが見つかればよいのです。

前者であれば、この手の複雑なデータ構造の場合には「論理削除を禁止!」できます。逆に、後者の場合では「物理削除だけでは問題が解決できない!」こととなり論理削除を採用する理由が見つかるはずです。

仮説を証明するために

1. 工場の部品受注システムのデータ構造を考える(論理削除型と物理削除型の両方)
2. 複雑なクエリあるいは手順のユースケースを考える
3. それぞれのケースで問題でクエリやコードを書いてみる
4. 問題が起こるかどうかを確認する

ということになります。

いままでは、この手のユースケースを考えたり、実際にクエリやコードを書いたりするのが大変だったのですが、今は AI エージェントがあるので簡単です。AI エージェントに「工場の部品受注システムのデータ構造」を突っ込んでおいて、SQL やコードを書いて貰えばいいのです。

で、そのコードが、人にとって読みやすいか≒人にとって書きやすいか、ということがわかればいいですよね。
逆に将来的に AI エージェントにコーディングを依存した場合に、破綻するようなデータ構造を設計するのを避けるという意味もあります。できる限り、AI が誤解しないようなデータ構造が望まれるでしょう。

データ構造

  • – 部品マスター
  • – 部品補助情報
  • – 部品の調達先
  • – 受注情報
  • – 部品生産管理
  • – 部品の組み合わせテーブル
  • – 受注組みあわせ番号管理
  • – 顧客発注履歴

論理削除型データ構造

-- 部品マスター
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 部品補助情報
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の調達先
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 受注情報
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 部品生産管理
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の組み合わせテーブル(BOM)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);
 
-- BOM マスター
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 受注組み合わせ番号管理
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);
 
-- 顧客発注履歴
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);
 
-- 顧客マスター
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
 
-- サプライヤーマスター
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

前回 Copilot に書いて貰ったデータ構造は、deleted_at カラムがある論理削除型のものでした。これは、AI が吐き出したものですから、多少の修正が必要かもしれませんが、AI がライブコーディングをしやすいデータ構造になっている筈です。

これに命題を与えていきます。

物理削除型データ構造

parts, bom テーブルに deleted_at カラムが入っているので、これを取り除きます。
さらに、parts や bom テーブルの過去のものが参照できるように、履歴用のテーブルを作って貰います。

-- 部品マスター(物理削除)
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 部品マスター履歴
CREATE TABLE parts_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    part_number VARCHAR(64) NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    version INT NOT NULL,
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- BOM マスター(物理削除)
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- BOM マスター履歴
CREATE TABLE bom_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    product_number VARCHAR(64) NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT NOT NULL,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 部品補助情報(物理削除)
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の調達先(物理削除)
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 受注情報(物理削除)
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 部品生産管理(物理削除)
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の組み合わせテーブル(BOM)(物理削除)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);

-- 受注組み合わせ番号管理(物理削除)
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 顧客発注履歴(物理削除)
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);

-- 顧客マスター(物理削除)
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- サプライヤーマスター(物理削除)
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

ER 図を見比べると

  • PARTS_HISTORY
  • BOM_HISTORY

という履歴テーブルが追加されていることが分かります。

ユースケースの検討

具体的にユースケースを考えましょう。
履歴が必要なのは、部品テーブル(parts)と部品の組み合わせテーブル(bom)です。
この2つのテーブルで、過去にさかのぼってデータを参照するパターンを考えます。

ケース1

顧客から古い発注書に基づいて部品の発注を受けた。残念ながら過去の発注のために、発注書の中にある、部品番号は現在扱っていない。この部品番号を新しくして見積もり書を作成したい。
このときに、過去の発注書と合計金額を照合して、差分を計算しておきたい。
部品A,B,C が過去の発注書にあり、部品B が廃盤になっているため、部品D に置き変えたい。

このときに、論理削除型データ構造を使ったときの SQL クエリ、あるいは、PHP/Laravel による検索結果を示してほしい。

論理削除のデータ構造の場合

Laravel/Eloquent のコードの場合は、論理削除を withTrashed メソッドを使って削除済みデータも取得できるようにします。普段の検索のときは、where を使って deleted_at is not null の条件が自動的に追加になるので、ここで混乱することはありません。

namespace App\Services;

use App\Models\CustomerOrder;
use App\Models\Bom;
use App\Models\BomItem;
use App\Models\Part;
use Illuminate\Support\Collection;

class QuoteService
{
    /**
     * @param string $orderNumber  過去の発注番号
     * @param array<string,string> $replacements  ['old_part_number' => 'new_part_number']
     */
    public function buildQuote(string $orderNumber, array $replacements): array
    {
        // 過去の発注を取得(削除済み BOM も拾うため withTrashed)
        $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();
        $bom   = Bom::withTrashed()->findOrFail($order->bom_id);

        // BOM 明細(削除済みパーツも拾うため withTrashed)
        $items = BomItem::where('bom_id', $bom->id)->get()->map(function (BomItem $item) {
            $item->part = Part::withTrashed()->findOrFail($item->part_id);
            return $item;
        });

        $lines = [];
        $oldTotal = 0;
        $newTotal = 0;

        foreach ($items as $item) {
            $oldPart = $item->part;
            $qty     = $item->quantity;
            $newPart = $oldPart;

            // 廃盤品を代替品に差し替え
            if (isset($replacements[$oldPart->part_number])) {
                $newPart = Part::where('part_number', $replacements[$oldPart->part_number])->firstOrFail();
            }

            $lines[] = [
                'old_part_number' => $oldPart->part_number,
                'new_part_number' => $newPart->part_number,
                'quantity'        => $qty,
                'old_price'       => $oldPart->unit_price,
                'new_price'       => $newPart->unit_price,
                'old_subtotal'    => $oldPart->unit_price * $qty,
                'new_subtotal'    => $newPart->unit_price * $qty,
                'replaced'        => $oldPart->id !== $newPart->id,
            ];

            $oldTotal += $oldPart->unit_price * $qty;
            $newTotal += $newPart->unit_price * $qty;
        }

        return [
            'order_number' => $order->order_number,
            'bom_id'       => $bom->id,
            'lines'        => $lines,
            'old_total'    => $oldTotal,
            'new_total'    => $newTotal,
            'diff'         => $newTotal - $oldTotal,
        ];
    }
}

まあ、これは softDelete の機能が Laravel/Eloquent にあるので簡単に書けるという裏技みたいなものなので、同じパターンを C#/LINQ でやってみましょう。

このパターンは C# の EF Core を暫くやっていなかったので、気が付かなかったのですが、IgnoreQueryFilters メソッドがありますね。
deleted_at カラムを除外するためにあらかじめグローバルクエリフィルターを付けておけばよいそうです。

using Microsoft.EntityFrameworkCore;

public class QuoteService
{
    private readonly AppDbContext _db;

    public QuoteService(AppDbContext db) => _db = db;

    // replacements: 古い品番→新しい品番のマップ
    public async Task<QuoteResult> BuildQuoteAsync(string orderNumber, Dictionary<string, string> replacements)
    {
        var order = await _db.CustomerOrders
            .AsNoTracking()
            .FirstOrDefaultAsync(o => o.OrderNumber == orderNumber)
            ?? throw new InvalidOperationException("order not found");

        var bom = await _db.Boms
            .IgnoreQueryFilters() // deleted_at を無視して取得
            .AsNoTracking()
            .FirstOrDefaultAsync(b => b.Id == order.BomId)
            ?? throw new InvalidOperationException("bom not found");

        var items = await _db.BomItems
            .Where(i => i.BomId == bom.Id)
            .AsNoTracking()
            .ToListAsync();

        var lines = new List<QuoteLine>();
        decimal oldTotal = 0;
        decimal newTotal = 0;

        foreach (var item in items)
        {
            var oldPart = await _db.Parts
                .IgnoreQueryFilters()
                .AsNoTracking()
                .FirstOrDefaultAsync(p => p.Id == item.PartId)
                ?? throw new InvalidOperationException("part not found");

            var newPart = oldPart;
            if (replacements.TryGetValue(oldPart.PartNumber, out var newPn))
            {
                newPart = await _db.Parts
                    .AsNoTracking()
                    .FirstOrDefaultAsync(p => p.PartNumber == newPn)
                    ?? throw new InvalidOperationException("replacement not found");
            }

            var qty = item.Quantity;
            var oldSub = oldPart.UnitPrice * qty;
            var newSub = newPart.UnitPrice * qty;

            lines.Add(new QuoteLine
            {
                OldPartNumber = oldPart.PartNumber,
                NewPartNumber = newPart.PartNumber,
                Quantity = qty,
                OldPrice = oldPart.UnitPrice,
                NewPrice = newPart.UnitPrice,
                OldSubtotal = oldSub,
                NewSubtotal = newSub,
                Replaced = oldPart.Id != newPart.Id
            });

            oldTotal += oldSub;
            newTotal += newSub;
        }

        return new QuoteResult
        {
            OrderNumber = order.OrderNumber,
            BomId = bom.Id,
            Lines = lines,
            OldTotal = oldTotal,
            NewTotal = newTotal,
            Diff = newTotal - oldTotal
        };
    }
}

public record QuoteResult
{
    public string OrderNumber { get; init; }
    public long BomId { get; init; }
    public List<QuoteLine> Lines { get; init; } = new();
    public decimal OldTotal { get; init; }
    public decimal NewTotal { get; init; }
    public decimal Diff { get; init; }
}

public record QuoteLine
{
    public string OldPartNumber { get; init; }
    public string NewPartNumber { get; init; }
    public int Quantity { get; init; }
    public decimal OldPrice { get; init; }
    public decimal NewPrice { get; init; }
    public decimal OldSubtotal { get; init; }
    public decimal NewSubtotal { get; init; }
    public bool Replaced { get; init; }
}

コードファーストのときに、OnModelCreating メソッドで、HasQueryFilter を使ってグローバルクエリフィルターを設定しておきます。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // Soft-delete 用フィルター(通常は deleted_at が NULL のみ取得)
    modelBuilder.Entity<Bom>()
        .HasQueryFilter(b => b.DeletedAt == null);

    // 他のエンティティにも必要なら同様に
    // modelBuilder.Entity<Part>().HasQueryFilter(p => p.DeletedAt == null);
}

物理削除のデータ構造の場合

今度は、物理削除のパターンです。

古い parts, bom テーブルのデータは削除されている可能性があるので、履歴テーブルから最新のものを取得する必要があります。findPartWithHistory や findPartByNumberWithHistory といったヘルパーメソッドを用意して、履歴テーブルから最新のデータを取得するようにします。

<?php

namespace App\Services;

use App\Models\CustomerOrder;
use App\Models\Bom;
use App\Models\BomItem;
use App\Models\Part;
use App\Models\PartsHistory;
use App\Models\BomHistory;
use Illuminate\Support\Facades\DB;

class QuoteService
{
    /**
     * @param string $orderNumber 過去の発注番号
     * @param array<string,string> $replacements ['old_part_number' => 'new_part_number']
     */
    public function buildQuote(string $orderNumber, array $replacements): array
    {
        return DB::transaction(function () use ($orderNumber, $replacements) {
            $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();

            // BOM を現行テーブル優先で取得、なければ履歴から最新を取得
            $bom = Bom::find($order->bom_id)
                ?? BomHistory::where('bom_id', $order->bom_id)
                    ->orderByDesc('effective_from')
                    ->firstOrFail();

            // BOM明細は現行を想定(履歴が必要なら別途 bom_items_history を用意して同様に取得)
            $items = BomItem::where('bom_id', $order->bom_id)->get();

            $lines = [];
            $oldTotal = 0;
            $newTotal = 0;

            foreach ($items as $item) {
                $oldPart = $this->findPartWithHistory($item->part_id);
                $qty = $item->quantity;

                $newPart = $oldPart;
                if (isset($replacements[$oldPart['part_number']])) {
                    $newPart = $this->findPartByNumberWithHistory($replacements[$oldPart['part_number']]);
                }

                $oldSub = $oldPart['unit_price'] * $qty;
                $newSub = $newPart['unit_price'] * $qty;

                $lines[] = [
                    'old_part_number' => $oldPart['part_number'],
                    'new_part_number' => $newPart['part_number'],
                    'quantity'        => $qty,
                    'old_price'       => $oldPart['unit_price'],
                    'new_price'       => $newPart['unit_price'],
                    'old_subtotal'    => $oldSub,
                    'new_subtotal'    => $newSub,
                    'replaced'        => $oldPart['id'] !== $newPart['id'] || $oldPart['part_number'] !== $newPart['part_number'],
                ];

                $oldTotal += $oldSub;
                $newTotal += $newSub;
            }

            return [
                'order_number' => $order->order_number,
                'bom_id'       => $order->bom_id,
                'lines'        => $lines,
                'old_total'    => $oldTotal,
                'new_total'    => $newTotal,
                'diff'         => $newTotal - $oldTotal,
            ];
        });
    }

    private function findPartWithHistory(int $partId): array
    {
        $part = Part::find($partId);
        if ($part) {
            return $part->toArray();
        }

        $hist = PartsHistory::where('part_id', $partId)
            ->orderByDesc('effective_from')
            ->firstOrFail();

        return [
            'id'          => $hist->part_id,
            'part_number' => $hist->part_number,
            'unit_price'  => $hist->unit_price,
        ];
    }

    private function findPartByNumberWithHistory(string $partNumber): array
    {
        $part = Part::where('part_number', $partNumber)->first();
        if ($part) {
            return $part->toArray();
        }

        $hist = PartsHistory::where('part_number', $partNumber)
            ->orderByDesc('effective_from')
            ->firstOrFail();

        return [
            'id'          => $hist->part_id,
            'part_number' => $hist->part_number,
            'unit_price'  => $hist->unit_price,
        ];
    }
}

普段は where メソッドを使って parts, bom テーブルから検索するので、特に問題はでません。過去部品だったときに、findPartWithHistory や findPartByNumberWithHistory メソッドを使って履歴テーブルから最新のデータを取得するようにしています。
このケースの場合は「過去から読み取る可能性がある」ことが前提になっているので、withTrashed を使うか findPartWithHistory メソッドのようなヘルパーメソッドを使うかという判断はコーディング時に判明しそうです。

同じパターンを C#/LINQ でやってみましょう。
LINQ の場合は、IgnoreQueryFilters メソッドは使わずに、履歴テーブル(BomHistory、PartsHistory)へのアクセスで過去データの照合を行います。

// 物理削除+履歴テーブルでケース1を処理する C#/EF Core サービス例
using Microsoft.EntityFrameworkCore;

public class QuoteService
{
    private readonly AppDbContext _db;
    public QuoteService(AppDbContext db) => _db = db;

    // replacements: 古い品番→新しい品番
    public async Task<QuoteResult> BuildQuoteAsync(string orderNumber, Dictionary<string, string> replacements)
    {
        var order = await _db.CustomerOrders
            .AsNoTracking()
            .FirstOrDefaultAsync(o => o.OrderNumber == orderNumber)
            ?? throw new InvalidOperationException("order not found");

        // BOM は現行優先、無ければ履歴から最新を取得
        var bom = await _db.Boms.AsNoTracking()
                     .FirstOrDefaultAsync(b => b.Id == order.BomId)
                 ?? await _db.BomHistory.AsNoTracking()
                     .Where(h => h.BomId == order.BomId)
                     .OrderByDescending(h => h.EffectiveFrom)
                     .Select(h => new Bom { Id = h.BomId, ProductNumber = h.ProductNumber, ProductName = h.ProductName })
                     .FirstOrDefaultAsync()
                 ?? throw new InvalidOperationException("bom not found");

        // BOM 明細は現行を想定(履歴が必要なら bom_items_history も同様にフォールバック)
        var items = await _db.BomItems
            .Where(i => i.BomId == order.BomId)
            .AsNoTracking()
            .ToListAsync();

        var lines = new List<QuoteLine>();
        decimal oldTotal = 0, newTotal = 0;

        foreach (var item in items)
        {
            var oldPart = await FindPartWithHistoryAsync(item.PartId);
            var qty = item.Quantity;

            var newPart = oldPart;
            if (replacements.TryGetValue(oldPart.PartNumber, out var newPn))
            {
                newPart = await FindPartByNumberWithHistoryAsync(newPn);
            }

            var oldSub = oldPart.UnitPrice * qty;
            var newSub = newPart.UnitPrice * qty;

            lines.Add(new QuoteLine
            {
                OldPartNumber = oldPart.PartNumber,
                NewPartNumber = newPart.PartNumber,
                Quantity = qty,
                OldPrice = oldPart.UnitPrice,
                NewPrice = newPart.UnitPrice,
                OldSubtotal = oldSub,
                NewSubtotal = newSub,
                Replaced = oldPart.Id != newPart.Id || oldPart.PartNumber != newPart.PartNumber
            });

            oldTotal += oldSub;
            newTotal += newSub;
        }

        return new QuoteResult
        {
            OrderNumber = order.OrderNumber,
            BomId = bom.Id,
            Lines = lines,
            OldTotal = oldTotal,
            NewTotal = newTotal,
            Diff = newTotal - oldTotal
        };
    }

    private async Task<PartDto> FindPartWithHistoryAsync(long partId)
    {
        var part = await _db.Parts.AsNoTracking().FirstOrDefaultAsync(p => p.Id == partId);
        if (part != null) return new PartDto(part);

        var hist = await _db.PartsHistory.AsNoTracking()
            .Where(h => h.PartId == partId)
            .OrderByDescending(h => h.EffectiveFrom)
            .FirstOrDefaultAsync()
            ?? throw new InvalidOperationException("part not found");

        return new PartDto(hist.PartId, hist.PartNumber, hist.UnitPrice);
    }

    private async Task<PartDto> FindPartByNumberWithHistoryAsync(string partNumber)
    {
        var part = await _db.Parts.AsNoTracking().FirstOrDefaultAsync(p => p.PartNumber == partNumber);
        if (part != null) return new PartDto(part);

        var hist = await _db.PartsHistory.AsNoTracking()
            .Where(h => h.PartNumber == partNumber)
            .OrderByDescending(h => h.EffectiveFrom)
            .FirstOrDefaultAsync()
            ?? throw new InvalidOperationException("replacement not found");

        return new PartDto(hist.PartId, hist.PartNumber, hist.UnitPrice);
    }
}

public record QuoteResult
{
    public string OrderNumber { get; init; }
    public long BomId { get; init; }
    public List<QuoteLine> Lines { get; init; } = new();
    public decimal OldTotal { get; init; }
    public decimal NewTotal { get; init; }
    public decimal Diff { get; init; }
}

public record QuoteLine
{
    public string OldPartNumber { get; init; }
    public string NewPartNumber { get; init; }
    public int Quantity { get; init; }
    public decimal OldPrice { get; init; }
    public decimal NewPrice { get; init; }
    public decimal OldSubtotal { get; init; }
    public decimal NewSubtotal { get; init; }
    public bool Replaced { get; init; }
}

public record PartDto
{
    public PartDto(Part p) : this(p.Id, p.PartNumber, p.UnitPrice) { }
    public PartDto(long id, string partNumber, decimal unitPrice)
    {
        Id = id; PartNumber = partNumber; UnitPrice = unitPrice;
    }
    public long Id { get; }
    public string PartNumber { get; }
    public decimal UnitPrice { get; }
}

履歴テーブルを参照する分だけ、物理削除の C#/LINQ のコードの方が長くはなりますが、明示的に BomHistory や PartsHistory を参照しているので、コードを読んだときに「過去データを参照している」ことが分かりやすいという利点があります。

ケース2

もうひとつケースを考えてみましょう。

客先から発注書を受けたが、発注書に記載されている部品番号をシステムで検索すると出て来ない。どうやら、以前の見積もりを出した時には、調達可能だったもので組み合わせ番号を作成していたのだが、その後に廃盤になってしまったらしい。組み合わせ番号の整備が追い付かず、部品の組み合わせのほうが有効になっているが、部品番号を調べると画面に出て来ないという状況になってしまっている。

発注書に組み合わせ番号が書いてあったときに、その組み合わせに含まれる部品番号が有効であるかどうかのチェックを行いたい。1回目のチェックでは、現状調達できる部品だけでチェックをするが、2回目のチェックでは、履歴テーブルも参照して、過去に存在した部品番号も含めてチェックを行いたい。
1回目と2回目のチェックのメソッドを作成して欲しい。
入力は customer_orders.order_number としたい。

論理削除のデータ構造の場合

この場合は、1回目が現行のテーブルで確認。ここで存在しないので、確認のために2回目で履歴を探すという二段階の流れになります。

namespace App\Services;

use App\Models\CustomerOrder;
use App\Models\Bom;
use App\Models\BomItem;
use App\Models\Part;

class BomCheckService
{
    // 現行のみ(deleted_at IS NULL)
    public function checkCurrentByOrder(string $orderNumber): array
    {
        $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();
        $bom   = Bom::findOrFail($order->bom_id);

        $partIds   = BomItem::where('bom_id', $bom->id)->pluck('part_id')->all();
        $existing  = Part::whereIn('id', $partIds)->pluck('id')->all(); // 現行のみ
        $missingId = array_values(array_diff($partIds, $existing));
        $missingPn = Part::withTrashed()->whereIn('id', $missingId)->pluck('part_number')->all();

        return [
            'bom_id'  => $bom->id,
            'missing' => $missingPn,
        ];
    }

    // 履歴も含める(withTrashed で論理削除を許容)
    public function checkWithHistoryByOrder(string $orderNumber): array
    {
        $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();
        $bom   = Bom::withTrashed()->findOrFail($order->bom_id);

        $partIds   = BomItem::where('bom_id', $bom->id)->pluck('part_id')->all();
        $allParts  = Part::withTrashed()->whereIn('id', $partIds)->get(['id','part_number','deleted_at']);
        $foundIds  = $allParts->pluck('id')->all();
        $missingId = array_values(array_diff($partIds, $foundIds));

        $foundInHistory = $allParts->filter(fn($p) => $p->deleted_at !== null)
                                   ->pluck('part_number')->all();
        $unknownPn = Part::withTrashed()->whereIn('id', $missingId)->pluck('part_number')->all();

        return [
            'bom_id'           => $bom->id,
            'missing'          => $unknownPn,      // 現行にも履歴にも無い
            'found_in_history' => $foundInHistory, // 論理削除済み(過去存在)
        ];
    }
}

物理削除のデータ構造の場合

こっちは、BomHistory, PartsHistory を参照して履歴を確認する必要があります。
両方の構造を見ると、論理削除でも物理削除でもあまり変わらないコードになっているので、コードの記述レベルでは、どちらを選んでもよさそうな気がします。
ただ、このテーブル構造の場合は、customer_orders と parts テーブルがやや近いところにあるので、そこだけ見れば構造が分かりやすいためとも言えますね。

namespace App\Services;

use App\Models\CustomerOrder;
use App\Models\Bom;
use App\Models\BomHistory;
use App\Models\BomItem;
use App\Models\Part;
use App\Models\PartsHistory;

class BomCheckService
{
    // 1回目: 現行のみ(物理削除なので履歴は見ない)
    public function checkCurrentByOrder(string $orderNumber): array
    {
        $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();
        $bom   = Bom::findOrFail($order->bom_id);

        $partIds   = BomItem::where('bom_id', $bom->id)->pluck('part_id')->all();
        $existing  = Part::whereIn('id', $partIds)->pluck('id')->all();
        $missingId = array_values(array_diff($partIds, $existing));
        $missingPn = PartsHistory::whereIn('part_id', $missingId)
                        ->orderBy('effective_from', 'desc')
                        ->pluck('part_number')
                        ->unique()
                        ->values()
                        ->all();

        return [
            'bom_id'  => $bom->id,
            'missing' => $missingPn, // 現行テーブルに無い部品番号
        ];
    }

    // 2回目: 履歴も含めて存在確認(parts_history / bom_history を参照)
    public function checkWithHistoryByOrder(string $orderNumber): array
    {
        $order = CustomerOrder::where('order_number', $orderNumber)->firstOrFail();

        // BOM: 現行優先、無ければ履歴の最新を採用
        $bom = Bom::find($order->bom_id)
            ?? BomHistory::where('bom_id', $order->bom_id)
                ->orderByDesc('effective_from')
                ->firstOrFail();

        $partIds = BomItem::where('bom_id', $order->bom_id)->pluck('part_id')->all();

        // 現行 + 履歴
        $current = Part::whereIn('id', $partIds)->get(['id','part_number']);
        $hist    = PartsHistory::whereIn('part_id', $partIds)
                     ->orderByDesc('effective_from')
                     ->get(['part_id','part_number']);

        $foundIds = $current->pluck('id')->all();
        $histIds  = $hist->pluck('part_id')->all();
        $missingId = array_values(array_diff($partIds, array_unique(array_merge($foundIds, $histIds))));

        $foundInHistory = $hist->pluck('part_number')->unique()->values()->all();
        $unknownPn      = PartsHistory::whereIn('part_id', $missingId)
                               ->orderByDesc('effective_from')
                               ->pluck('part_number')
                               ->unique()
                               ->values()
                               ->all();

        return [
            'bom_id'           => $bom->id,
            'missing'          => $unknownPn,      // 現行にも履歴にも無い
            'found_in_history' => $foundInHistory, // 履歴で存在した部品番号
        ];
    }
}

これだけだと、双方で差異がみられないので、引き続き。

カテゴリー: 開発, データベース | 論理削除と物理削除の続き(工場の部品受注システムの例) はコメントを受け付けていません

論理削除と物理削除の続き

論理削除と物理削除の切り分けを具体例を示して思考実験する

前記事の続きです。既にブーム?は去っているのですが(苦笑)、昔の原理原則にとらわれ過ぎると思考停止に陥ってしまうので、最近のフレームワークも含めて再考しましょう、ってのが主旨です(もちろん、この記事自体も 10 年も経てば古くなるわけですが)。

残りのモデルパターンを考えていきます。

  • 回転寿司の受注システム
  • 税務関係の社内経理システム、経費処理
  • 社内のナレッジ管理、退職者の管理

これらのテーブルを眺めた後で、実際に SQL や ORM を使ってどのようになるのかを推測してみればよいわけです。

回転寿司の受注システム

これは新人研修用に作ったので、これをベースにします。

-- テーブル席
CREATE TABLE seats (
    id INT PRIMARY KEY AUTO_INCREMENT,
    table_number INT NOT NULL,
    status ENUM('available', 'occupied') DEFAULT 'available',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- テーブル注文
CREATE TABLE orders (
    id INT AUTO_INCREMENT,
    seat_id INT NOT NULL,
    order_id INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_seat_id (seat_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 注文履歴
CREATE TABLE order_history (
    id INT PRIMARY KEY AUTO_INCREMENT,
    order_id INT NOT NULL,
    seat_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_order_id (order_id),
    INDEX idx_seat_id (seat_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 商品マスタ
CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    description TEXT,
    image_url VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- カテゴリ
CREATE TABLE categories (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 商品カテゴリ
CREATE TABLE product_categories (
    product_id INT NOT NULL,
    category_id INT NOT NULL,
    PRIMARY KEY (product_id, category_id),
    INDEX idx_product_id (product_id),
    INDEX idx_category_id (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 商品在庫
CREATE TABLE product_stock (
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    PRIMARY KEY (product_id),
    INDEX idx_product_id (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

ちょっと新人研修用に作ったので deleted_at が入っていないのですが。これも BOM 表と同じパターンで、deleted_at を入れるパターンが考えられます。

商品マスター(寿司)の値段が変わった時には、products.price を更新することになるのですが、過去の注文履歴を残すために deleted_at カラムを入れるか、過去データのテーブルを用意するかしなくてはいけません。このときに、リアルタイムに価格の変更がかかるのか、それとも日次や週次の単位で価格の変更がかかるのか?によって、設計が違っています。
当たり前のようですが、1日の商売のうちに店内では価格の変更がされることはないでしょう。その場合には、店内の客が終わった後に精算処理を行うバッチで、過去の商品データに移したり、お客の履歴検索を調節したりすることが可能です。
しかし、WEB サイト予約のような(寿司の場合はあるとは思えないけど)、24時間運用で、リアルタイムに価格が切り替わる場合はどうでしょうか? この場合には deleted_at カラムのほうがよさそうな気がします。タイミング的に、注文 → 精算 の間に時間差があるので、注文時の価格を保存しておくか、精算時に価格がずれていた(該当する商品IDがマッチしなかった)ときを考慮する必要がでてきます。

いわゆる、時間差の有無、更新→参照のタイミングで差がでてくる場合、を考慮するか否か、がポイントになります。まあ、寿司注文の場合は、あまりリアルタイムに価格が変わることはないと思いますが。

税務関係の社内経理システム、経費処理

経費処理の場合はもう少し長いスパンで決済のタイミングが異なります。たとえば、同じ PC を買ったとしても購入時期により価格が変わったり消費税が変わったりすることはよくあります。
この経費精算を後から修正するパターンを考えてみましょう。社員が経費精算をするときに入力した値はなんらかの形で保持しておいて、後から経理部門や税理士が修正するパターンです。

  • 物品購入テーブル
  • 経費精算申請
  • 経費精算承認フロー
  • 経費精算の修正
  • 会計仕分けテーブル
  • 経費項目マスタ
  • 消費税マスタ
-- 物品購入テーブル
CREATE TABLE purchases (
    id INT PRIMARY KEY AUTO_INCREMENT,
    employee_id INT NOT NULL,
    purchase_date DATE NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    tax_rate DECIMAL(5, 2) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_employee_id (employee_id),
    INDEX idx_purchase_date (purchase_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 経費精算申請
CREATE TABLE expense_claims (
    id INT PRIMARY KEY AUTO_INCREMENT,
    employee_id INT NOT NULL,
    purchase_id INT NOT NULL,
    claimed_amount DECIMAL(10, 2) NOT NULL,
    expense_category_id INT NOT NULL,
    claim_date DATE NOT NULL,
    status ENUM('draft', 'submitted', 'approved', 'rejected') DEFAULT 'draft',
    notes TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    INDEX idx_employee_id (employee_id),
    INDEX idx_purchase_id (purchase_id),
    INDEX idx_status (status),
    FOREIGN KEY (purchase_id) REFERENCES purchases(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 経費精算承認フロー
CREATE TABLE expense_approvals (
    id INT PRIMARY KEY AUTO_INCREMENT,
    expense_claim_id INT NOT NULL,
    approver_id INT NOT NULL,
    approval_status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending',
    comment TEXT,
    approved_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_expense_claim_id (expense_claim_id),
    INDEX idx_approver_id (approver_id),
    FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 経費精算の修正履歴
CREATE TABLE expense_revisions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    expense_claim_id INT NOT NULL,
    original_amount DECIMAL(10, 2) NOT NULL,
    revised_amount DECIMAL(10, 2) NOT NULL,
    reason TEXT,
    revised_by INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_expense_claim_id (expense_claim_id),
    FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 会計仕分けテーブル
CREATE TABLE journal_entries (
    id INT PRIMARY KEY AUTO_INCREMENT,
    expense_claim_id INT NOT NULL,
    account_code VARCHAR(20) NOT NULL,
    debit DECIMAL(10, 2) DEFAULT 0,
    credit DECIMAL(10, 2) DEFAULT 0,
    entry_date DATE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_expense_claim_id (expense_claim_id),
    INDEX idx_account_code (account_code),
    FOREIGN KEY (expense_claim_id) REFERENCES expense_claims(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 経費項目マスタ
CREATE TABLE expense_categories (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    account_code VARCHAR(20),
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 消費税マスタ
CREATE TABLE tax_rates (
    id INT PRIMARY KEY AUTO_INCREMENT,
    rate DECIMAL(5, 2) NOT NULL,
    effective_date DATE NOT NULL,
    expired_date DATE,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

これも AI に作って貰ったパターンなので、 deleted_at カラムをチェックします。経費精算申請テーブルや経費項目マスタに入っていますね。

経費精算申請(expense_claims)は、申請した状態を status カラムで保持しているので、deleted_at カラムの意味はなんらかの形で “完全に削除する” という意味になります。申請自体は approved で完了となるので、approved の後に社内経理で削除にするのか、それとも draft の段階なのでデータ的に削除するのかという意味でしょう。
ここは経理システムの履歴に関するものなので、

  • 厳格な会計システムとして履歴を重視したいときは、DELETE を禁止して、deleted_at で対応する
  • ある程度、柔軟に運用したいときは、deleted_at カラムを使わずに DELETE してしまう
  • 操作履歴を残すために、トリガーを使って履歴テーブルに INSERT する

などの手段が考えられます。これは設計上で揺れるところかなと。

経費項目マスタ(expense_categories)の場合は、過去の id を参照する必要があるのでテーブル内に残しておいたほうが無難です。deleted_at カラムを意識する場合は、UI から選択するパターンなど限定的になるので間違いは起こりにくいかなと思われます。

社内のナレッジ管理、退職者の管理

典型的な社内SNSの運用を考えてみましょう。社内ナレッジでは掲示板を運営していて、投稿ログが表示されます。しかし、投稿者自身は退職する可能性があるので、退職者の情報(部署、権限など)をどうするのか? という問題を考えます。もともと、部署は変わる可能性があるので、投稿権限や閲覧権限は時間を経ると変わっていきます。ただし、投稿記事や投稿ログ自体は、当時の記録として残しておきたいパターンです。

  • 投稿記事テーブル
  • 投稿者ログ
  • 投稿記事のカテゴリ
  • 投稿記事の閲覧権限
  • 社員マスタ
  • 部署マスタ

※ 投稿記事には、投稿した社員の当時の部署/投稿名が表示される
※ 社員の部署は変わる可能性がある。
※ 社員が退職する場合も考慮する。

-- 社員マスタ
CREATE TABLE employees (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    INDEX idx_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 部署マスタ
CREATE TABLE departments (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 社員部署履歴
CREATE TABLE employee_departments (
    id INT PRIMARY KEY AUTO_INCREMENT,
    employee_id INT NOT NULL,
    department_id INT NOT NULL,
    assigned_at DATE NOT NULL,
    unassigned_at DATE NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_employee_id (employee_id),
    INDEX idx_department_id (department_id),
    FOREIGN KEY (employee_id) REFERENCES employees(id),
    FOREIGN KEY (department_id) REFERENCES departments(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 投稿記事カテゴリ
CREATE TABLE post_categories (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 投稿記事
CREATE TABLE posts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    employee_id INT NOT NULL,
    category_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    INDEX idx_employee_id (employee_id),
    INDEX idx_category_id (category_id),
    INDEX idx_posted_at (posted_at),
    FOREIGN KEY (employee_id) REFERENCES employees(id),
    FOREIGN KEY (category_id) REFERENCES post_categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 投稿ログ(投稿当時の社員情報スナップショット)
CREATE TABLE post_logs (
    id INT PRIMARY KEY AUTO_INCREMENT,
    post_id INT NOT NULL,
    employee_id INT NOT NULL,
    employee_name VARCHAR(100) NOT NULL,
    department_name VARCHAR(100),
    posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_post_id (post_id),
    INDEX idx_employee_id (employee_id),
    FOREIGN KEY (post_id) REFERENCES posts(id),
    FOREIGN KEY (employee_id) REFERENCES employees(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 投稿記事の閲覧権限
CREATE TABLE post_permissions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    post_id INT NOT NULL,
    department_id INT,
    is_public BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_post_id (post_id),
    INDEX idx_department_id (department_id),
    FOREIGN KEY (post_id) REFERENCES posts(id),
    FOREIGN KEY (department_id) REFERENCES departments(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

多分、この手の DB は定番だと思うのですが AI が作成すると deleted_at カラムが結構入っています。おそらく、React や Vue などを使って、自動生成するパターンが多いからでしょう。

興味深いのは投稿記事(posts)テーブルには deleted_at が入っていますが、投稿ログ(post_logs)テーブルには deleted_at が入っていません。基本的にログは消すことがない、むしろ消してはいけないので deleted_at はつけません。改変できないようにするためです。
投稿記事の閲覧権限(post_permissions)テーブルにも deleted_at は入っていません。このような補助テーブルは主となるテーブル(posts)に依存しているので、主テーブルが削除されれば(この場合は is_deleted フラグで論理削除ですが)、補助テーブルのほうは検索で自動的に除外されるためです。なので、補助テーブルには deleted_at は不要です。

このあたりから考えて、論理削除(deleted_at)と物理削除(DELETE)の切り分けには一定のルールがあります。ただし、投稿記事(posts)テーブルをどう扱うか、部署や権限などが変更する場合はどちらを採用するのか? に関しては、利用する場面をよく考えたほうがよい、という指針になります。

これでひと通り、DDL と ER 図を示すことができたので、次回は具体的に SQL や ORM を使ったときの具体例を考えていきましょう。

カテゴリー: 開発, データベース | 論理削除と物理削除の続き はコメントを受け付けていません

論理削除と物理削除の切り分けを具体例を示して思考実験する

おそらく、過去にたくさんの記事が書かれているし「論理削除とは?」とかいうブログ記事も既にあるので、そちらの方を参考にしてもいいのですが、X/Twitter でたびたび炎上するのと、実際の開発現場でも議論が分かれるところなので、私なりの指針を示しておきます。

最初に断っておきますが、基本的に私は “削除フラグを作らない” 人です。古くから DB を設計してきた人にとって安易な is_deleted フラグの追加は結構な鬼門です。あと、90 年代からデータベースにさわっていると、データ量が多くなるという点で is_deleted フラグを使いません。削除できるデータは削除してしまってデータベースの容量を減らすほうに重きを置きます。
しかし、昨今のストレージ容量の多さや、データのアクセス性能の向上、コードファースト/ORM を利用した開発スタイルの普及により、論理削除を使う場面が多くなっています。

ゆえに、現在では「ある程度のフレームワーク/ORM を使った開発スタイルであれば、論理削除を使うことも良い」と考え直しています。
つまり、場合によりけり、ということなのですが、その「場合」とは一体なのか? ということですね。

  • データ量の多さ、少なさ、100万件以上、それ以下
  • サブクエリで参照されやすい、されない、深いところで参照される
  • 削除される頻度は、極少、なし、頻繁、1/10 程度
  • テーブル数は 100 個ぐらい、20 個以上
  • フレームワークは、単一言語、複数種類で多言語
  • データの時間性は、ある、なし

という視点があります。テーブル単位で逐一調べる、あるいは開発単位やフレームワークを基準にして考える必要があり、非常に面倒です。ええ、面倒なんですよ…これ。なので、単純に「論理削除をすべきかどうか?」という話ではない、ことに注意してください。

これを具体的に考察するために、具体的なユースケースを考えてみましょう。最近では AI エージェントを使ってコードを出力できるので結構短時間で実験ができます。いくつか手元の AI エージェントを使って自分の使っているプログラム言語&フレームワークで試してみてください。

ここでは、実例として、

  • 自治体の住民検索、社会保険等
  • 旅館の予約システム
  • 工場の部品受注システム
  • 回転寿司の受注システム
  • 税務関係の社内経理システム、経費処理
  • 社内のナレッジ管理、退職者の管理

を具体的に考えてみます。

コードの出力は以下で試してみます。

  • PHP + Laravel + Eloquent ORM
  • C# + LINQ + Entity Framework
  • Java + Spring + Hibernate?(最近 Java を触れていないので…)
  • Ruby + ActiveRecord
  • Python + SQLAlchemy ? (これもよく知らない。勉強中)
  • TypeScript + Node.js + Prisma ? (これもよく知らない。勉強中)

まずは削除の手法

最初にデータ削除の手法だけ紹介しておきます。

基本情報処理試験的には、DELETE してしまうのが「物理削除」、is_delete のようなフラグを使うのが「論理削除」と言いますが。これは基本的な知識として覚えておきます。

物理削除

DELETE FROM users WHERE user_id = 12345;

単純にデータを削除してしまう方法です。テーブルからデータが消えるので後から検索をしたりするときに、変なデータが残って検索されてしまうことはありません。

さらに言えば、万が一データが漏れたとしても、過去のデータは消え去ってしまっているので、漏洩リスクが減るというメリットもあります。

SELECT * FROM users ;

検索するときはシンプルです。

後から何かを復活しようとしたり、過去のデータを遡って調べようとしない限りは DELETE を使った削除で十分です。

フラグによる論理削除

UPDATE users SET is_deleted = true WHERE user_id = 12345;
UPDATE users SET is_deleted = now() WHERE user_id = 12345;

is_deleted のようなフラグを使うパターンですね。上記のように boolean 型で設定する方法と、削除した日付を入れておく方法があります。
is_deleted の型をどう設定するのか、NULL を許容するのか、しないのか、の設計の違いがあります。

SELECT * FROM users WHERE is_deleted is NULL ;

当然検索するときに is_deleted のチェックが必要なのでひと手間かかります。
ここを忘れるのが開発上のネックになります。
特に多人数で開発しているときや、複数の会社で開発しているときはこの is_deleted チェックを忘れることが多いです。

しかし、だからダメというわけではありません。これは ORM によっては、is_delete を自動的にチェックしてくれます。
例えば、Laravel + Eloquent ORM では、Soft Delete という機能があって、

$items = $table->items() ;

という形で、内部では自動的に is_delete チェックが働いています。
また、集計関数などでも自動的に is_delete チェックが働きます。

$count = $table->total() ;

なので、”単一の開発言語で同一フレームワークを使っている” 場合限り、 is_delete フラグを使った論理削除は非常に便利です。
逆に言えば、他のプログラム言語や別々のフレームワークを使い始めたときには、is_delete チェックが問題になることが多いですね。

別テーブルによるデータ退避

かつては、元データを残したいときには別テーブルに退避することが多いです。
ただし、これは削除トリガーを使うとかしないと結構面倒くさいので、削除があまり発生しないテーブルに対してという限定手段になるでしょう。
あと、退避テーブルの構造を元のテーブルと同じにしないといけないので、テーブル設計が意外と面倒です。

BEGIN TRANSACTION;
INSERT INTO users_deleted SELECT * FROM users WHERE user_id = 12345;
DELETE FROM users WHERE id = 12345;
COMMIT;

2つの SQL 文が必ず動くので、トランザクションが必須です。
この部分は、DELETE トリガーを使ったも構いません。SQL だと、いちいち退避コードを書くのが面倒なので、トリガーを使った方が楽です。ただし、一気に DELETE するとその分トリガーも呼び出されてしまうので、その点は注意が必要です。

さらに言えば、これでは不十分で、users テーブルでは user_id がユニークキーになっているのですが、二重にデータを退避できません。例えば、id が一意、user_id がユーザーに見えるようなキー情報だと考えてみましょう。

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    user_id VARCHAR(64) UNIQUE,
    name VARCHAR(128),
    ...
);

このような場合、退避テーブル users_deleted を二重に退避できるようにするには、次のようなテーブルになります。

CREATE TABLE users_deleted (
    users_deleted_id BIGINT PRIMARY KEY,
    deleted_at TIMESTAMP,
    id BIGINT,
    user_id VARCHAR(64),
    name VARCHAR(128),
    ...
);

退避テーブルにユニークキーを置くかどうか、という設計もあり(ユニークキーがないと ORM からアクセスできないことが多い)、テーブル構造が users と users_deleted で異なります。
ここも設計ルールということになりますが、なかなか統一性を保つのは難しいですよね。

補助テーブルを使う

実は is_deleted フラグ自体は、主体となる user 情報の補助であるので、補助テーブルとして正規化してしまうのもひとつの方法です。

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    user_id VARCHAR(64) UNIQUE,
    name VARCHAR(128),
    ...
);
CREATE TABLE users_status (
    user_id BIGINT PRIMARY KEY,
    is_deleted BOOLEAN,
    deleted_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

常に users と users_status を JOIN して使わないといけないのが面倒ではありますが、users テーブルの内容が明確になります。ある意味で、一度作成したら絶対に消えません。
あと、本来はカード番号とか住所とか、セキュリティ上分離しておいたほうが良い情報を、users_status テーブルに保持しておくと漏洩のリスクが減ります。

これらは、あくまでデータを削除するための手法であったり、データを復活するような場面があったらどうするのか? セキュリティの確保や開発時のミスをどのように防ぐのか? という部分最適化の話になっています。

なので、ひとつの開発手法としては、

  • 物理削除するならば、全体を物理削除で統一する
  • is_deleted フラグを使う論理削除ならば、全体を論理削除で統一する

のがひとつの手段です。
これだと話が簡単なのですが、当然、それぞれの場面がありパーフォーマンスや開発ミスなどを考慮すると、そうでもないことが多いのです。

具体例をあてはめてみる

では、最初に示したように具体例を当てはめてみましょう。

  • 自治体の住民検索、社会保険等
  • 旅館の予約システム
  • 工場の部品受注システム
  • 回転寿司の受注システム
  • 税務関係の社内経理システム、経費処理
  • 社内のナレッジ管理、退職者の管理

データ量や時間軸の有無なども関係しているのですが、そこは具体例をみたほうがイメージしやすいと思います。
SQL 文やアクセスのための ORM コードを AI エージェントを使って書き出してみてください。昔は、手作業でちまちま整合性を確認していたのですが、今だと OpenAPI.xml を作成して MVC マッピングのコードを出力させることができます。そのなかで ORM や SQL を使ったデータ抽出を試してみるとよいです。

以下は、私の都合上 Laravel + Eloquent ORM の例を示しておきます。

自治体の住民検索、社会保険等

実は自治体のガバクラで「論理削除」が結構揉めていた時期があります。最終的には決裂してしまったのですが、さて、それからどうなったのかは私は知りません。

要は、自治体の住民情報は「調べたときのその時の情報」になります。過去の遡って転出してしまった住民の情報を調べることは…できなくもないのですが、それらを Web API で提供するかどうかという話になります。ガバクラで、その API 仕様があったのがそのあたりの難点になります。

  • 自治体での住民情報の管理
  • 住民の入退出の情報
  • 社会保険の加入、脱退の情報
-- 自治体の住民情報スキーマ例
CREATE TABLE residents (    -- 住民基本台帳
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    resident_id VARCHAR(64) UNIQUE NOT NULL,
    name VARCHAR(128) NOT NULL,
    address VARCHAR(255) NOT NULL,
    birth_date DATE NOT NULL,
    status ENUM('active', 'moved_out', 'deceased') DEFAULT 'active',
    registered_at TIMESTAMP NOT NULL,
    status_changed_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

CREATE TABLE resident_histories ( -- 住民の入退出履歴
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    resident_id BIGINT NOT NULL,
    event_type ENUM('registration', 'move_in', 'move_out', 'status_change') NOT NULL,
    address VARCHAR(255) NULL,
    event_date TIMESTAMP NOT NULL,
    remarks TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (resident_id) REFERENCES residents(id)
);

CREATE TABLE insurance_records ( -- 社会保険加入履歴
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    resident_id BIGINT NOT NULL,
    insurance_type VARCHAR(64) NOT NULL,
    status ENUM('enrolled', 'withdrawn') DEFAULT 'enrolled',
    enrolled_date DATE NOT NULL,
    withdrawn_date DATE NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (resident_id) REFERENCES residents(id)
);

residents 住民基本台帳には deleted_at カラムを設けて論理削除を可能にしていますが、実際にはステータス status カラムでチェックすることになります。このテーブルでは、住民が自治体にいるときは「active」になるので、必ず where status = ‘active’ の検索が必要になります。
例えば、住民が 30 万人程度だとしても、転出や転入、死亡なども含めれば毎年結構な量になりますよね。大抵は active な筈ですが、長年使っていると active 以外が多くなりそうな可能性が高いです。

個人の一意性は、residents.resident_id で特定ができるので、他テーブルを使うことも可能なのですが、ちょっと待ってください。この個人が、同じ自治体に転出→転入をしたときはどうなるのでしょうか?
ひとりの個人に対して、ふたつ以上の resident_id になります。
なので、履歴自体を resident_histories や insurance_records テーブルで保持できるように管理していて「ひとりの個人の過去の履歴を取ってくる」ことが一見できそうな感じがしていて、実はできません。

-- residents.resident_id を指定して、住民の入退出履歴と社会保険加入履歴を取得する例
SELECT rh.*, ir.*
FROM residents r
LEFT JOIN resident_histories rh ON r.id = rh.resident_id
LEFT JOIN insurance_records ir ON r.id = ir.resident_id
WHERE r.resident_id in ( 'some_resident_id', 'another_resident_id' )
  AND r.status = 'active';

この場合、residents.is_deleted チェックは、何を意味するのでしょうか?
既に、住民であるか否かは status カラムとして用意してあるので、is_delete は住民の有無をしめしません。
利用パターンとしては、「一時的に転入情報を個人として入れておいたが、実はデータの間違いが発生したのを削除しようとした」ぐらいと思われます。
つまり、この is_delete フラグは、住民基本台帳のデータとしてはかなり補助的な役割を示していると考えられ、status 的な意味とは異なります。更に言えば、後から戻すことも考えられません。転出したときのデータは status で管理するのですから、住民基本台帳である residents テーブルは “消されることがない” ことになります。
データとして増加し続けるのは容量的に問題だとは思いますが、たかだか 30 万人(10年後だったとしても 300 万人のレコードにはならないでしょう)ぐらいならば、ストレージを十分にとっておけば大丈夫そうです。

そういう試算をしたうえで、residents.is_deleted フラグは不要と考えられます。

-- 自治体の住民情報スキーマ例
CREATE TABLE residents (    -- 住民基本台帳
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    resident_id VARCHAR(64) UNIQUE NOT NULL,
    name VARCHAR(128) NOT NULL,
    address VARCHAR(255) NOT NULL,
    birth_date DATE NOT NULL,
    status ENUM('active', 'moved_out', 'deceased') DEFAULT 'active',
    registered_at TIMESTAMP NOT NULL,
    status_changed_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

さらに、自治体のデータベースアクセスは、単一のフレームワークからだけアクセスするとは限りません。将来的にさまざまな業者がプログラムを作ることになると考えられます。

こんな感じで、is_delete フラグの利用有無を考えていきます。

旅館の予約システム

今度は、旅館の予約システムを考えてみましょう。WEB サイトから旅館を予約します。ユーザーが部屋の空きがあるところを選んで予約します。当然、ユーザーログインが必要になり、ユーザーによるキャンセルも発生します。

  • 旅館の部屋情報
  • 部屋の空き/予約情報
  • 予約カレンダー
  • ユーザー情報
  • ユーザーの予約履歴
  • ユーザーのキャンセル履歴(やっぱり予約したいとか)

AI エージェントを使って、DDL を作成します。このとき、旅館の部屋の価格が期間によって異なるのでこれも追加します。

-- 旅館の部屋情報
CREATE TABLE rooms (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    room_number VARCHAR(64) UNIQUE NOT NULL,
    room_type VARCHAR(64) NOT NULL,
    capacity INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE room_pricing (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    room_id BIGINT NOT NULL,
    season_start_date DATE NOT NULL,
    season_end_date DATE NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (room_id) REFERENCES rooms(id),
    UNIQUE KEY unique_pricing (room_id, season_start_date, season_end_date)
);

-- 予約情報
CREATE TABLE reservations (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    room_id BIGINT NOT NULL,
    check_in_date DATE NOT NULL,
    check_out_date DATE NOT NULL,
    status ENUM('confirmed', 'cancelled', 'completed') DEFAULT 'confirmed',
    reserved_at TIMESTAMP NOT NULL,
    cancelled_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (room_id) REFERENCES rooms(id),
    UNIQUE KEY unique_reservation (room_id, check_in_date, check_out_date)
);

-- ユーザー情報
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(128) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(128) NOT NULL,
    phone VARCHAR(20) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- キャンセル履歴
CREATE TABLE cancellation_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    reservation_id BIGINT NOT NULL,
    cancelled_at TIMESTAMP NOT NULL,
    reason TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (reservation_id) REFERENCES reservations(id)
);

ここで、部屋 rooms と部屋の価格 room_pricing は、基本的に削除されないように設計されてしまいますが、いや、そんなことはないですよね。部屋が老朽化して使えなくなったり、旅館自体が閉鎖されたりすることもあります。そういう場合は、rooms テーブルから削除しないといけません。しかし、削除してしまうと過去の部屋の予約履歴がなくなってしまいます。id のリンクが外れてしまうわけです。
この場合、rooms.id を残しつつ、同じ room_number を再利用できるようにしなければいけません。
ここで、is_deleted フラグを使うのか、別テーブルに退避するのかという設計の選択が必要になります。
rooms の一意性を確保したい場合には、別テーブルに退避して、過去の経理情報を調べるときには退避テーブルのほうも参照する、という設計にすることになります。
ただし、rooms テーブルのデータ数は非常に少ないと考えられます。さらに言えば、頻繁に変わることはないでしょう。

そこを考慮したうえで rooms テーブルに is_deleted フラグを追加することにしましょう(もちろん、逆に is_deleted フラグを追加しない設計もありえます)。

-- 旅館の部屋情報(削除を考慮)
CREATE TABLE rooms (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    room_number VARCHAR(64) NOT NULL,
    room_type VARCHAR(64) NOT NULL,
    capacity INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

room_number がユニークキーにならないので、注意してください。
ただし、過去も含めて room_number だけで検索することができます。過去の年間の予約状況を調べるときに便利ですね。

-- 過去の部屋の予約状況を調べる例
SELECT r.room_number, res.check_in_date, res.check_out_date, res.status
FROM rooms r
LEFT JOIN reservations res ON r.id = res.room_id
WHERE r.room_number = '101'
AND res.status = 'completed'  -- 完了した予約のみ
AND res.check_in_date between '2023-01-01' AND '2023-12-31'
ORDER BY res.check_in_date DESC;

過去の場合は is_delete のチェックを外します。

逆に言えば、現在利用できる rooms のリストでは is_deleted チェックが必須です。

-- 現在利用可能な部屋のリストを取得する例
SELECT *
FROM rooms
WHERE deleted_at IS NULL;

これは、どちらを主要に使うかですね。
主に経理系や過去の取引履歴などを参照することが多い場合は、is_deleted フラグを使ったほうが便利です。

工場の部品受注システム

過去のデータを遡らなくてはいけないのは、おそらく工場の BOM 関係のシステムです。
部品自体は BOM 情報としてスナップショットがいいのですが、過去の受注状態を顧客から調べるときには BOM 表にないデータも必要になります。つまりは、生産終了したものも含めないといけないので、ここも BOM テーブルに残さないといけません。
これも、現在持っている部品と、生産中止した部品を別テーブルに持つことも可能なのですが、かなり煩雑です。部品に関連する情報が常に補助テーブルを参照するようになってしまいます。

  • 部品マスター
  • 部品補助情報
  • 部品の調達先
  • 受注情報
  • 部品生産管理
  • 部品の組み合わせテーブル
  • 受注組みあわせ番号管理
  • 顧客発注履歴

このパターンで AI に作成して貰います。

-- 部品マスター
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

-- 部品補助情報
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の調達先
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 受注情報
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 部品生産管理
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の組み合わせテーブル(BOM)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);

-- BOM マスター
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);

-- 受注組み合わせ番号管理
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 顧客発注履歴
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);

-- 顧客マスター
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- サプライヤーマスター
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

この状態で deleted_at が付いているのは、

  • parts テーブル
  • bom テーブル

の 2 つです。部品 parts テーブルは、過去の受注履歴を調べるときに必要になります。BOM テーブルも同様です。生産管理の部品調達は、他社のコードと自社のコードが混在します。更に、現在生産可能なもの調達可能なもの、調達不可能なものが混在するわけです。これを期間で管理する必要もあり、調達不可能な場合にはほかの会社に切り替える必要がでてくるので、ここも調節しないといけません。

これらを過去の情報も含めて、顧客から参照できるようにする(過去の調達の組み合わせや履歴を参照できるようにするため)ので、テーブル数が膨大になります。ここではサンプルなのでこの程度なのですが、実際には 100 テーブルを超えてしまいます。

そんな中で、統一的に is_deleted にするのか、物理削除にするのか、というのはかなり不可能に近いです。集計クエリの対象になりやすいとか、過去を参照せずに、物理削除で十分であるとか、そのような考慮をいれてそれぞれのテーブルの設計を行わなければいけません。

いったんここで区切り

この記事、結構長くなりそうなので、いったんここで区切ります。

  • 回転寿司の受注システム
  • 税務関係の社内経理システム、経費処理
  • 社内のナレッジ管理、退職者の管理

については次回で。

単なる「論理削除/物理削除とは?」という単純な話ではなくて、具体的にユースケースを出して考えるとよいです。先にも書きましたが AI エージェントを使うと SQL とか ORM のサンプルコードを出してくれるので、実験がしやすくなっています。仮説を立てるだけでなく、検証がやりやすくなったので活用してください。

続き 2025/12/22

論理削除と物理削除の続き https://www.moonmile.net/blog/archives/11878

カテゴリー: 開発, データベース | 論理削除と物理削除の切り分けを具体例を示して思考実験する はコメントを受け付けていません

LibreOffice Calc + Python で単票(請求書)を作成

手もとにある Excel で作成した請求書を LibreOffice Calc に変換していきます。

請求書のような単票は、Word で作るのが普通だと思うのですが、Excel のほうが意外と楽だったりします。まあ、C# で単票を操作したときに Word よりも Excel でレイアウトを作ったほうが楽だったという理由と、Excel/Word の両方に対応するのは面倒だったというのも大きな理由ですが、

  • 数値や文字列などを隠しデータシートに書き込んでおく
  • 印刷用のシートからデータシートを参照させる

の2手順にしておくとプログラムが楽になります。
いわゆる、プログラムから書き込むセルの位置を固定にできる利点があります。単票のレイアウトが変わったときは、データシートはそのままの形式にして、印刷用のシートを変更すればよいのです。

単票のテンプレート

印刷用のシートを作成しておきます。

データシートはこんな感じです。印刷用のシートから “=$Data.B1” のように参照します。

印刷プレビューはこんな感じ。

ひとつ注意しないといけないのは、セルにファンクションを入れると「0」が出てしまうことがあります。これは Excel でも LibreOffice Calc でも、こんな感じで「ゼロ値」のチェックを外すと表示が消えるようになります。

で、Excel の場合はこれで消えるのですが、LibreOffice Calc ではバグなのか仕様なのか、印刷するときや PDF に出力するときには「0」が出てしまいます。これ、きっとバグなんですが…仕方がないので、書式で設定します。

これが結構面倒で、文字列の場合と金額の場合は書き分けないといけません。

文字列の場合
0;-0;;@
数値の場合
[>0]0;[<0]-0;"";@
金額の場合
[>0]"\"#,##0;[<0][RED]"\-"#,##0;"";@

「0」が表示される場合は、こんな感じになります。

これ「ゼロ値」外せば大丈夫なときと駄目なときがあるので、LibreOffice のバージョンで確認してください。ちょっと不安定っぽいです。

Python コード

以下のコードで請求書の Calc ファイルが生成されるようになります。

import uno
from pathlib import Path

# サーバーの起動
# & "C:\Program Files\LibreOffice\program\soffice" --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo
# PowerShell での起動
# & "C:\Program Files\LibreOffice\program\python" .\calcBill001.py

"""
請求テンプレート(.ots)をテンプレートとして新規ドキュメントで開き、Dataシートに書き込んで請求書を生成するサンプル。

手順:
1. 「請求書テンプレート」(bill-template.ots) をテンプレートとして新規ドキュメントを開く
2. シート名「Data」にサンプルデータを書き込む(B1:宛名, B2:件名 など固定セルに投入)
3. 明細行(任意で複数)を書き込む。必要ならテンプレートの行書式を複製してからデータ投入。
4. 別名で保存
"""


# 設定
TEMPLATE_NAME = "bill-template.ots"
OUTPUT_NAME = "bill-output.ods"
DATA_SHEET_NAME = "Data"
ITEM_START_ROW = 5  # 明細の開始行(0-based, 10行目). テンプレート構成に合わせて調整
ITEM_TEMPLATE_ROW = 8  # 書式をコピーする元行(0-based). 明細の1行目など。


def to_file_url(path: Path) -> str:
	return uno.systemPathToFileUrl(str(path.resolve()))


def connect_desktop():
	local_ctx = uno.getComponentContext()
	resolver = local_ctx.ServiceManager.createInstanceWithContext(
		"com.sun.star.bridge.UnoUrlResolver", local_ctx
	)
	ctx = resolver.resolve(
		"uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext"
	)
	smgr = ctx.ServiceManager
	desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
	doc = desktop.getCurrentComponent()
	if doc is None:
		raise RuntimeError("アクティブな Calc ドキュメントが見つかりません")
	return desktop, doc


def create_doc_from_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = [
		uno.createUnoStruct("com.sun.star.beans.PropertyValue"),
		uno.createUnoStruct("com.sun.star.beans.PropertyValue"),
	]
	props[0].Name = "AsTemplate"
	props[0].Value = True
	props[1].Name = "Hidden"  # UIが欲しい場合は False に変更
	props[1].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, tuple(props))


def set_scalar(sheet, address: str, value):
	try:
		sheet.getCellRangeByName(address).setString(str(value))
	except Exception:
		pass

def write_items(sheet, items, start_row: int, start_col: int = 0):
	"""明細行を書き込む。 items は2次元配列。"""
	for r, row in enumerate(items):
		for c, value in enumerate(row):
			cell = sheet.getCellByPosition(start_col + c, start_row + r)
			if isinstance(value, (int, float)):
				cell.setValue(value)
			else:
				cell.setString(str(value))


def main():
	base_dir = Path(__file__).resolve().parent
	template_path = base_dir / TEMPLATE_NAME
	output_path = base_dir / OUTPUT_NAME

	# サンプル固定項目
	fields = {
		"B1": "株式会社サンプル 様",  # 宛名
		"B2": "12月分 請求書",       # 件名
		"B3": "2512-01",        # 請求番号
		"B4": "2025/12/17",        # 請求日
		"B18": "これはサンプル請求書です",  # 備考
	}

	# 明細: [No, 摘要, 数量, 単位, 単価]
	items = [
		[1, "商品A", 2, "個", 1500],
        [2, "商品B", 1, "式", 3000],
        [3, "商品C", 5, "個", 800],
		[4, "商品D", 3, "個", 1200],
	]

	if not template_path.exists():
		raise FileNotFoundError(f"テンプレートが見つかりません: {template_path}")

	desktop, _ = connect_desktop()
	doc = create_doc_from_template(desktop, template_path)
	sheets = doc.Sheets

	if not sheets.hasByName(DATA_SHEET_NAME):
		raise RuntimeError("テンプレートに Data シートがありません")
	data_sheet = sheets.getByName(DATA_SHEET_NAME)

	# 固定セルを書き込み
	for addr, val in fields.items():
		set_scalar(data_sheet, addr, val)

	write_items(data_sheet, items, start_row=ITEM_START_ROW, start_col=1)

	# 保存
	store_props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	store_props[0].Name = "FilterName"
	store_props[0].Value = "calc8"
	doc.storeToURL(to_file_url(output_path), store_props)
	print(f"請求書を保存しました: {output_path}")

	# 後処理
	try:
		doc.dispose()
	except Exception:
		pass


if __name__ == "__main__":
	try:
		main()
	except Exception as e:
		print(f"error: {e}")


これでいったん、表形式の帳票と単票の作成ができることが確認できました。

自分が業務で使っているパターンとしては網羅できたので、あとは使い勝手です。これらの Python コードはほとんどが AI エージェントで作成して貰っているので、手が出しずらい状態になっています。あくまで使い捨てのコーディングになっているので、細かいところを修正しようとするとプロンプトから手をいれることになってしまいます。

  • せめて Calc を操作するところの UNO API のコード補完が欲しい → スタブの作成
  • 構造体の作成で createUnoStruct が必要になるので、これを適当にラップする
  • getCellByPosition を Cells に変えるとか、VBA に寄せた命名にしたい

このあたりは、年明けぐらいに試してみる予定。

カテゴリー: 開発, LibreOffice | LibreOffice Calc + Python で単票(請求書)を作成 はコメントを受け付けていません