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 で単票(請求書)を作成 はコメントを受け付けていません

LibreOffice Calc で Python から帳票作成(テンプレートファイルの利用)

前回からの続き。

半日ほど調べて、LibreOffice Calc では、印刷用のページテンプレート(ヘッダーやフッターなど)を UNO API からは操作できないことがわかりました。ドラッグ&ドロップでコピーできないのも変だけどマクロからも操作ができませんね。

仕方がないので、あらかじめ印刷用のページテンプレートを設定したファイル(*.ots)を作っておいて、それを利用することにします。別に普通の Calc ファイル(*.ods)を使っても作れます。

ページスタイルの作成

あれこれ探すとページスタイルが作れるようになりました。
「スタイルペインを開く」→「ページスタイルアイコンをクリック」という手順でページスタイル(いわゆるシートに紐づけるスタイル)を作成&設定できます。デフォルトで「標準」と「レポート」というスタイルが作っていあるので、ここでは「帳票テンプレート」というスタイルを作成しました。

ヘッダー、フッターの仕方は Excel の設定と似たような感じです。

確か Excel では印刷プレビューから飛べたはずなのですが、LibreOffice Calc では飛べません。まあ、あまり印刷をしないのかもしれません。ただし、文書として PDF に変換することが多いでしょうから、こういう印刷スタイルも整備してほしいかなと。

このファイルを「report-template.ots」あるいは「report-template.ods」のように保存しておきます。テンプレートファイルの *.ots にしてしまうと、

C:\Users\Tomoaki\AppData\Roaming\LibreOffice\4\user\template\report-template.ots

こんな感じの LibreOffice のテンプレート用のフォルダーに保存されてしまうので結構面倒です。テンプレート文書とはいえ Python マクロから参照するだけなので、プログラムと同じ場所に置いたほうが実験がしやすいと思います。

テンプレート文書を開いて、別名で閉じる

いろいろ試したのですが、以下の手順が一番安定しています。

  1. テンプレート文書を開く
  2. データ等を書き込む
  3. 別名でファイルを保存して閉じる

途中の動作を確認しようかと思って、テンプレート文書のほうの Calc を開いたままにしようかと思ったのですが、Calc が落ちます。どうやら、UI で何か受け付けようとしてうまくいっていないようです。マクロを動かしている Calc を Hidden にしておくと安定します。

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" .\calcReport002.py

"""
テンプレートから新規ドキュメントを作成して Calc レポートを生成するサンプル。

手順:
1. 「帳票テンプレート」(report-template.ots) をテンプレートとして新規ドキュメントを開く
2. サンプルデータの行数に合わせて明細行の書式を複製
3. データを書き込む
4. 別名で保存
"""


# 設定: テンプレートと出力先
TEMPLATE_NAME = "report-template.ots"
OUTPUT_NAME = "report-output.ods"
TEMPLATE_SHEET_NAME = "Template"  # テンプレート内の帳票シート名
REPORT_SHEET_NAME = "Report"      # 必要ならこの名前のシートを使用(存在しなければ先頭シートを使う)


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 load_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	props[0].Name = "Hidden"
	props[0].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, props)


def ensure_rows_with_format(sheet, template_row_idx: int, start_row: int, rows_needed: int, last_col: int):
	template_range = sheet.getCellRangeByPosition(0, template_row_idx, last_col, template_row_idx)
	source_addr = template_range.getRangeAddress()
	template_height = sheet.getRows().getByIndex(template_row_idx).Height

	for i in range(rows_needed):
		target_row = start_row + i
		if target_row == template_row_idx:
			continue
		dest_addr = uno.createUnoStruct("com.sun.star.table.CellAddress")
		dest_addr.Sheet = source_addr.Sheet
		dest_addr.Column = 0
		dest_addr.Row = target_row
		sheet.copyRange(dest_addr, source_addr)
		sheet.getRows().getByIndex(target_row).Height = template_height


def write_data(sheet, data, start_row: int = 1, start_col: int = 0):
	for r, row in enumerate(data):
		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

	sample_data = [
		["001", "Alice", "Sales", 1200.5],
		["002", "Bob", "Marketing", 980.0],
		["003", "Carol", "Engineering", 1540.75],
		["004", "Dave", "Support", 760.25],
	]

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

	desktop, _ = connect_desktop()
	doc = load_template(desktop, template_path)

	# 帳票シートを取得(指定名がなければ先頭シート)
	sheets = doc.Sheets
	if sheets.hasByName(REPORT_SHEET_NAME):
		report_sheet = sheets.getByName(REPORT_SHEET_NAME)
	elif sheets.hasByName(TEMPLATE_SHEET_NAME):
		report_sheet = sheets.getByName(TEMPLATE_SHEET_NAME)
	else:
		report_sheet = sheets.getByIndex(0)

	# データ件数に合わせて行を用意(ヘッダー1行 + データ行)
	last_col = len(sample_data[0]) - 1 if sample_data else 0
	ensure_rows_with_format(
		sheet=report_sheet,
		template_row_idx=1,
		start_row=1,
		rows_needed=len(sample_data),
		last_col=last_col,
	)

	# データを書き込み(1行目はヘッダー想定)
	write_data(report_sheet, sample_data, start_row=1, start_col=0)
	# シート名を変更
	report_sheet.Name = "給与レポート"

	# 別名で保存
	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 コードを整理しますが、肝は以下の部分です。

def load_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	props[0].Name = "Hidden"
	props[0].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, props)

ここで Hidden の値を False にすると Calc が表示されるようになりますが、頻繁に Calc が落ちます。UNO API で操作しているときは、UI を止めたほうがよさそうです。

帳票作成

作成できた report-output.ods がこれです。

印刷プレビュー

ヘッダー、フッターの操作はできないので、あらかじめテンプレート文書に入れておきます。マクロから操作できないのは痛いのですが、社名などは固定になるし印刷したときの日時はヘッダー/フッターの機能として用意されているので、あまり不便にはならないでしょう。

複数ページになったときの設定とかは Excel と同じなので、これもいけると思います。

次は、裏にデータシートを設定して関数とかでセル参照をしているときの動作ですね。請求書とかを作るときに便利な方法です。

カテゴリー: 開発, LibreOffice | LibreOffice Calc で Python から帳票作成(テンプレートファイルの利用) はコメントを受け付けていません

LibreOffice Calc で Python から帳票作成(シートのスタイル以外は)

Excel から LibreOffice Calc に移行するときに、いわゆる帳票の作成ができるか否か私的には結構な肝になります。ちまちまと Calc で罫線を引いているのならば別なのですが、プログラムを使い自動化をしているとちょっと状況が異なります。

ClosedXMLを使って、超高速にリスト形式の帳票を作成する https://www.moonmile.net/blog/archives/9672

自分が良く使っているのは(これは逆引き大全にも入っているけど)、こんな風に C# + ClosedXML で帳票を自動作成するパターンです。帳票を作るツールはいくつかあるのですが、この場合は帳票のテンプレートをカスタムしやすくするように、テンプレートを Excel 自身で作成しています。罫線やら印刷時のタイトルなどはテンプレートとなる Excel ファイルに書いておきます。そして、シートに数値や文字列だけを C# から書き込むようにします。

このようにすると、

  • 年次のバージョンアップのように、帳票が変化してもプログラムに手を入れなくて済む
  • プログラムをつくるときにも罫線の修正や出力位置は Excel を使うことができるので、帳票政策が楽

と言う利点があります。欠点としては

  • Excel を業務 PC に入れないといけない

ですね。当然といえば当然なのですが、C# から Excel オブジェクトを扱うために(実際には ClosedXML なので、OpenXML のほうなのですが)Excel 自体が必須になってしまいます。

実は、ファイル作成だけならば Excel は必要がないのですが、印刷時に Excel オブジェクトが必要なのと、帳票のシート自体の編集に Excel が必要なので、開発者はともかくとしてお客にも Excel が必要になってしまいます。まあ、大抵は Excel を持っているからいいんですが。

Excel + C# + ClosedXML の組み合わせを LibreOffice Calc に変換できるか?

肝は、Excel + ClosedXML の組み合わせが、LibreOffice Calc + UNO API でできるか?ということです。私としては C# から UNO API を使えればよいのですが、前記事から C# から UNO API を使うには難点があるので、素直に Python でコーディングをします。

まあ、コーディングをするといってもベースを作るのは AI エージェントなので、それを動かしてちょこちょこと Python コードを修正することになります。

帳票テンプレート

まず、帳票テンプレート(report-template.ods)を用意します。

テンプレートのシートには、罫線やタイトル行の色を付けておきます。他にも行の高さも変えます。セルのフォーマットも変えておきましょう。実際の帳票を作るように試してみるのがよいです。

印刷時のヘッダーやフッター、タイトル行なんかも入れておくとよいです。

このテンプレートのシートは、作成するシートとしてコピーされてるので、このような印刷フォーマットなども使えます。先の C# + ClosedXML の例もそうなのですが、この手の細かい調節をプログラムですべてやるのは大変です。複雑な処理をしたい場合は、別途帳票ツールを購入したほうが良いでしょう。ただし、そこまで複雑すぎないとか、年単位で帳票のフォーマットが変わってしまうとか、そういうカスタムが多い場合は、こんな形で Excel あるいは Office Calc でテンプレートとなるシートを作ったほうが楽です。

実際、先の業務では20帳票位のテンプレートを Excel で自作しています。その分、プログラムコードが激減するのです。

AI エージェントに渡すプロンプト

実装するためのプロンプトは以下の通りです。


"""
テンプレートファイルを使って Calc レポートを作成するサンプル

# 実装手順

1. テンプレートファイルを開く
2. テンプレートシートをコピーして新しいシートを作成
3. サンプルデータの行数を計算する
4. シートの1行目はヘッダー行なのでそのまま
5. 2行目以降にサンプルデータをデータの行数分だけ用意する。
 5.1 行の罫線も一緒にコピーして、行数を増やす
6. データを行数分書き込む

"""

何等かのシステムならば色々目的なり設計を書いたほうがいいのですが、今回は既に作成されている Excel + ClosedXML のコードを模倣したいので、その手順を直接書いています。C# のコードを渡して「UNO API + Python に書き直して」でも通じるかもしれませんが、今回は少し丁寧に手順を書いています。業務コードのほうは、業務特有の複雑なパターンが掛かれているので、余計な情報を詰め込まないといけないので、ここではサンプル帳票にあわせます。

できあがったコード

少し手直しをしていますが、ほぼそのまま AI エージェント(GPT-5.1-Codex-Max)で書いたものです。行の高さが上手くコピーできていなかったので、後からプロンプトで足しています。

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" .\calcReport001.py

"""
テンプレートファイルを使って Calc レポートを作成するサンプル

# 実装手順

1. テンプレートファイルを開く
2. テンプレートシートをコピーして新しいシートを作成
3. サンプルデータの行数を計算する
4. シートの1行目はヘッダー行なのでそのまま
5. 2行目以降にサンプルデータをデータの行数分だけ用意する。
 5.1 行の罫線も一緒にコピーして、行数を増やす
6. データを行数分書き込む

"""


# 設定: テンプレートと出力先
TEMPLATE_NAME = "report-template.ods"
OUTPUT_NAME = "report-output.ods"
TEMPLATE_SHEET_NAME = "Template"
REPORT_SHEET_NAME = "Report"


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 load_template(desktop, template_path: Path):
	url = to_file_url(template_path)
	props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	props[0].Name = "Hidden"
	props[0].Value = True
	return desktop.loadComponentFromURL(url, "_blank", 0, props)


def import_template_sheet(target_doc, template_doc, template_sheet_name: str, report_sheet_name: str):
	target_sheets = target_doc.Sheets
	if target_sheets.hasByName(report_sheet_name):
		target_sheets.removeByName(report_sheet_name)

	# importSheet: 第3引数は挿入位置(末尾に追加)
	target_sheets.importSheet(template_doc, template_sheet_name, target_sheets.getCount())

	# 取り込んだシート名はテンプレートのものなので、目的の名前に変更
	imported_sheet = target_sheets.getByName(template_sheet_name)
	imported_sheet.Name = report_sheet_name
	return imported_sheet


def ensure_rows_with_format(sheet, template_row_idx: int, start_row: int, rows_needed: int, last_col: int):
	template_range = sheet.getCellRangeByPosition(0, template_row_idx, last_col, template_row_idx)
	source_addr = template_range.getRangeAddress()
	template_height = sheet.getRows().getByIndex(template_row_idx).Height

	for i in range(rows_needed):
		target_row = start_row + i
		if target_row == template_row_idx:
			continue  # テンプレート行は既に存在
		dest_addr = uno.createUnoStruct("com.sun.star.table.CellAddress")
		dest_addr.Sheet = source_addr.Sheet
		dest_addr.Column = 0
		dest_addr.Row = target_row
		sheet.copyRange(dest_addr, source_addr)
		sheet.getRows().getByIndex(target_row).Height = template_height


def write_data(sheet, data, start_row: int = 1, start_col: int = 0):
	for r, row in enumerate(data):
		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

	sample_data = [
		["001", "Alice", "Sales", 1200.5],
		["002", "Bob", "Marketing", 980.0],
		["003", "Carol", "Engineering", 1540.75],
		["004", "Dave", "Support", 760.25],
	]

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

	desktop, active_doc = connect_desktop()
	template_doc = load_template(desktop, template_path)

	# テンプレートドキュメントからシートをアクティブドキュメントへコピー
	report_sheet = import_template_sheet(
		target_doc=active_doc,
		template_doc=template_doc,
		template_sheet_name=TEMPLATE_SHEET_NAME,
		report_sheet_name=REPORT_SHEET_NAME,
	)

	# データ件数に合わせて行を用意(ヘッダー1行 + データ行)
	last_col = len(sample_data[0]) - 1 if sample_data else 0
	ensure_rows_with_format(
		sheet=report_sheet,
		template_row_idx=1,  # テンプレートの明細行(2行目)
		start_row=1,
		rows_needed=len(sample_data),
		last_col=last_col,
	)

	# データを書き込み(1行目はヘッダー想定)
	write_data(report_sheet, sample_data, start_row=1, start_col=0)

	# 別名で保存
	store_props = (uno.createUnoStruct("com.sun.star.beans.PropertyValue"),)
	store_props[0].Name = "FilterName"
	store_props[0].Value = "calc8"
	active_doc.storeToURL(to_file_url(output_path), store_props)
	print(f"レポートを保存しました: {output_path}")

	# 後処理: そのまま開いたままでも良いが、ここではクローズ
	template_doc.dispose()


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


書き込むデータが sample_data として直書きになっていますが、これは SQLite や CSV ファイルなどから読み込むように変更すればいいでしょう。別途 Calc のシートにあるデータを参照してもよいかもしれません。別シートのデータを参照するのは、請求書などの1枚帳票でやることが多いので、後で試してみましょう。

動作確認

Python で実行させるとこんな感じです。現在開いている Calc に新しいシート「Report」が追加されます。ここでは、Calc を表示した状態にしていますが、自動でファイルに保存することができます。もともと、AI エージェントのコードは画面に表示せず、ファイルに保存するだけのものになっています。

ここでは、確認の意味もあって、Calc を表示させたままにしています。

印刷プレビューの状態

シート自体はうまくいっているのですが、印刷時のスタイルがうまくいかないようです。
どうやら、LibreOffice Calc では、シートをUIでドラッグ&ドロップしたときでもシート単位でのスタイルが設定されないようで、これは仕様みたいですね。


def import_template_sheet(target_doc, template_doc, template_sheet_name: str, report_sheet_name: str):
	target_sheets = target_doc.Sheets
	if target_sheets.hasByName(report_sheet_name):
		target_sheets.removeByName(report_sheet_name)

	# importSheet: 第3引数は挿入位置(末尾に追加)
	target_sheets.importSheet(template_doc, template_sheet_name, target_sheets.getCount())

	# 取り込んだシート名はテンプレートのものなので、目的の名前に変更
	imported_sheet = target_sheets.getByName(template_sheet_name)
	imported_sheet.Name = report_sheet_name

	# ページスタイル(印刷の向きやヘッダー/フッター等)をプロパティ単位でコピー
	template_styles = template_doc.getStyleFamilies().getByName("PageStyles")
	target_styles = target_doc.getStyleFamilies().getByName("PageStyles")
	page_style_name = imported_sheet.PageStyle

	if page_style_name:
		template_style = template_styles.getByName(page_style_name)
		if not target_styles.hasByName(page_style_name):
			new_style = target_doc.createInstance("com.sun.star.style.PageStyle")
			target_styles.insertByName(page_style_name, new_style)
		target_style = target_styles.getByName(page_style_name)

		# よく使うプロパティを安全に転写
		for prop in [
			"IsLandscape",
			"HeaderIsOn",
			"FooterIsOn",
			"PrintHeaders",
			"PrintGrid",
			"LeftMargin",
			"RightMargin",
			"TopMargin",
			"BottomMargin",
			"ScaleToPages",
			"ScaleToPagesX",
			"ScaleToPagesY",
		]:
			try:
				setattr(target_style, prop, getattr(template_style, prop))
			except Exception:
				pass

		imported_sheet.PageStyle = page_style_name
	return imported_sheet

試しにスタイルを手動でコピーするコードを入れてみたのですがうまくいきません。

【LibreOffice/Basic】Calcでアクティブシートにページスタイル(ページ設定)を追加するマクロ #LibreOfficeBasic – Qiita https://qiita.com/brunojacopteteth/items/23f614afc6b1b929c297

Basic のマクロならばできるようなので、「標準」ではなくて、新しいスタイルシートを作成して追加すればよさそうです。

「書式」→「ページスタイル」では、ページスタイルを編集できないっぽいので、マクロ専用なのか、それとも何か別な方法があるのか?

このあたりは、もうちょっと調べる必要あり。

カテゴリー: 開発, LibreOffice | LibreOffice Calc で Python から帳票作成(シートのスタイル以外は) はコメントを受け付けていません

LibreOffice Calc を C# からアクセスする…が、できません

最終的には Python か VB あたりで動かしたいのですが、ちょっと寄り道。

Python コードを書くときに型チェックが出来なくて困っています…と言いますか、Python の場場合にはプログラミングをするときにプロパティ名とかメソッド名とかどうやって補完しているのでしょうか?

*.pyi でスタブを作る

LibreOffice の UNO API は戻り値が Any なのでコード補完が効きません。多分、戻り値が object なので、そういう仕様になっているのでしょうが、コードを書くときに面倒です。というか、どのメソッドを呼び出せるのか実行時にしかわかりません。
ひょっとしてメソッド名を全部覚えているのか? とも思わなくもないのですが、もっと敷居を下げておきたいです。自分のためにも。

おそらく、C++、Java で UNO API を使うためには大量のインターフェースを定義してあるはずです。

from typing import TYPE_CHECKING, Protocol, cast, Any

class XCell(Protocol):
    def getString(self) -> str: ...
    def setString(self, value: str) -> None: ...
    def getFormula(self) -> str: ...
    def setFormula(self, formula: str) -> None: ...
    def getValue(self) -> float: ...
    def setValue(self, value: float) -> None: ...

class XCellRange(Protocol):
    def getCellByPosition(self, column: int, row: int) -> XCell: ...
    def getCellRangeByPosition(self, startColumn: int, startRow: int, endColumn: int, endRow: int) -> 'XCellRange': ...
    def getCellRangesByName (self, aRangeName: str) -> tuple['XCellRange', ...]: ...
    def getRangeAddress(self) -> CellRangeAddress: ...

class XNameAccess(Protocol):
    def getByName(self, name: str) -> XComponent: ...
    def hasByName(self, name: str) -> bool: ...
    def getElementNames(self) -> tuple[str, ...]: ...
    def getEmbeddedObject(self) -> XChartDocument: ...

class XChartDocument(Protocol):
    def setDiagram(self, diagram: XDiagram) -> None: ...
    def createInstance(self, serviceName: str) -> Any: ...

class XDiagram (Protocol):
    pass

class XTableCharts(Protocol):
    def addNewByName(self, name: str, aRect: Any, aRanges: tuple[str, ...], bColumnHeaders: bool, bRowHeaders: bool) -> None: ...
    def hasByName(self, name: str) -> bool: ...
    def removeByName(self, name: str) -> None: ...
    def getByName(self, name: str) -> XNameAccess: ...
...

こんな感じで calc.pyi というスタブを作っておいて、本体のコードで参照させます。

import uno
import re
from typing import Any, TYPE_CHECKING, cast

# typings/calc.pyi は型チェック専用に読み込む(実行時はフォールバック)
if TYPE_CHECKING:
	from calc import XDesktop, XComponent, XSpreadsheet, XCellRange, Rectangle  # type: ignore
else:
	XDesktop = XComponent = XSpreadsheet = XCellRange = Rectangle = Any

"""
棒グラフを作成するスクリプト
"""


def connect_to_libreoffice() -> tuple[XDesktop, XComponent, XSpreadsheet]:
	"""LibreOffice に接続してアクティブな Calc シートを取得する"""
	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 : XDesktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)

	model = desktop.getCurrentComponent()
	if model is None:
		raise RuntimeError("LibreOffice Calc ドキュメントが開かれていません")

	sheet = model.getCurrentController().getActiveSheet()
	return desktop, model, sheet

if TYPE_CHECKING のところで、コーディング時と実行時の切り替えをしなといけないのが面倒なのですが、これで Any となっている型を無理矢理 XCellRange などの型に変えることができます。
本来は、cast 関数を使って正式にキャストをすればいいのですが、どうせ Any からのキャストでしかないので、変数の型だけ決めて、そこに無理やり押しこみます。

desktop : XDesktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)

実行時の型チェックではなくて、あくまでコーディング時のコード補完のためなのでこれで十分です。

pyi のスタブはどう作るのか?

UNO API のクラスは膨大にあって、ちまちま手作業で変換するのは大変です。いや、そもそも使いたいのは Calc 関係だけなので、全部を変換するのは大袈裟だし、手間がかかります。
基本は AI エージェントにコードを作って貰うつもりなので、すべての型が必要なわけではありません。ちょっと手直しをするとか、数行のマクロコードを書くときにコード補完ができればよいのです。

C++ や Java のライブラリを利用すればいいのでは?

UNO API のインターフェースは、Python だけではありません。C++ や Java からも使うことができます。ドキュメントは非常に少ないですが、C# から、つまり .NET からのアクセスも可能となっています。

Copilot に聞くと unoidl.dll というのがあるそうです(実際にはありません。後述するように、cli_oootypes.dll 等のファイル名に変わっています)。これをうまく使えば自動生成ができるかもしれませんね。

SDK をインストールする

ここでは、他のドキュメントを見るために SDK をインストールしていますが、実行時には必要ありません。C# の場合は、適当な *.dll を実行ファイルと一緒のフォルダーに入れることになるので、客先の PC に SDK を入れなくてもよいです。

SDK and Sourcecode
https://www.libreoffice.org/download/download-libreoffice

これをインストールすると LibreOffice のマクロドキュメントなどがインストールされます。
フォルダー名は C:\Program Files\LibreOffice\sdk\ です。

IDL リファレンスは本家 https://api.libreoffice.org/ にアクセスすると重たくて仕方がないので、ローカル PC に入れて検索すると良いです。懐かしの doxygen による自動生成です。

C# でプロジェクト参照する

適当なプロジェクトを作って、C:\Program Files\LibreOffice\sdk\cli\ にある *.dll をプロジェクトから参照します。

主に cli_oootypes に各種のクラス定義が入っています。

これをうまくピックアップしながら *.pyi のスタブファイルを作っていけばよいわけです。

これは今後の課題。

おまけとして、C# でアクセスできるのか?

結論から言うと現状の 25.8.3 では動作しません。これは .NET 関係で作られているラッパーがおかしいのか、プログラムがおかしいのか不明ですが、UNO API サーバーに繋がりません。

using System;
using System.Diagnostics;
using uno;
using uno.util;
using unoidl.com.sun.star.bridge;
using unoidl.com.sun.star.container;
using unoidl.com.sun.star.frame;
using unoidl.com.sun.star.lang;
using unoidl.com.sun.star.sheet;
using unoidl.com.sun.star.table;
using unoidl.com.sun.star.text;
using unoidl.com.sun.star.uno;
using unoidl.com.sun.star.beans;

class Program
{
    static void Main()
    {
        try
        {
            Console.WriteLine("Starting...");
            string[] programPathCandidates =
            {
                @"C:\\Program Files\\LibreOffice\\program",
                @"C:\\Program Files (x86)\\LibreOffice\\program"
            };

            string? libreOfficeProgramPath = null;
            foreach (var p in programPathCandidates)
            {
                if (System.IO.Directory.Exists(p))
                {
                    libreOfficeProgramPath = p;
                    break;
                }
            }

            if (libreOfficeProgramPath == null)
            {
                Console.WriteLine("LibreOffice program folder not found. Please adjust path.");
                foreach (var p in programPathCandidates)
                {
                    Console.WriteLine($"Tried: {p}");
                }
                return;
            }

            Console.WriteLine($"UNO_PATH target: {libreOfficeProgramPath}");

            // Ensure UNO can locate LibreOffice binaries.
            Environment.SetEnvironmentVariable("UNO_PATH", libreOfficeProgramPath, EnvironmentVariableTarget.Process);
            var bootstrapIni = System.IO.Path.Combine(libreOfficeProgramPath, "fundamental.ini");
            Environment.SetEnvironmentVariable("URE_BOOTSTRAP", $"vnd.sun.star.pathname:{bootstrapIni}", EnvironmentVariableTarget.Process);

            string ureBin = System.IO.Path.Combine(libreOfficeProgramPath, "..\\URE\\bin");
            string newPathSegment = libreOfficeProgramPath + ";" + ureBin;

            string? currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
            if (!currentPath.Contains(libreOfficeProgramPath, StringComparison.OrdinalIgnoreCase) || !currentPath.Contains(ureBin, StringComparison.OrdinalIgnoreCase))
            {
                Environment.SetEnvironmentVariable("PATH", newPathSegment + ";" + currentPath, EnvironmentVariableTarget.Process);
            }

            Environment.SetEnvironmentVariable("UNO_SERVICES", System.IO.Path.Combine(libreOfficeProgramPath, "uno_services.rdb"), EnvironmentVariableTarget.Process);

            // Some environments need the working directory inside the LibreOffice program folder.
            Environment.CurrentDirectory = libreOfficeProgramPath;

            // Start (or reuse) LibreOffice headless with a socket connector.
            var sofficePath = System.IO.Path.Combine(libreOfficeProgramPath, "soffice.exe");
            var acceptArg = "--accept=socket,host=localhost,port=2002;urp;StarOffice.ServiceManager";

            if (!System.IO.File.Exists(sofficePath))
            {
                Console.WriteLine($"soffice.exe not found at {sofficePath}");
                return;
            }

            bool sofficeRunning = Process.GetProcessesByName("soffice.bin").Length > 0;
            if (!sofficeRunning)
            {
                Console.WriteLine("Starting soffice headless...");
                var psi = new ProcessStartInfo
                {
                    FileName = sofficePath,
                    Arguments = $"--headless --nologo --norestore --nodefault {acceptArg}",
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    WorkingDirectory = libreOfficeProgramPath
                };
                Process.Start(psi);
                // Give soffice time to open the socket.
                System.Threading.Thread.Sleep(TimeSpan.FromSeconds(5));
            }

            Console.WriteLine("Creating initial UNO context...");
            XComponentContext localContext = Bootstrap.defaultBootstrap_InitialComponentContext();
            XMultiComponentFactory localSmgr = localContext.getServiceManager();
            XUnoUrlResolver resolver = (XUnoUrlResolver)localSmgr.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localContext);

            Console.WriteLine("Resolving remote context via socket...");
            XComponentContext remoteContext = (XComponentContext)resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext");

            XMultiComponentFactory serviceManager = remoteContext.getServiceManager();
            XDesktop? desktop = serviceManager.createInstanceWithContext("com.sun.star.frame.Desktop", remoteContext) as XDesktop;
            if (desktop == null)
            {
                Console.WriteLine("Failed to acquire LibreOffice desktop.");
                return;
            }

            Console.WriteLine("Desktop acquired");

            XComponentLoader? loader = desktop as XComponentLoader;
            if (loader == null)
            {
                Console.WriteLine("Failed to acquire component loader.");
                return;
            }

            Console.WriteLine("Component loader OK");

            // Reuse the active Calc document, or create a new one if none is open.
            Console.WriteLine("Checking current component...");
            XSpreadsheetDocument? calcDocument = desktop.getCurrentComponent() as XSpreadsheetDocument;
            if (calcDocument == null)
            {
                Console.WriteLine("No active Calc. Creating new...");
                var args = Array.Empty<PropertyValue>();
                var component = loader.loadComponentFromURL("private:factory/scalc", "_blank", 0, args);
                calcDocument = component as XSpreadsheetDocument;
            }

            if (calcDocument == null)
            {
                Console.WriteLine("No Calc document available.");
                return;
            }

            Console.WriteLine("Calc document ready");

            // Access the first sheet and write text to cell A1.
            XSpreadsheets sheets = calcDocument.getSheets();
            XIndexAccess sheetAccess = (XIndexAccess)sheets;
            Any sheetAny = (Any)sheetAccess.getByIndex(0);
            XSpreadsheet? sheet = sheetAny.Value as XSpreadsheet;
            if (sheet == null)
            {
                Console.WriteLine("Failed to access the first sheet.");
                return;
            }

            Console.WriteLine("Sheet acquired");

            XCell cell = sheet.getCellByPosition(0, 0);
            cell.setFormula("Hello by C#");

            Console.WriteLine("Value written to A1.");
        }
        catch (System.Exception ex)
        {
            Console.WriteLine($"Failed to write to Calc: {ex.Message}");
            Console.WriteLine(ex);
        }
    }
}

結果

PS H:\LibreOffice\net-ref> dotnet run
Starting...
UNO_PATH target: C:\\Program Files\\LibreOffice\\program
Creating initial UNO context...
Failed to write to Calc: External component has thrown an exception.
System.Runtime.InteropServices.SEHException (0x80004005): External component has thrown an exception.
   at cppu.defaultBootstrap_InitialComponentContext(Reference<com::sun::star::uno::XComponentContext>*)
   at uno.util.Bootstrap.defaultBootstrap_InitialComponentContext(String ini_file, IDictionaryEnumerator bootstrap_parameters)
   at uno.util.Bootstrap.defaultBootstrap_InitialComponentContext()
   at Program.Main() in H:\LibreOffice\net-ref\Program.cs:line 97
PS H:\LibreOffice\net-ref> 

実行すると、XComponentContext localContext = Bootstrap.defaultBootstrap_InitialComponentContext(); の位置で例外が発生していて致命的です。いわゆる LibreOffice がホストしているサーバーに繋がらず最初のインスタンスが生成できていません。Python からは繋げることができるので、

local_ctx = uno.getComponentContext()

まあ、 .NET 関係がおかしいのでしょう。

ひとまず、.NET 関係で動かすのは諦めて、素直に Python での実装を考えます。つまりは *.pyi のスタブをもう少し充実させます。

カテゴリー: 開発, LibreOffice | LibreOffice Calc を C# からアクセスする…が、できません はコメントを受け付けていません

LibreOffice Calc を外部から Python で操作する

Microsoft Office 365 が値上げをされたので、無料の LibreOffice に移行しましょう…ってのが流行ったがどうかわかりませんが、まあ、いままで Excel で作っていた資料を LibreOffice Calc でうまく作れるかどうかが問題ですよね。これは Google Workspace の Calc でも同じ問題を抱えるわけですが。

全体の操作感は別として(個人的には Excel が慣れているので、他の表計算ソフトを使うのはちょっと避けたいです)、いままで作っていた Excel VBA のマクロだとか、あらたな Calc でうまく移植できるのか? が問題になってきます。実は Excel VBA の開発環境もほぼ化石のままだし、LibreOffice Calc の Basic マクロの開発環境は化石みたいなものです。現在の vscode + copilot のような AI を使ったコード環境に比べると、ちょっとこのまま Excel VBA で開発のするのは避けたい…というのがプログラマの心情なのですが、俺にはこれしかないんだ、これが一番なのだという 97式自動小銃を持ち出す松本零士キャラならば、まあ、確かにそうですよね、とも言えなくもありません。

LibreOffice Calc は Excel VBA の互換環境なのか?

一見作れそうな感じなのですが、全然だめでした。全く互換はありません。

おなじみの Excel VBA のコードですが、これを LibreOffice Calc の Basic にコピペしても動きません。

Sub test()
    Dim sh As Worksheet
    Set sh = ActiveSheet
    sh.Cells(1, 1).Value = "増田智明"
End Sub

エラーになるので ChatGPT でコンバートすると以下のようになります。

Sub test
    Dim oDoc As Object
    Dim oSheet As Object
    Dim oCell As Object

    ' 現在のドキュメントを取得
    oDoc = ThisComponent

    ' アクティブシートを取得
    oSheet = oDoc.CurrentController.ActiveSheet

    ' A1セルを取得 (行・列は0始まり)
    oCell = oSheet.getCellByPosition(0, 0)

    ' 値を設定
    oCell.String = "増田智明"
End Sub

いや…これだと無理ですね。複雑な Excel VBA の移植は到底無理だし、簡単な関数も結構手間なような感じです。

あらたに Calc 用の Basic を覚えるのは馬鹿馬鹿しいので、新規一転して Python を使ってみましょう。

LibreOffice に Python エディタはついていない!!!

理由はよくわからないのですが、LibreOffice には Basic マクロのエディタは付いているのですが、Python マクロのエディタは付属していません。

「ツール」→「マクロ」→「マクロの編集」を開くと Basic マクロのエディタが開きます。

このエディタは文法は Basic だけど、Excel VBA とも違うし、さらに言うと Excel VBA エディタと同じぐらい化石なのでなんとも使い勝手が悪いです。「As」の後の補完候補もでないので、Excel VBA よりも使い勝手は悪いです…どうやってマクロプログラミングをするのでしょうか?

で、Basic のほうはおまけみたいなものらしく、結局のところ Basic じゃなくて Python で組む方が良さそうとのこと。

Python マクロを使う

実は LibreOffice Calc のPython マクロを使うのには2種類のやり方があります。

  • 内部マクロにして XSCRIPTCONTEXT モジュールを使って組む
  • 外部マクロにして UNO API 経由で組む

Excel VBA のように Excel ファイルに組み込まれた形なのは前者の XSCRIPTCONTEXT モジュールのほうになるのですが、どうも使い勝手が悪いので、ここでは UNO API 経由のほうを紹介します。

UNO API 経由のほうは、LibreOffice Calc がサーバーになって socket 経由で Python から Calc の内部を操作することができます。これは、Excel オブジェクトを C# で操作するような感じで使えるので、意外と使い勝手がよいです。特に外部から自動実行や AI 経由で何かさせたいときはこっちのほうが良さそうです。

UNO API 経由

Calc を起動しておく。

PowerShell で、以下のコマンドを実行しておく。ポート 2002 番で LibreOffice のサーバーを実行させます。

& "C:\Program Files\LibreOffice\program\soffice" --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo

vscode で以下のコードを記述(これは Copilot に作って貰ってます)
コード名は calc001.py です。

# UNO接続の準備
local_ctx = uno.getComponentContext()
resolver = local_ctx.ServiceManager.createInstanceWithContext(
    "com.sun.star.bridge.UnoUrlResolver", local_ctx)

# LibreOfficeに接続
ctx = resolver.resolve(
    "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")

smgr = ctx.ServiceManager
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)

# 現在開いているCalcドキュメントを取得
doc = desktop.getCurrentComponent()
sheet = doc.Sheets[0]  # 1枚目のシート

# A1セルに文字列を入力
cell = sheet.getCellByPosition(0, 0)  # (列=0, 行=0) → A1
cell.setString("tomoaki masuda")

PowerShell で、以下を実行

& "C:\Program Files\LibreOffice\program\python" .\calc001.py

A1 セルに文字列「tomoaki masuda」が入る。

この方法が良いのは、

  • Calc は開いたままでよい
  • Python コードは vscode で書ける
  • コードが即反映される(Python.exe を動かすので当たり前)

ただし、コードの前提条件として、

  • import uno が必要
  • LibreOffice に接続するまでが少々手間
  • おそらくデバッグ実行はできないのでは?
  • Python は、LibreOffice 内蔵の “C:\Program Files\LibreOffice\program\python” を動かす必要あり。
  • シート付属ではないので、環境依存になりがち

という形で結構制限が多いです。Excel VBA のときのように、Excel VBA エディタを開きながらぽちぽちとコーディングをしたり、シートにダイアログやボタンを貼り付けたりすることはできませんが(これは別途後で調べます)

  • テキストを読み込んで、Calc にシートを作成して表形式で整える
  • 既存のシートを読み込んで、Python で計算した後に、シートに計算結果を入れる
  • シートのデータをもとに、グラフを作成して貼り付ける

のような定型作業には便利そうです。

import uno のコード補完を有効にする

python が特殊なものを参照しているので、import uno のコード補完が有効になりません。これはかなり不便なので、コード補完が有効になるように settings.json を追加します。

{
    "python.analysis.autoImportCompletions": true,
    "python.analysis.extraPaths": [
        "C:/Program Files/LibreOffice/program",
        "C:/Program Files/LibreOffice/program/python-core-3.11.13/lib"
    ]
}

Lib を参照している Python のバージョン部分「python-core-3.11.13」を開発環境で揃えないといけませんが、これで import uno のコード補完が有効になります。
ただし、もともと Any 型になっているものが多いのか、あまり役に立たないのですが、メソッド名とかクラス名からライブラリのコードにジャンプできるのはいいかもしれません。

Markdown の表を読み込んでシートに書き出す

もう少し複雑な例として Markdown 形式で書かれた表を読み取ってシートに貼り付ける Python マクロを書いてみましょう。これも最初のコメント部分だけ書いておいて、コードは Copilot に書いて貰っています。

"""
markdown のテーブルを読み取って、LibreOffice Calc のシートに書き出すスクリプト

使用方法:
1. LibreOffice Calc を開いた状態で実行
2. Markdownテーブルを含むファイルを指定

実行コマンド:
& "C:\Program Files\LibreOffice\program\python" .\calc002.py
"""

import uno
import re
from pathlib import Path


def parse_markdown_table(markdown_text: str) -> list[list[str]]:
    """
    Markdownテーブルをパースして2次元リストに変換する
    
    Args:
        markdown_text: Markdownテーブルを含むテキスト
    
    Returns:
        2次元リスト(各行のセルデータ)
    """
    lines = markdown_text.strip().split('\n')
    table_data = []
    
    for line in lines:
        # テーブル行かどうかを確認(|で始まる行)
        if not line.strip().startswith('|'):
            continue
        
        # 区切り行(|---|---|など)をスキップ
        if re.match(r'^\|[\s\-:]+\|$', line.strip().replace('|', '|').replace('-', '-')):
            if '---' in line or ':-' in line or '-:' in line:
                continue
        
        # セルを分割
        cells = line.split('|')
        # 最初と最後の空要素を除去
        cells = [cell.strip() for cell in cells[1:-1]]
        
        if cells:  # 空でない行のみ追加
            table_data.append(cells)
    
    return table_data


def connect_to_libreoffice():
    """
    LibreOffice に接続してデスクトップオブジェクトを取得する
    
    Returns:
        (desktop, model, sheet) のタプル
    """
    local_ctx = uno.getComponentContext()
    resolver = local_ctx.ServiceManager.createInstanceWithContext(
        "com.sun.star.bridge.UnoUrlResolver", local_ctx)
    # LibreOfficeに接続
    ctx = resolver.resolve(
        "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
    smgr = ctx.ServiceManager
    desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
    
    # 現在のドキュメントを取得
    model = desktop.getCurrentComponent()
    
    if model is None:
        raise Exception("LibreOffice Calc ドキュメントが開かれていません")
    
    # アクティブなシートを取得
    sheet = model.getCurrentController().getActiveSheet()
    
    return desktop, model, sheet


def write_to_calc(sheet, table_data: list[list[str]], start_row: int = 0, start_col: int = 0):
    """
    LibreOffice Calc のシートにデータを書き込む
    
    Args:
        sheet: Calcのシートオブジェクト
        table_data: 書き込むデータ(2次元リスト)
        start_row: 開始行(0始まり)
        start_col: 開始列(0始まり)
    """
    for row_idx, row_data in enumerate(table_data):
        for col_idx, cell_value in enumerate(row_data):
            cell = sheet.getCellByPosition(start_col + col_idx, start_row + row_idx)
            cell.setString(cell_value)


def main():
    """メイン処理"""
    # サンプルのMarkdownテーブル
    sample_markdown = """
| 名前 | 年齢 | 職業 |
|------|------|------|
| 田中 | 30 | エンジニア |
| 佐藤 | 25 | デザイナー |
| 鈴木 | 35 | マネージャー |
"""
    
    try:
        # Markdownテーブルをパース
        table_data = parse_markdown_table(sample_markdown)
        print(f"パースしたデータ: {table_data}")
        
        # LibreOffice に接続
        desktop, model, sheet = connect_to_libreoffice()
        print("LibreOffice Calc に接続しました")
        
        # データを書き込み
        write_to_calc(sheet, table_data)
        print("データの書き込みが完了しました")
        
    except Exception as e:
        print(f"エラーが発生しました: {e}")


if __name__ == "__main__":
    main()

コードを見ると、Markdown 形式の文字列から読み込んでいるので、なんちゃってツールではありますが、これで動作は確認できます。実は、そのままでは動かなかったので、connect_to_libreoffice 関数や、以下のアクティブシートのところだけ手作業で書き変えています。

    # アクティブなシートを取得
sheet = model.getCurrentController().getActiveSheet()

アクティブワークブックやアクティブシートは、現在開いているワークブックやシートに書き込むので結構頻繁に使います。

コマンドラインから実行

すると Calc のアクティブシートにデータ書き込まれます。

2列目に「—」が入ったままなのはご愛敬なのですが、CSV 形式からの貼り付けとか、別フォーマットからの貼り付けとかには使えそうです。表形式の場合は、ここから罫線を付けたり、タイトル行に色を付けたりするので、そのあたりも Python マクロにできれば一応便利になるかなと。

で、Excel & Excel VBA から LibreOffice & Python に移行できそうか?

結論から言うと、Excel VBA から Python への移行は敷居が高いです。Excel VBA には ActiveWorkbook とか Me とか、Visual Basic 風な便利なグローバルオブジェクトが多いんですよね。import uno がどこまでグローバルオブジェクトが作られているか分からないのですが、この部分がないと結構コーディングが手間です。

逆に言えば、AI エージェント使ったコーディングができれば Python コードでも特に問題がなさそうです。おそらく、Excel VBA ベースのグローバル変数を追加して、いろいろな前提条件んどを端折れるようにすれば業務的に使えるようになるとは思います。

何か内製できそうなツールを作ってみるといいんじゃないでしょうか?

  • 複数のアンケートシートからひとつの集計結果にまとめる
  • データベースの情報を収集して、Calc に週間レポートや月間レポートしてグラフ付きで作成する
  • シートに関数をつけて計算しておいて、更に集計してまとめるようなツール

このあたりが、社内でツールとして作れればよいかな、と。M365 の Python マクロってどのくらい使われているんでしょうね?

追記 2025/12/16

LibreOffice Calc で Python から帳票作成(シートのスタイル以外は) https://www.moonmile.net/blog/archives/11840
LibreOffice Calc で Python から帳票作成(テンプレートファイルの利用) https://www.moonmile.net/blog/archives/11849

追記 2026/01/05

LibreOffice Calc で使う Excel Like UNO を仮リリース https://www.moonmile.net/blog/archives/11916

カテゴリー: 開発, LibreOffice | LibreOffice Calc を外部から Python で操作する はコメントを受け付けていません