diff --git a/.env.example b/.env.example index 42f3d04..230deae 100644 --- a/.env.example +++ b/.env.example @@ -28,12 +28,16 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +# 開発は compose.yaml の PostgreSQL。`podman compose up -d` または +# `docker compose up -d` で起動する。receiptbuilder と同時起動できるよう host port は 55433 を使う。 +# 運用サーバでは接続先と認証情報を差し替え、DB_SSLMODE=require を検討する。 +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=55433 +DB_DATABASE=quotebuilder +DB_USERNAME=quotebuilder +DB_PASSWORD=quotebuilder +# DB_SSLMODE=require SESSION_DRIVER=database SESSION_LIFETIME=120 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..960fe76 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Frontend checks + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + frontend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Type check + run: npm run type-check + + - name: Build + run: npm run build diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml new file mode 100644 index 0000000..2b9a83f --- /dev/null +++ b/.github/workflows/php-tests.yml @@ -0,0 +1,63 @@ +name: PHP tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + feature-tests: + runs-on: ubuntu-latest + env: + APP_ENV: testing + DB_CONNECTION: pgsql + DB_HOST: 127.0.0.1 + DB_PORT: 55433 + DB_DATABASE: quotebuilder_test + DB_USERNAME: quotebuilder + DB_PASSWORD: quotebuilder + DB_URL: '' + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: quotebuilder_test + POSTGRES_USER: quotebuilder + POSTGRES_PASSWORD: quotebuilder + ports: + - 55433:5432 + options: >- + --health-cmd "pg_isready -U quotebuilder -d quotebuilder_test" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + steps: + - uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: pdo_pgsql, pdo_sqlite, sqlite3 + coverage: none + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y poppler-utils sqlite3 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Prepare application + run: | + cp .env.example .env + php artisan key:generate + + - name: Verify PostgreSQL migrations + run: php artisan migrate:fresh --env=testing --force + + - name: Run tests + run: php artisan test diff --git a/README.md b/README.md index d979b01..af3eae3 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## コンセプト -- 個人〜少人数、PC1台での運用を前提とした最小構成(SQLite・単一サーバー) +- 個人〜少人数での運用を前提とした最小構成(PostgreSQL・単一サーバー) - 過去の見積ファイル(xlsx/ods、quotebuilder 出力PDF)を取り込んで移行できる -- データは利用者自身の手元に置き、バックアップ・復元も自己完結できる +- データは利用者が管理するPostgreSQLに置き、JSON形式で移行・復元できる ## 技術スタック @@ -16,7 +16,7 @@ |---|---| | Backend | PHP 8.3 / Laravel 13 | | Frontend | React 19 + TypeScript / Vite / Tailwind CSS 4(SPA、同一オリジン配信) | -| DB | SQLite(PostgreSQL対応は検討中: [#27](https://github.com/strnh/quotebuilder/issues/27)) | +| DB | PostgreSQL 17(SQLiteは旧環境からの移行・SQLite専用フルバックアップの互換用途) | | 取込 | PhpSpreadsheet(xlsx / ods) | | 集計表示 | Recharts | | テスト | PHPUnit(Feature)/ Playwright(E2E)/ tsc | @@ -52,7 +52,7 @@ **バックアップ / リストア** - JSONエクスポート・リストア(データ移行向け。旧形式 version 1 の取込にも対応) -- SQLiteフルSQL dump(障害復旧向け。一貫スナップショット + メタデータ生成) +- SQLiteフルSQL dump(旧SQLite環境の障害復旧向け。一貫スナップショット + メタデータ生成) - データ管理画面からの作成・一覧・ダウンロード、保持ルールによる世代管理 - 操作の監査ログ記録(`backup_audit_logs`)、backup系APIの admin 認可 @@ -70,7 +70,7 @@ | バックアップ管理画面の拡充 | [#22](https://github.com/strnh/quotebuilder/issues/22) | 画面からの復元操作 | | 差分バックアップ | [#23](https://github.com/strnh/quotebuilder/issues/23) | 方式設計とMVP範囲の確定 | | 見積一覧のTAB化 | [#17](https://github.com/strnh/quotebuilder/issues/17) | ステータス別TABでの一覧表示 | -| PostgreSQL対応 | [#27](https://github.com/strnh/quotebuilder/issues/27) | DB pluginの検討 | +| SQLite legacy DB連携 | [#27](https://github.com/strnh/quotebuilder/issues/27) | PostgreSQLからsqlite_fdwで旧DBを参照する方式の検討 | | Windows簡易アプリ化 | [#2](https://github.com/strnh/quotebuilder/issues/2) | NativePHPによる非技術者向け配布 | さらに先の構想として、OAuth2 / MFA(メール)による認証強化があります(#5 に記載)。 @@ -104,7 +104,16 @@ cd quotebuilder ./scripts/init.sh # 依存導入・.env生成・migrate・ビルドまで一括 ``` -または `composer run setup` でも同等の初期化ができます。開発サーバーの起動は: +初期化には PHP の `pdo_pgsql` 拡張と、Podman Compose または Docker Compose が必要です。 +開発用PostgreSQLは `compose.yaml` で起動し、receiptbuilder (`55432`) と同時に使えるよう +`127.0.0.1:55433` で待ち受けます。手動で起動・停止する場合は次のコマンドを使います。 + +```bash +podman compose up -d # Dockerの場合は docker compose up -d +podman compose down # volumeを残して停止 +``` + +PostgreSQLを手動で起動済みなら、`composer run setup` で依存導入・migration・ビルドを実行できます。開発サーバーの起動は: ```bash composer run dev # serve + queue + logs + vite を並行起動 @@ -113,17 +122,39 @@ composer run dev # serve + queue + logs + vite を並行起動 テスト: ```bash -php artisan test # Feature テスト +php artisan test # PostgreSQLのテストDBでFeatureテスト npm run type-check # TypeScript 型検査 npm run test:e2e # Playwright E2E ``` +### 既存SQLite環境からの移行 + +移行前にSQLiteファイル自体を退避し、業務データをJSONへエクスポートします。 + +```bash +cp database/database.sqlite database/database.sqlite.pre-postgresql +php artisan backup:export --output=storage/app/quotebuilder-migration.json +``` + +その後 `.env` のDB設定を `.env.example` のPostgreSQL設定へ変更し、PostgreSQLを起動して取り込みます。 + +```bash +podman compose up -d # Dockerの場合は docker compose up -d +php artisan migrate --force +php artisan backup:import storage/app/quotebuilder-migration.json +php artisan admin:create +``` + +JSONにはユーザー、監査ログ、ジョブなどは含まれないため、管理者アカウントは移行先で再作成します。履歴を含む完全移行が必要な場合は、退避したSQLiteファイルを保持してください。 + ## バックアップ 用途に応じて2種類のバックアップを使い分けます。 -- `php artisan backup:export`: 基本情報・顧客・取引先識別子・見積データをJSONで出力します。内容を確認しやすく、別環境へのデータ移行や競合を制御した取込み向けです。schema、ユーザー、ジョブなどは含みません。 -- `php artisan backup:sqlite-dump`: SQLite DB全体をschemaとdataを含むSQLで出力します。同時書き込みに対して一貫したスナップショットを作り、障害復旧用のフルバックアップとして使用します。SQLite以外では実行できません。 +- `php artisan backup:export`: 基本情報・顧客・取引先識別子・見積データをJSONで出力します。PostgreSQL環境間や旧SQLite環境からのデータ移行、競合を制御した取込み向けです。schema、ユーザー、ジョブなどは含みません。 +- `php artisan backup:sqlite-dump`: 旧SQLite環境のDB全体をschemaとdataを含むSQLで出力します。SQLite接続でのみ実行でき、PostgreSQLの障害復旧用途には使用できません。 + +PostgreSQLの障害復旧バックアップは、運用基盤側で `pg_dump` またはマネージドDBのスナップショットを構成してください。アプリの「保存済みフルバックアップ」作成・復元は現時点ではSQLite専用で、PostgreSQL対応は [#63](https://github.com/strnh/quotebuilder/issues/63) で扱います。JSONバックアップとリストアはPostgreSQLでも利用できます。 フルバックアップは、既定では `storage/app/private/backups/` にSQLと `.meta.json` を生成します。出力先は次のように指定できます。 diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 12257d7..dcdb5c6 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -72,11 +72,42 @@ public function restore(array $data, string $mode): array } } $this->restoreRows('settlement_schedules', $scheduleRows, $mode, $summary, $encodeItems); + + $this->resetPostgresSequences([ + 'sender_profiles', + 'customers', + 'customer_signatures', + 'quotes', + 'settlements', + 'settlement_schedules', + ]); }); return $summary; } + /** + * バックアップ側の id を明示挿入した後、PostgreSQL の採番位置を現在の最大値へ進める。 + * SQLite は INTEGER PRIMARY KEY が最大値から自動採番するため追加処理は不要。 + * + * @param list $tables + */ + private function resetPostgresSequences(array $tables): void + { + $connection = DB::connection(); + if ($connection->getDriverName() !== 'pgsql') { + return; + } + + foreach ($tables as $table) { + $quotedTable = $connection->getQueryGrammar()->wrapTable($table); + $connection->statement( + "SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM {$quotedTable}", + [$table], + ); + } + } + /** * テーブルダンプとして最低限必要な行形式・主キー・NOT NULL カラムを検証する。 * ここで弾かないと DB の制約違反(500)になるカラムのみを対象とする。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5d122b9 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,24 @@ +# 開発用 PostgreSQL。podman compose / docker compose のどちらでも動く。 +# receiptbuilder (55432) と同時起動できるよう host port は 55433 を使う。 + +services: + db: + image: docker.io/library/postgres:17 + restart: unless-stopped + environment: + POSTGRES_DB: quotebuilder + POSTGRES_USER: quotebuilder + POSTGRES_PASSWORD: quotebuilder + ports: + - "127.0.0.1:55433:5432" + volumes: + - quotebuilder-pgdata:/var/lib/postgresql/data + - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U quotebuilder -d quotebuilder"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + quotebuilder-pgdata: diff --git a/composer.json b/composer.json index 1b0a51d..16dd639 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,6 @@ ], "post-create-project-cmd": [ "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan migrate --graceful --ansi" ], "pre-package-uninstall": [ diff --git a/config/database.php b/config/database.php index abbb88e..00445da 100644 --- a/config/database.php +++ b/config/database.php @@ -17,7 +17,7 @@ | */ - 'default' => env('DB_CONNECTION', 'sqlite'), + 'default' => env('DB_CONNECTION', 'pgsql'), /* |-------------------------------------------------------------------------- diff --git a/config/queue.php b/config/queue.php index 79c2c0a..e633605 100644 --- a/config/queue.php +++ b/config/queue.php @@ -103,7 +103,7 @@ */ 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'job_batches', ], @@ -122,7 +122,7 @@ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_CONNECTION', 'pgsql'), 'table' => 'failed_jobs', ], diff --git a/database/migrations/2026_07_01_000000_create_customer_signatures_table.php b/database/migrations/2026_07_01_000000_create_customer_signatures_table.php index c509b52..ca9431c 100644 --- a/database/migrations/2026_07_01_000000_create_customer_signatures_table.php +++ b/database/migrations/2026_07_01_000000_create_customer_signatures_table.php @@ -19,7 +19,8 @@ public function up(): void ->whereNotNull('customer_signature') ->where('customer_signature', '!=', '') ->groupBy('sig') - ->having('n', '>', 1) + // PostgreSQL は HAVING 句から SELECT の別名を参照できないため、集約式を直接指定する。 + ->havingRaw('count(*) > 1') ->pluck('sig'); if ($duplicates->isNotEmpty()) { diff --git a/docker/postgres/init/01-create-test-database.sql b/docker/postgres/init/01-create-test-database.sql new file mode 100644 index 0000000..9b2aa30 --- /dev/null +++ b/docker/postgres/init/01-create-test-database.sql @@ -0,0 +1,3 @@ +-- phpunit 用の DB。RefreshDatabase が各テストの状態を初期化するため、開発用 DB とは分ける。 +-- このスクリプトは volume が空の初回起動時のみ実行される。 +CREATE DATABASE quotebuilder_test OWNER quotebuilder; diff --git a/phpunit.xml b/phpunit.xml index cec80f7..760c1a1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,9 +24,15 @@ - - - + + + + + + + + diff --git a/resources/js/data/adapters/api.ts b/resources/js/data/adapters/api.ts index 6004c51..df30479 100644 --- a/resources/js/data/adapters/api.ts +++ b/resources/js/data/adapters/api.ts @@ -1,4 +1,4 @@ -// REST API アダプター(Laravel + SQLite バックエンド)。 +// REST API アダプター(Laravel + PostgreSQL バックエンド)。 // /api/{customers,sender-profiles,quotes} の apiResource を呼ぶ。 // 認証は Sanctum SPA(同一オリジン・session cookie)。全リクエストで cookie を送り、 // 変更系は CSRF トークン(XSRF-TOKEN cookie → X-XSRF-TOKEN ヘッダ)を付ける。 diff --git a/resources/js/data/store.ts b/resources/js/data/store.ts index ddf60c4..d6e4088 100644 --- a/resources/js/data/store.ts +++ b/resources/js/data/store.ts @@ -1,5 +1,5 @@ // データアクセス層。アダプターを切り替えて使う。 -// - 'api' : Laravel + SQLite バックエンド(既定) +// - 'api' : Laravel + PostgreSQL バックエンド(既定) // - 'local' : localStorage 完結(オフライン/デモ/e2e テスト用) // 切替: localStorage.setItem('zensales:adapter', 'local') import * as apiAdapter from './adapters/api'; diff --git a/scripts/init-test.sh b/scripts/init-test.sh index 17db52d..d9d4eb6 100755 --- a/scripts/init-test.sh +++ b/scripts/init-test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# テスト専用セットアップ: PHPUnit (in-memory) + E2E (Playwright) が動く最小構成 +# テスト専用セットアップ: PHPUnit (PostgreSQL) + E2E (Playwright) が動く最小構成 # CI や「テストだけ確認したい」ときに使う。npm run build は Playwright が自動実行する。 set -euo pipefail @@ -14,6 +14,7 @@ for cmd in php composer node npm; do command -v "$cmd" &>/dev/null || die "$cmd が見つかりません" done php -r 'version_compare(PHP_VERSION,"8.3.0",">=") or die(1);' || die "PHP 8.3 以上が必要です (現在: $(php -r 'echo PHP_VERSION;'))" +php -r 'extension_loaded("pdo_pgsql") or die(1);' || die "PHP拡張 pdo_pgsql が必要です" step "PHP 依存関係インストール" composer install --no-interaction @@ -29,9 +30,32 @@ fi step "アプリケーションキー生成" php artisan key:generate --ansi -step "SQLite データベース作成 (E2E 用)" -# PHPUnit は :memory: を使うのでファイル不要だが、E2E サーバー起動に必要 -touch database/database.sqlite +step "PostgreSQL 起動 (開発DB + PHPUnit用DB)" +if podman compose version &>/dev/null; then + compose=(podman compose) +elif docker compose version &>/dev/null; then + compose=(docker compose) +else + die "podman compose または docker compose が必要です" +fi +"${compose[@]}" up -d db +for _ in {1..30}; do + if "${compose[@]}" exec -T db pg_isready -U quotebuilder -d quotebuilder &>/dev/null; then + break + fi + sleep 1 +done +"${compose[@]}" exec -T db pg_isready -U quotebuilder -d quotebuilder &>/dev/null \ + || die "PostgreSQL が起動しませんでした" + +step "PHPUnit 用データベース確認" +if [ "$("${compose[@]}" exec -T db psql -U quotebuilder -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'quotebuilder_test'")" != "1" ]; then + "${compose[@]}" exec -T db createdb -U quotebuilder quotebuilder_test + ok "quotebuilder_test を作成しました" +else + ok "quotebuilder_test は既に存在します" +fi + php artisan migrate --force --ansi step "Node.js 依存関係インストール" diff --git a/scripts/init.sh b/scripts/init.sh index 76ac8ad..acb477c 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -14,6 +14,7 @@ for cmd in php composer node npm; do command -v "$cmd" &>/dev/null || die "$cmd が見つかりません" done php -r 'version_compare(PHP_VERSION,"8.3.0",">=") or die(1);' || die "PHP 8.3 以上が必要です (現在: $(php -r 'echo PHP_VERSION;'))" +php -r 'extension_loaded("pdo_pgsql") or die(1);' || die "PHP拡張 pdo_pgsql が必要です" step "PHP 依存関係インストール" composer install --no-interaction @@ -29,8 +30,31 @@ fi step "アプリケーションキー生成" php artisan key:generate --ansi -step "SQLite データベース作成" -touch database/database.sqlite +step "PostgreSQL 起動" +if podman compose version &>/dev/null; then + compose=(podman compose) +elif docker compose version &>/dev/null; then + compose=(docker compose) +else + die "podman compose または docker compose が必要です" +fi +"${compose[@]}" up -d db +for _ in {1..30}; do + if "${compose[@]}" exec -T db pg_isready -U quotebuilder -d quotebuilder &>/dev/null; then + break + fi + sleep 1 +done +"${compose[@]}" exec -T db pg_isready -U quotebuilder -d quotebuilder &>/dev/null \ + || die "PostgreSQL が起動しませんでした" + +step "PHPUnit 用データベース確認" +if [ "$("${compose[@]}" exec -T db psql -U quotebuilder -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'quotebuilder_test'")" != "1" ]; then + "${compose[@]}" exec -T db createdb -U quotebuilder quotebuilder_test + ok "quotebuilder_test を作成しました" +else + ok "quotebuilder_test は既に存在します" +fi step "マイグレーション実行" php artisan migrate --force --ansi diff --git a/tests/Feature/BackupStorageTest.php b/tests/Feature/BackupStorageTest.php index de6555f..fdcd6a2 100644 --- a/tests/Feature/BackupStorageTest.php +++ b/tests/Feature/BackupStorageTest.php @@ -32,8 +32,8 @@ protected function setUp(): void $database->exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY, migration TEXT, batch INTEGER)'); $database->exec("INSERT INTO migrations VALUES (1, 'create_quotes', 1)"); $database->exec('CREATE TABLE quotes (id INTEGER PRIMARY KEY, title TEXT)'); - $database->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, password TEXT, role TEXT, created_at TEXT, updated_at TEXT)'); - $database->exec('CREATE TABLE backup_audit_logs (id INTEGER PRIMARY KEY, actor_type TEXT, actor_identifier TEXT, action TEXT, target_type TEXT, target_identifier TEXT, outcome TEXT, failure_reason TEXT, created_at TEXT, updated_at TEXT)'); + $database->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, email_verified_at TEXT, password TEXT, role TEXT, remember_token TEXT, created_at TEXT, updated_at TEXT)'); + $database->exec('CREATE TABLE backup_audit_logs (id INTEGER PRIMARY KEY, actor_type TEXT NOT NULL, actor_identifier TEXT NOT NULL, user_id INTEGER, operation TEXT NOT NULL, target TEXT NOT NULL, result TEXT NOT NULL, failure_reason TEXT, note TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); $database->close(); $this->actingAs(User::factory()->create(['role' => 'admin'])); } diff --git a/tests/Feature/BackupTest.php b/tests/Feature/BackupTest.php index fd58b5e..64f64ac 100644 --- a/tests/Feature/BackupTest.php +++ b/tests/Feature/BackupTest.php @@ -73,6 +73,25 @@ public function test_restore_skip_inserts_new_records(): void $this->assertDatabaseHas('quotes', ['quote_number' => 'Q-001']); } + public function test_restore_advances_the_primary_key_sequence_after_explicit_ids(): void + { + $payload = $this->makeBackup( + senderProfiles: [[ + 'id' => 100, + 'sender_company' => '移行元会社', + 'is_default' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]], + ); + + $this->postRestore($payload)->assertOk(); + + $created = SenderProfile::create(['sender_company' => '移行後会社']); + + $this->assertSame(101, (int) $created->id); + } + public function test_restore_skip_does_not_overwrite_existing_record(): void { $sp = SenderProfile::create(['sender_company' => '元の会社名']); diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..cad3ca4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,11 @@ abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void + { + parent::setUp(); + + // Feature testはViteの出力ではなくHTTPレスポンスを検証するため、manifestを要求しない。 + $this->withoutVite(); + } }