From 4da96f1202d378fdb490ff6ac73ba13d2f837e8d Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 17:16:43 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E3=83=BB=E5=AE=9A=E6=9C=9F=E8=AB=8B=E6=B1=82=E3=82=B9=E3=82=B1?= =?UTF-8?q?=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=81=AE=E3=82=B3=E3=82=A2?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=20(Phase=20A)=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settlements / settlement_schedules の migration を追加 - settlement_number は nullable + UNIQUE - (settlement_id, sequence_no) UNIQUE、親削除時 cascade - Settlement / SettlementSchedule model と casts・relations を追加 - SettlementPricing: 1回分の明細・小計・税・税込額のサーバ側再計算 - SettlementScheduleGenerator: month/year 周期・有限境界・月末クランプ・ advance/arrears・金額スナップショット・基準日以降のみ再生成する sync() - Unit / Feature テストを追加 Co-Authored-By: Claude Fable 5 --- app/Models/Settlement.php | 29 +++ app/Models/SettlementSchedule.php | 28 ++ app/Support/SettlementPricing.php | 41 +++ app/Support/SettlementScheduleGenerator.php | 155 +++++++++++ ..._07_08_000001_create_settlements_table.php | 65 +++++ ...0002_create_settlement_schedules_table.php | 33 +++ .../SettlementScheduleGeneratorTest.php | 243 ++++++++++++++++++ tests/Unit/SettlementPricingTest.php | 38 +++ 8 files changed, 632 insertions(+) create mode 100644 app/Models/Settlement.php create mode 100644 app/Models/SettlementSchedule.php create mode 100644 app/Support/SettlementPricing.php create mode 100644 app/Support/SettlementScheduleGenerator.php create mode 100644 database/migrations/2026_07_08_000001_create_settlements_table.php create mode 100644 database/migrations/2026_07_08_000002_create_settlement_schedules_table.php create mode 100644 tests/Feature/SettlementScheduleGeneratorTest.php create mode 100644 tests/Unit/SettlementPricingTest.php diff --git a/app/Models/Settlement.php b/app/Models/Settlement.php new file mode 100644 index 0000000..1cc30a4 --- /dev/null +++ b/app/Models/Settlement.php @@ -0,0 +1,29 @@ + 'array', + 'start_date' => 'date:Y-m-d', + 'end_date' => 'date:Y-m-d', + 'occurrences_count' => 'integer', + 'interval_count' => 'integer', + 'billing_day' => 'integer', + 'tax_rate' => 'integer', + 'unit_subtotal' => 'integer', + 'unit_tax_amount' => 'integer', + 'unit_total_amount' => 'integer', + ]; + + public function settlementSchedules(): HasMany + { + return $this->hasMany(SettlementSchedule::class); + } +} diff --git a/app/Models/SettlementSchedule.php b/app/Models/SettlementSchedule.php new file mode 100644 index 0000000..8e9b00d --- /dev/null +++ b/app/Models/SettlementSchedule.php @@ -0,0 +1,28 @@ + 'array', + 'sequence_no' => 'integer', + 'period_start' => 'date:Y-m-d', + 'period_end' => 'date:Y-m-d', + 'billing_date' => 'date:Y-m-d', + 'due_date' => 'date:Y-m-d', + 'subtotal' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + ]; + + public function settlement(): BelongsTo + { + return $this->belongsTo(Settlement::class); + } +} diff --git a/app/Support/SettlementPricing.php b/app/Support/SettlementPricing.php new file mode 100644 index 0000000..098147f --- /dev/null +++ b/app/Support/SettlementPricing.php @@ -0,0 +1,41 @@ +map(function ($item) { + $quantity = (int) ($item['quantity'] ?? 0); + $unitPrice = (int) ($item['unit_price'] ?? 0); + $item['total'] = $quantity * $unitPrice; + + return $item; + }); + + $subtotal = $items->sum('total'); + $taxRate = (int) ($data['tax_rate'] ?? 10); + $tax = (int) floor($subtotal * $taxRate / 100); + + $data['items'] = $items->values()->all(); + $data['unit_subtotal'] = $subtotal; + $data['unit_tax_amount'] = $tax; + $data['unit_total_amount'] = $subtotal + $tax; + + return $data; + } + + public static function subtotal(Collection|array $items): int + { + return collect($items)->sum(function ($item) { + return (int) ($item['quantity'] ?? 0) * (int) ($item['unit_price'] ?? 0); + }); + } +} diff --git a/app/Support/SettlementScheduleGenerator.php b/app/Support/SettlementScheduleGenerator.php new file mode 100644 index 0000000..163823d --- /dev/null +++ b/app/Support/SettlementScheduleGenerator.php @@ -0,0 +1,155 @@ +> + */ + public static function generate(Settlement $settlement): array + { + self::assertGeneratable($settlement); + + $startDate = CarbonImmutable::parse($settlement->start_date->toDateString()); + $endDate = $settlement->end_date + ? CarbonImmutable::parse($settlement->end_date->toDateString()) + : null; + $occurrencesCount = $settlement->occurrences_count; + $schedules = []; + + for ($index = 0; ; $index++) { + $sequenceNo = $index + 1; + if ($occurrencesCount !== null && $sequenceNo > $occurrencesCount) { + break; + } + + // 常に初回開始日から加算し、1月31日などの月末アンカーのドリフトを防ぐ。 + $periodStart = self::addInterval($startDate, $settlement, $index); + if ($endDate && $periodStart->greaterThan($endDate)) { + break; + } + + $periodEnd = self::addInterval($startDate, $settlement, $index + 1)->subDay(); + if ($endDate && $periodEnd->greaterThan($endDate)) { + $periodEnd = $endDate; + } + + $billingDate = self::billingDate($periodStart, $periodEnd, $settlement); + $schedules[] = [ + 'sequence_no' => $sequenceNo, + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + 'billing_date' => $billingDate->toDateString(), + 'due_date' => null, + 'subtotal' => $settlement->unit_subtotal, + 'tax_amount' => $settlement->unit_tax_amount, + 'total_amount' => $settlement->unit_total_amount, + 'items' => $settlement->items ?? [], + ]; + } + + return $schedules; + } + + /** + * 基準日より前の回を保持し、当日以降の予定だけを現在条件で置き換える。 + */ + public static function sync(Settlement $settlement, ?CarbonInterface $asOf = null): Collection + { + $cutoff = CarbonImmutable::parse(($asOf ?? now())->toDateString()); + + DB::transaction(function () use ($settlement, $cutoff) { + $generated = self::generate($settlement); + $pastQuery = $settlement->settlementSchedules() + ->whereDate('billing_date', '<', $cutoff->toDateString()); + $nextSequence = ((int) $pastQuery->max('sequence_no')) + 1; + + $settlement->settlementSchedules() + ->whereDate('billing_date', '>=', $cutoff->toDateString()) + ->delete(); + + foreach ($generated as $schedule) { + if ($schedule['billing_date'] < $cutoff->toDateString()) { + continue; + } + + $schedule['sequence_no'] = $nextSequence++; + $settlement->settlementSchedules()->create($schedule); + } + }); + + return $settlement->settlementSchedules()->orderBy('sequence_no')->get(); + } + + private static function assertGeneratable(Settlement $settlement): void + { + if (! $settlement->start_date) { + throw new InvalidArgumentException('start_date is required.'); + } + + if (! $settlement->end_date && $settlement->occurrences_count === null) { + throw new InvalidArgumentException('end_date or occurrences_count is required.'); + } + + if ($settlement->end_date && $settlement->end_date->lt($settlement->start_date)) { + throw new InvalidArgumentException('end_date must be on or after start_date.'); + } + + if (! in_array($settlement->interval_unit, ['month', 'year'], true)) { + throw new InvalidArgumentException('interval_unit must be month or year.'); + } + + if ((int) $settlement->interval_count < 1) { + throw new InvalidArgumentException('interval_count must be at least 1.'); + } + + if ($settlement->occurrences_count !== null && $settlement->occurrences_count < 1) { + throw new InvalidArgumentException('occurrences_count must be at least 1.'); + } + + if (! in_array($settlement->billing_timing, ['advance', 'arrears'], true)) { + throw new InvalidArgumentException('billing_timing must be advance or arrears.'); + } + + if ($settlement->billing_day !== null && ($settlement->billing_day < 1 || $settlement->billing_day > 31)) { + throw new InvalidArgumentException('billing_day must be between 1 and 31.'); + } + } + + private static function addInterval( + CarbonImmutable $startDate, + Settlement $settlement, + int $multiplier, + ): CarbonImmutable { + $amount = $settlement->interval_count * $multiplier; + + return $settlement->interval_unit === 'month' + ? $startDate->addMonthsNoOverflow($amount) + : $startDate->addYearsNoOverflow($amount); + } + + private static function billingDate( + CarbonImmutable $periodStart, + CarbonImmutable $periodEnd, + Settlement $settlement, + ): CarbonImmutable { + $boundary = $settlement->billing_timing === 'arrears' ? $periodEnd : $periodStart; + if ($settlement->billing_day === null) { + return $boundary; + } + + $day = min($settlement->billing_day, $boundary->daysInMonth); + + return $boundary->startOfMonth()->day($day); + } +} diff --git a/database/migrations/2026_07_08_000001_create_settlements_table.php b/database/migrations/2026_07_08_000001_create_settlements_table.php new file mode 100644 index 0000000..3af208d --- /dev/null +++ b/database/migrations/2026_07_08_000001_create_settlements_table.php @@ -0,0 +1,65 @@ +id(); + $table->string('settlement_number')->nullable()->unique(); + $table->string('subject')->nullable(); + $table->string('status')->default('draft'); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->unsignedInteger('occurrences_count')->nullable(); + $table->string('interval_unit'); + $table->unsignedInteger('interval_count')->default(1); + $table->unsignedTinyInteger('billing_day')->nullable(); + $table->string('billing_timing')->default('advance'); + $table->string('payment_terms')->nullable(); + $table->unsignedInteger('tax_rate')->default(10); + $table->text('notes')->nullable(); + + // 発行者スナップショット + $table->foreignId('sender_profile_id')->nullable(); + $table->string('sender_company')->nullable(); + $table->string('sender_zip')->nullable(); + $table->string('sender_pref')->nullable(); + $table->string('sender_city')->nullable(); + $table->string('sender_address1')->nullable(); + $table->string('sender_address2')->nullable(); + $table->string('sender_person')->nullable(); + $table->string('sender_tel')->nullable(); + $table->string('sender_fax')->nullable(); + $table->longText('sender_logo_url')->nullable(); + + // 取引先スナップショット + $table->foreignId('customer_id')->nullable(); + $table->string('customer_name')->nullable(); + $table->string('customer_department')->nullable(); + $table->string('customer_person')->nullable(); + $table->string('customer_zip')->nullable(); + $table->string('customer_pref')->nullable(); + $table->string('customer_city')->nullable(); + $table->string('customer_address1')->nullable(); + $table->string('customer_address2')->nullable(); + $table->string('customer_tel')->nullable(); + + // 1回分の明細・金額 + $table->json('items')->nullable(); + $table->bigInteger('unit_subtotal')->default(0); + $table->bigInteger('unit_tax_amount')->default(0); + $table->bigInteger('unit_total_amount')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settlements'); + } +}; diff --git a/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php b/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php new file mode 100644 index 0000000..5a64ea5 --- /dev/null +++ b/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('settlement_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('sequence_no'); + $table->date('period_start'); + $table->date('period_end'); + $table->date('billing_date'); + $table->date('due_date')->nullable(); + $table->bigInteger('subtotal')->default(0); + $table->bigInteger('tax_amount')->default(0); + $table->bigInteger('total_amount')->default(0); + $table->json('items')->nullable(); + $table->timestamps(); + + $table->unique(['settlement_id', 'sequence_no']); + }); + } + + public function down(): void + { + Schema::dropIfExists('settlement_schedules'); + } +}; diff --git a/tests/Feature/SettlementScheduleGeneratorTest.php b/tests/Feature/SettlementScheduleGeneratorTest.php new file mode 100644 index 0000000..e17d95a --- /dev/null +++ b/tests/Feature/SettlementScheduleGeneratorTest.php @@ -0,0 +1,243 @@ +settlement([ + 'items' => [['name' => '保守']], + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-31', + ]); + $schedule = $settlement->settlementSchedules()->create([ + 'sequence_no' => 1, + 'period_start' => '2024-01-01', + 'period_end' => '2024-01-31', + 'billing_date' => '2024-01-01', + 'subtotal' => 100, + 'tax_amount' => 10, + 'total_amount' => 110, + 'items' => [['name' => '保守']], + ]); + + $this->assertSame('2024-01-01', $settlement->start_date->format('Y-m-d')); + $this->assertSame([['name' => '保守']], $schedule->items); + $this->assertTrue($schedule->settlement->is($settlement)); + $this->assertTrue($settlement->settlementSchedules->first()->is($schedule)); + } + + public function test_settlement_number_is_nullable_and_unique(): void + { + $this->settlement(['settlement_number' => null]); + $this->settlement(['settlement_number' => null]); + $this->settlement(['settlement_number' => 'S-001']); + + $this->expectException(UniqueConstraintViolationException::class); + $this->settlement(['settlement_number' => 'S-001']); + } + + public function test_schedule_sequence_is_unique_per_settlement_and_cascades_on_delete(): void + { + $settlement = $this->settlement(); + $attributes = [ + 'sequence_no' => 1, + 'period_start' => '2024-01-01', + 'period_end' => '2024-01-31', + 'billing_date' => '2024-01-01', + ]; + $schedule = $settlement->settlementSchedules()->create($attributes); + + $otherSettlement = $this->settlement(); + $otherSettlement->settlementSchedules()->create($attributes); + $settlement->delete(); + + $this->assertDatabaseMissing('settlement_schedules', ['id' => $schedule->id]); + + $duplicateSettlement = $this->settlement(); + $duplicateSettlement->settlementSchedules()->create($attributes); + $this->expectException(UniqueConstraintViolationException::class); + $duplicateSettlement->settlementSchedules()->create($attributes); + } + + public function test_generates_monthly_periods_from_month_end_and_clamps_billing_day(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-31', + 'occurrences_count' => 3, + 'billing_day' => 31, + ]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertSame( + ['2024-01-31', '2024-02-29', '2024-03-31'], + array_column($schedules, 'period_start'), + ); + $this->assertSame( + ['2024-02-28', '2024-03-30', '2024-04-29'], + array_column($schedules, 'period_end'), + ); + $this->assertSame( + ['2024-01-31', '2024-02-29', '2024-03-31'], + array_column($schedules, 'billing_date'), + ); + } + + public function test_supports_multi_month_arrears_periods(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-15', + 'occurrences_count' => 3, + 'interval_count' => 2, + 'billing_timing' => 'arrears', + ]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertSame( + ['2024-03-14', '2024-05-14', '2024-07-14'], + array_column($schedules, 'billing_date'), + ); + } + + public function test_supports_yearly_intervals_and_leap_day_clamping(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-02-29', + 'occurrences_count' => 3, + 'interval_unit' => 'year', + 'interval_count' => 2, + 'billing_day' => 31, + ]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertSame( + ['2024-02-29', '2026-02-28', '2028-02-29'], + array_column($schedules, 'period_start'), + ); + $this->assertSame( + ['2024-02-29', '2026-02-28', '2028-02-29'], + array_column($schedules, 'billing_date'), + ); + } + + public function test_stops_at_whichever_boundary_is_reached_first(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-03-15', + 'occurrences_count' => 12, + ]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertCount(3, $schedules); + $this->assertSame('2024-03-15', $schedules[2]['period_end']); + + $settlement->end_date = '2025-12-31'; + $settlement->occurrences_count = 2; + + $this->assertCount(2, SettlementScheduleGenerator::generate($settlement)); + } + + public function test_end_date_alone_creates_a_finite_partial_final_period(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-15', + 'end_date' => '2024-03-20', + 'occurrences_count' => null, + ]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertCount(3, $schedules); + $this->assertSame('2024-03-15', $schedules[2]['period_start']); + $this->assertSame('2024-03-20', $schedules[2]['period_end']); + } + + public function test_each_schedule_snapshots_items_and_per_occurrence_amounts(): void + { + $priced = SettlementPricing::apply([ + 'tax_rate' => 10, + 'items' => [['name' => '月額保守', 'quantity' => 1, 'unit_price' => 105]], + ]); + $settlement = $this->settlement($priced + ['occurrences_count' => 2]); + + $schedules = SettlementScheduleGenerator::generate($settlement); + + $this->assertSame(105, $schedules[0]['subtotal']); + $this->assertSame(10, $schedules[0]['tax_amount']); + $this->assertSame(115, $schedules[0]['total_amount']); + $this->assertSame('月額保守', $schedules[1]['items'][0]['name']); + } + + public function test_sync_preserves_past_snapshot_and_replaces_future_schedules(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-01', + 'occurrences_count' => 3, + 'items' => [['name' => '旧明細']], + 'unit_subtotal' => 100, + 'unit_tax_amount' => 10, + 'unit_total_amount' => 110, + ]); + SettlementScheduleGenerator::sync($settlement, CarbonImmutable::parse('2024-01-01')); + $past = SettlementSchedule::where('sequence_no', 1)->firstOrFail(); + + $settlement->update([ + 'items' => [['name' => '新明細']], + 'unit_subtotal' => 200, + 'unit_tax_amount' => 20, + 'unit_total_amount' => 220, + ]); + SettlementScheduleGenerator::sync($settlement->fresh(), CarbonImmutable::parse('2024-02-01')); + + $this->assertSame(3, $settlement->settlementSchedules()->count()); + $this->assertSame($past->id, SettlementSchedule::where('sequence_no', 1)->value('id')); + $this->assertSame('旧明細', SettlementSchedule::where('sequence_no', 1)->firstOrFail()->items[0]['name']); + $this->assertSame(220, SettlementSchedule::where('sequence_no', 2)->value('total_amount')); + $this->assertSame('新明細', SettlementSchedule::where('sequence_no', 3)->firstOrFail()->items[0]['name']); + } + + public function test_requires_a_finite_valid_schedule(): void + { + $settlement = $this->settlement(['end_date' => null, 'occurrences_count' => null]); + + $this->expectException(InvalidArgumentException::class); + SettlementScheduleGenerator::generate($settlement); + } + + private function settlement(array $attributes = []): Settlement + { + return Settlement::create(array_merge([ + 'status' => 'draft', + 'start_date' => '2024-01-01', + 'end_date' => null, + 'occurrences_count' => 1, + 'interval_unit' => 'month', + 'interval_count' => 1, + 'billing_day' => null, + 'billing_timing' => 'advance', + 'tax_rate' => 10, + 'items' => [], + 'unit_subtotal' => 0, + 'unit_tax_amount' => 0, + 'unit_total_amount' => 0, + ], $attributes)); + } +} diff --git a/tests/Unit/SettlementPricingTest.php b/tests/Unit/SettlementPricingTest.php new file mode 100644 index 0000000..675c042 --- /dev/null +++ b/tests/Unit/SettlementPricingTest.php @@ -0,0 +1,38 @@ + 10, + 'unit_total_amount' => 999999, + 'items' => [ + ['name' => '商品A', 'quantity' => 3, 'unit_price' => 1000, 'total' => 1], + ['name' => '商品B', 'quantity' => 2, 'unit_price' => 500, 'total' => 1], + ], + ]); + + $this->assertSame(3000, $result['items'][0]['total']); + $this->assertSame(1000, $result['items'][1]['total']); + $this->assertSame(4000, $result['unit_subtotal']); + $this->assertSame(400, $result['unit_tax_amount']); + $this->assertSame(4400, $result['unit_total_amount']); + } + + public function test_tax_is_floored_for_one_occurrence(): void + { + $result = SettlementPricing::apply([ + 'tax_rate' => 10, + 'items' => [['quantity' => 1, 'unit_price' => 105]], + ]); + + $this->assertSame(10, $result['unit_tax_amount']); + $this->assertSame(115, $result['unit_total_amount']); + } +} From 29b855f09335a66c5ec52d19f01533ed2a6171ff Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 17:35:56 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20=E4=BB=95=E5=88=87=E6=9B=B8=20API?= =?UTF-8?q?=20=E3=81=A8=E8=AB=8B=E6=B1=82=E4=BA=88=E5=AE=9A=E3=83=87?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E5=8F=96=E5=BE=97=20API=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=20(Phase=20B)=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettlementController (apiResource): QuoteController 踏襲の validation、 settlement_number は Rule::unique->ignore、終了条件 (end_date / occurrences_count のいずれか必須)を required_without で検証 - store/update で SettlementPricing 再計算。store は全回生成、 update は基準日以降のみ SettlementScheduleGenerator::sync で再生成 - SettlementScheduleController#index: ?settlement_id= / ?from= / ?to= (billing_date 範囲)フィルタ付きで各回の請求予定データを JSON 提供 - routes/api.php: auth:sanctum 配下に apiResource と取得ルートを追加 - Feature テスト: CRUD、サーバ側再計算、終了条件 422、番号 UNIQUE、 過去回スナップショット不変の再生成、フィルタ、quotes 回帰 Co-Authored-By: Claude Fable 5 --- app/Http/Controllers/SettlementController.php | 116 +++++++++++ .../SettlementScheduleController.php | 31 +++ routes/api.php | 7 + tests/Feature/SettlementApiTest.php | 196 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 app/Http/Controllers/SettlementController.php create mode 100644 app/Http/Controllers/SettlementScheduleController.php create mode 100644 tests/Feature/SettlementApiTest.php diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php new file mode 100644 index 0000000..b695e7c --- /dev/null +++ b/app/Http/Controllers/SettlementController.php @@ -0,0 +1,116 @@ +orderByDesc('start_date') + ->orderByDesc('id') + ->get(); + } + + public function store(Request $request) + { + $data = SettlementPricing::apply($this->validated($request)); + + $settlement = Settlement::create($data); + // interval_count 等の DB default を attributes に反映してから各回を生成する。 + $settlement->refresh(); + // 新規作成時は過去/未来の区別なく全回を予定データとして持つ。 + $settlement->settlementSchedules()->createMany( + SettlementScheduleGenerator::generate($settlement), + ); + + return response()->json($settlement->load('settlementSchedules'), 201); + } + + public function show(Settlement $settlement) + { + return $settlement->load('settlementSchedules'); + } + + public function update(Request $request, Settlement $settlement) + { + $settlement->update(SettlementPricing::apply($this->validated($request))); + $settlement->refresh(); + // 過去回のスナップショットは不変。基準日(今日)以降の回だけ現在条件で作り直す。 + SettlementScheduleGenerator::sync($settlement); + + return $settlement->fresh()->load('settlementSchedules'); + } + + public function destroy(Settlement $settlement) + { + $settlement->delete(); + + return response()->noContent(); + } + + private function validated(Request $request): array + { + return $request->validate([ + // settlement_number は quote_number と同じ扱い(nullable + UNIQUE)。 + // 未設定(null)は対象外=番号なし下書きは複数作成可能。 + 'settlement_number' => [ + 'nullable', 'string', 'max:100', + Rule::unique('settlements', 'settlement_number')->ignore($request->route('settlement')), + ], + 'subject' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'in:draft,active,ended'], + + // 終了条件は必須: end_date か occurrences_count のいずれか(無期限は不可)。 + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'required_without:occurrences_count', 'date', 'after_or_equal:start_date'], + 'occurrences_count' => ['nullable', 'required_without:end_date', 'integer', 'min:1'], + 'interval_unit' => ['required', 'in:month,year'], + 'interval_count' => ['nullable', 'integer', 'min:1'], + 'billing_day' => ['nullable', 'integer', 'between:1,31'], + 'billing_timing' => ['nullable', 'in:advance,arrears'], + + 'payment_terms' => ['nullable', 'string', 'max:255'], + 'tax_rate' => ['nullable', 'integer', 'min:0', 'max:100'], + 'notes' => ['nullable', 'string'], + + 'sender_profile_id' => ['nullable'], + 'sender_company' => ['nullable', 'string', 'max:255'], + 'sender_zip' => ['nullable', 'string', 'max:20'], + 'sender_pref' => ['nullable', 'string', 'max:50'], + 'sender_city' => ['nullable', 'string', 'max:255'], + 'sender_address1' => ['nullable', 'string', 'max:255'], + 'sender_address2' => ['nullable', 'string', 'max:255'], + 'sender_person' => ['nullable', 'string', 'max:255'], + 'sender_tel' => ['nullable', 'string', 'max:50'], + 'sender_fax' => ['nullable', 'string', 'max:50'], + 'sender_logo_url' => ['nullable', 'string'], + + 'customer_id' => ['nullable'], + 'customer_name' => ['nullable', 'string', 'max:255'], + 'customer_department' => ['nullable', 'string', 'max:255'], + 'customer_person' => ['nullable', 'string', 'max:255'], + 'customer_zip' => ['nullable', 'string', 'max:20'], + 'customer_pref' => ['nullable', 'string', 'max:50'], + 'customer_city' => ['nullable', 'string', 'max:255'], + 'customer_address1' => ['nullable', 'string', 'max:255'], + 'customer_address2' => ['nullable', 'string', 'max:255'], + 'customer_tel' => ['nullable', 'string', 'max:50'], + + 'items' => ['nullable', 'array'], + 'items.*.name' => ['nullable', 'string', 'max:255'], + 'items.*.spec' => ['nullable', 'string', 'max:255'], + 'items.*.quantity' => ['nullable', 'numeric'], + 'items.*.unit' => ['nullable', 'string', 'max:50'], + 'items.*.standard_price' => ['nullable', 'numeric'], + 'items.*.unit_price' => ['nullable', 'numeric'], + 'items.*.total' => ['nullable', 'numeric'], + ]); + } +} diff --git a/app/Http/Controllers/SettlementScheduleController.php b/app/Http/Controllers/SettlementScheduleController.php new file mode 100644 index 0000000..f0bbf66 --- /dev/null +++ b/app/Http/Controllers/SettlementScheduleController.php @@ -0,0 +1,31 @@ +validate([ + 'settlement_id' => ['nullable', 'integer'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date', 'after_or_equal:from'], + ]); + + return SettlementSchedule::query() + ->when($filters['settlement_id'] ?? null, fn ($query, $id) => $query->where('settlement_id', $id)) + ->when($filters['from'] ?? null, fn ($query, $from) => $query->whereDate('billing_date', '>=', $from)) + ->when($filters['to'] ?? null, fn ($query, $to) => $query->whereDate('billing_date', '<=', $to)) + ->orderBy('billing_date') + ->orderBy('settlement_id') + ->orderBy('sequence_no') + ->get(); + } +} diff --git a/routes/api.php b/routes/api.php index 2fbc70b..982ea9a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,8 @@ use App\Http\Controllers\ImportController; use App\Http\Controllers\QuoteController; use App\Http\Controllers\SenderProfileController; +use App\Http\Controllers\SettlementController; +use App\Http\Controllers\SettlementScheduleController; use App\Http\Controllers\SetupController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; @@ -33,6 +35,11 @@ // 御見積書ファイル(xlsx/ods)取込: ファイル名で取引先突合し Quote を生成 Route::post('quotes/import', [ImportController::class, 'store']); + // 仕切書と各回の請求予定データ(issue #54 Phase B) + Route::apiResource('settlements', SettlementController::class); + // 各回の請求予定データを取得(フィルタ: ?settlement_id= / ?from= / ?to=) + Route::get('settlement-schedules', [SettlementScheduleController::class, 'index']); + // admin ロール専用: ユーザー管理 CRUD Route::middleware('admin')->group(function () { Route::apiResource('admin/users', UserController::class) diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php new file mode 100644 index 0000000..e2733aa --- /dev/null +++ b/tests/Feature/SettlementApiTest.php @@ -0,0 +1,196 @@ +create()); + } + + /** + * 月次3回・前払(期首請求)の標準的な仕切書 payload。 + */ + private function payload(array $overrides = []): array + { + return array_merge([ + 'settlement_number' => 'S-2026-001', + 'subject' => 'サーバ保守 月額', + 'status' => 'active', + 'start_date' => '2026-01-01', + 'occurrences_count' => 3, + 'interval_unit' => 'month', + 'interval_count' => 1, + 'billing_timing' => 'advance', + 'tax_rate' => 10, + 'items' => [ + ['name' => '保守費用', 'quantity' => 1, 'unit' => '式', 'unit_price' => 50000], + ], + ], $overrides); + } + + public function test_store_creates_settlement_with_all_schedules(): void + { + $res = $this->postJson('/api/settlements', $this->payload()); + + $res->assertCreated() + ->assertJsonPath('settlement_number', 'S-2026-001') + ->assertJsonPath('unit_subtotal', 50000) + ->assertJsonPath('unit_tax_amount', 5000) + ->assertJsonPath('unit_total_amount', 55000) + ->assertJsonCount(3, 'settlement_schedules') + ->assertJsonPath('settlement_schedules.0.sequence_no', 1) + ->assertJsonPath('settlement_schedules.0.billing_date', '2026-01-01') + ->assertJsonPath('settlement_schedules.1.billing_date', '2026-02-01') + ->assertJsonPath('settlement_schedules.2.billing_date', '2026-03-01') + ->assertJsonPath('settlement_schedules.0.total_amount', 55000); + + $this->assertDatabaseCount('settlements', 1); + $this->assertDatabaseCount('settlement_schedules', 3); + } + + public function test_store_recalculates_amounts_server_side(): void + { + // クライアントが送る合計金額は信用せず、items からサーバ側で再計算する。 + $res = $this->postJson('/api/settlements', $this->payload([ + 'items' => [ + ['name' => '保守費用', 'quantity' => 2, 'unit_price' => 30000, 'total' => 1], + ], + ]) + ['unit_total_amount' => 1]); + + $res->assertCreated() + ->assertJsonPath('items.0.total', 60000) + ->assertJsonPath('unit_subtotal', 60000) + ->assertJsonPath('unit_tax_amount', 6000) + ->assertJsonPath('unit_total_amount', 66000); + } + + public function test_store_requires_end_condition(): void + { + // 終了条件(end_date か occurrences_count)が両方 null なら 422(無期限は不可)。 + $payload = $this->payload(); + unset($payload['occurrences_count']); + + $this->postJson('/api/settlements', $payload) + ->assertUnprocessable() + ->assertJsonValidationErrors(['end_date', 'occurrences_count']); + } + + public function test_settlement_number_must_be_unique_but_null_allows_multiple(): void + { + $this->postJson('/api/settlements', $this->payload())->assertCreated(); + + $this->postJson('/api/settlements', $this->payload()) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settlement_number']); + + // null(番号なし下書き)は UNIQUE の対象外なので複数作成できる。 + $this->postJson('/api/settlements', $this->payload(['settlement_number' => null]))->assertCreated(); + $this->postJson('/api/settlements', $this->payload(['settlement_number' => null]))->assertCreated(); + } + + public function test_update_regenerates_future_schedules_and_keeps_past_snapshots(): void + { + $id = $this->postJson('/api/settlements', $this->payload())->json('id'); + + // 2回目の請求(2026-02-01)まで過去になった時点で単価を改定する。 + $this->travelTo('2026-02-15'); + $res = $this->putJson("/api/settlements/{$id}", $this->payload([ + 'items' => [ + ['name' => '保守費用', 'quantity' => 1, 'unit' => '式', 'unit_price' => 60000], + ], + ])); + $this->travelBack(); + + // 過去回(第1回・第2回)はスナップショット不変、未来回(第3回)だけ新単価で再生成。 + $res->assertOk() + ->assertJsonPath('unit_total_amount', 66000) + ->assertJsonCount(3, 'settlement_schedules') + ->assertJsonPath('settlement_schedules.0.total_amount', 55000) + ->assertJsonPath('settlement_schedules.1.total_amount', 55000) + ->assertJsonPath('settlement_schedules.2.sequence_no', 3) + ->assertJsonPath('settlement_schedules.2.billing_date', '2026-03-01') + ->assertJsonPath('settlement_schedules.2.total_amount', 66000); + } + + public function test_show_and_destroy(): void + { + $id = $this->postJson('/api/settlements', $this->payload())->json('id'); + + $this->getJson("/api/settlements/{$id}") + ->assertOk() + ->assertJsonPath('id', $id) + ->assertJsonCount(3, 'settlement_schedules'); + + $this->deleteJson("/api/settlements/{$id}")->assertNoContent(); + + // 親削除で各回も cascade 削除される。 + $this->assertDatabaseCount('settlements', 0); + $this->assertDatabaseCount('settlement_schedules', 0); + } + + public function test_index_returns_settlements_with_schedules(): void + { + $this->postJson('/api/settlements', $this->payload())->assertCreated(); + $this->postJson('/api/settlements', $this->payload([ + 'settlement_number' => 'S-2026-002', + 'start_date' => '2026-04-01', + ]))->assertCreated(); + + $this->getJson('/api/settlements') + ->assertOk() + ->assertJsonCount(2) + // start_date 降順 + ->assertJsonPath('0.settlement_number', 'S-2026-002') + ->assertJsonCount(3, '0.settlement_schedules'); + } + + public function test_schedules_index_returns_and_filters_schedules(): void + { + $monthlyId = $this->postJson('/api/settlements', $this->payload())->json('id'); + $this->postJson('/api/settlements', $this->payload([ + 'settlement_number' => 'S-2026-002', + 'start_date' => '2026-01-10', + 'occurrences_count' => 2, + 'interval_unit' => 'year', + ]))->assertCreated(); + + // フィルタなし: 月次3回 + 年次2回 = 5件、billing_date 昇順。 + $this->getJson('/api/settlement-schedules') + ->assertOk() + ->assertJsonCount(5) + ->assertJsonPath('0.billing_date', '2026-01-01'); + + // settlement_id で絞り込み。 + $this->getJson("/api/settlement-schedules?settlement_id={$monthlyId}") + ->assertOk() + ->assertJsonCount(3); + + // from/to は billing_date に対する範囲(年次の2回目 2027-01-10 は範囲外)。 + $this->getJson('/api/settlement-schedules?from=2026-02-01&to=2026-12-31') + ->assertOk() + ->assertJsonCount(2) + ->assertJsonPath('0.billing_date', '2026-02-01') + ->assertJsonPath('1.billing_date', '2026-03-01'); + } + + public function test_quotes_api_is_unaffected(): void + { + // 回帰: settlements 追加後も quotes API は従来どおり動く。 + $this->postJson('/api/settlements', $this->payload())->assertCreated(); + + $this->postJson('/api/quotes', ['subject' => '回帰確認'])->assertCreated(); + $this->getJson('/api/quotes')->assertOk()->assertJsonCount(1); + } +} From 3e52ffba3d44be185f806eca2c1dc12387f9e75b Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 17:42:28 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=20TypeScript=20=E5=9E=8B=E3=83=BBadapters=E3=83=BB?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=82=A2=E3=83=83=E3=83=97=E5=AF=BE?= =?UTF-8?q?=E8=B1=A1=E6=8B=A1=E5=BC=B5=20(Phase=20C)=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: Settlement / SettlementSchedule / IntervalUnit / SettlementStatus / BillingTiming / SettlementScheduleFilters を追加、BackupPayload に settlements / settlement_schedules を追加(version 3 で追加、旧形式は optional) - api adapter: Settlement CRUD と listSettlementSchedules (settlement_id / from / to フィルタ)を追加 - local adapter: SettlementPricing / ScheduleGenerator 相当をブラウザ内で再現 (アンカー加算・月末クランプ・advance/arrears。デモ用のため更新時は全回再生成) - store.ts: adapter 切替に Settlement 経路を追加 - backup: エクスポート version 2→3。download / backup:export に settlements・settlement_schedules を追加、BackupRestorer で親→子の順に復元 (skip された親の予定は紐付けない、items は JSON encode) - テスト: SettlementBackupTest(download 構造、restore skip/overwrite、 必須カラム 422、旧 version 2 互換)、BackupTest の version 断言を 3 に更新 Co-Authored-By: Claude Fable 5 --- app/Console/Commands/BackupExport.php | 4 + app/Http/Controllers/BackupController.php | 4 + app/Services/BackupRestorer.php | 30 +++- resources/js/data/adapters/api.ts | 12 ++ resources/js/data/adapters/local.ts | 139 +++++++++++++++- resources/js/data/store.ts | 9 +- resources/js/types.ts | 61 +++++++ tests/Feature/BackupTest.php | 6 +- tests/Feature/SettlementBackupTest.php | 190 ++++++++++++++++++++++ 9 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/SettlementBackupTest.php diff --git a/app/Console/Commands/BackupExport.php b/app/Console/Commands/BackupExport.php index 0697982..095b038 100644 --- a/app/Console/Commands/BackupExport.php +++ b/app/Console/Commands/BackupExport.php @@ -6,6 +6,8 @@ use App\Models\CustomerSignature; use App\Models\Quote; use App\Models\SenderProfile; +use App\Models\Settlement; +use App\Models\SettlementSchedule; use App\Services\BackupAudit; use App\Services\BackupRestorer; use Illuminate\Console\Command; @@ -27,6 +29,8 @@ public function handle(BackupAudit $audit): int 'customers' => Customer::all()->toArray(), 'customer_signatures' => CustomerSignature::all()->toArray(), 'quotes' => Quote::all()->toArray(), + 'settlements' => Settlement::all()->toArray(), + 'settlement_schedules' => SettlementSchedule::all()->toArray(), ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); $output = $this->option('output'); diff --git a/app/Http/Controllers/BackupController.php b/app/Http/Controllers/BackupController.php index 0087400..c2afd91 100644 --- a/app/Http/Controllers/BackupController.php +++ b/app/Http/Controllers/BackupController.php @@ -8,6 +8,8 @@ use App\Models\CustomerSignature; use App\Models\Quote; use App\Models\SenderProfile; +use App\Models\Settlement; +use App\Models\SettlementSchedule; use App\Services\BackupRestorer; use App\Services\BackupStorage; use Illuminate\Http\JsonResponse; @@ -84,6 +86,8 @@ public function download(): JsonResponse 'customers' => Customer::all()->toArray(), 'customer_signatures' => CustomerSignature::all()->toArray(), 'quotes' => Quote::all()->toArray(), + 'settlements' => Settlement::all()->toArray(), + 'settlement_schedules' => SettlementSchedule::all()->toArray(), ]); } diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 9032e41..08eae7a 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -9,11 +9,14 @@ class BackupRestorer { - /** エクスポート形式のバージョン。2 で customers から customer_signatures を分離した。 */ - public const VERSION = 2; + /** + * エクスポート形式のバージョン。 + * 2 で customers から customer_signatures を分離、3 で settlements / settlement_schedules を追加した。 + */ + public const VERSION = 3; /** 復元を受け付けるバージョン。1 は customers[].customer_signature を含む旧形式。 */ - public const SUPPORTED_VERSIONS = [1, 2]; + public const SUPPORTED_VERSIONS = [1, 2, 3]; /** * @return array{inserted: int, skipped: int, updated: int, errors: array} @@ -44,13 +47,28 @@ public function restore(array $data, string $mode): array return $row; }); - $this->restoreRows('quotes', $data['quotes'] ?? [], $mode, $summary, function (array $row): array { + $encodeItems = function (array $row): array { if (isset($row['items']) && is_array($row['items'])) { $row['items'] = json_encode($row['items'], JSON_UNESCAPED_UNICODE); } return $row; - }); + }; + + $this->restoreRows('quotes', $data['quotes'] ?? [], $mode, $summary, $encodeItems); + + $restoredSettlementIds = $this->restoreRows('settlements', $data['settlements'] ?? [], $mode, $summary, $encodeItems); + + $scheduleRows = []; + foreach ($data['settlement_schedules'] ?? [] as $row) { + if (in_array($row['settlement_id'] ?? null, $restoredSettlementIds, true)) { + $scheduleRows[] = $row; + } else { + // skip された親や存在しない親の予定を、同じIDの別仕切書へ紐付けない。 + $summary['skipped']++; + } + } + $this->restoreRows('settlement_schedules', $scheduleRows, $mode, $summary, $encodeItems); }); return $summary; @@ -67,6 +85,8 @@ private function validateRows(array $data): void 'customers' => ['customer_name'], 'customer_signatures' => ['customer_id', 'signature'], 'quotes' => [], + 'settlements' => ['start_date', 'interval_unit'], + 'settlement_schedules' => ['settlement_id', 'sequence_no', 'period_start', 'period_end', 'billing_date'], ]; foreach ($requiredColumns as $table => $columns) { diff --git a/resources/js/data/adapters/api.ts b/resources/js/data/adapters/api.ts index c5f8482..6004c51 100644 --- a/resources/js/data/adapters/api.ts +++ b/resources/js/data/adapters/api.ts @@ -5,6 +5,7 @@ import type { AuthUser, BackupPayload, Customer as TCustomer, EntityAdapter, ID, ImportResponse, ManagedUser, Quote as TQuote, RestoreCredentialsMode, RestoreResult, SenderProfile as TSenderProfile, + Settlement as TSettlement, SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, SetupStatus, StoredBackup, StoredRestoreResult, } from '../../types'; @@ -88,6 +89,17 @@ function makeEntity(path: string): EntityAdapter { export const Quote: EntityAdapter = makeEntity('quotes'); export const Customer: EntityAdapter = makeEntity('customers'); export const SenderProfile: EntityAdapter = makeEntity('sender-profiles'); +export const Settlement: EntityAdapter = makeEntity('settlements'); + +// 各回の請求予定データを取得する(フィルタ: settlement_id / from / to は billing_date に対する範囲)。 +export function listSettlementSchedules(filters: SettlementScheduleFilters = {}): Promise { + const params = new URLSearchParams(); + if (filters.settlement_id !== undefined && filters.settlement_id !== '') params.set('settlement_id', String(filters.settlement_id)); + if (filters.from) params.set('from', filters.from); + if (filters.to) params.set('to', filters.to); + const qs = params.toString(); + return request('GET', `/api/settlement-schedules${qs ? `?${qs}` : ''}`); +} // 御見積書ファイル(複数可)を multipart/form-data で取り込む。 // FormData は request() を通さない: JSON 化や手動 Content-Type 付与は diff --git a/resources/js/data/adapters/local.ts b/resources/js/data/adapters/local.ts index 5edeb75..e951faa 100644 --- a/resources/js/data/adapters/local.ts +++ b/resources/js/data/adapters/local.ts @@ -1,6 +1,10 @@ // localStorage アダプター(オフライン/デモ/テスト用)。 // API を使わずブラウザ内で完結する。e2e はこのアダプターで分離性を確保する。 -import type { Customer as TCustomer, EntityAdapter, ID, ImportResponse, ImportResult, LineItem, Quote as TQuote, SenderProfile as TSenderProfile } from '../../types'; +import type { + Customer as TCustomer, EntityAdapter, ID, ImportResponse, ImportResult, IntervalUnit, LineItem, + Quote as TQuote, SenderProfile as TSenderProfile, Settlement as TSettlement, + SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, +} from '../../types'; const NS = 'quotes'; @@ -89,6 +93,139 @@ SenderProfile.update = async (id, data) => { return r; }; +// --- 仕切書(settlements)と各回の請求予定(settlement_schedules) --- +// サーバ側 SettlementPricing / SettlementScheduleGenerator と同じ規則 +// (金額再計算、アンカー日からの no-overflow 加算、月末クランプ)を local で再現する。 +// デモ/e2e 用の簡易実装のため、更新時は過去回の保持を行わず全回を作り直す。 + +const SCHEDULE_KEY = 'settlement_schedules'; + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +// 常に初回開始日(アンカー)から加算し、月末周期のドリフトを防ぐ(1/31 + 1ヶ月 → 2/28|29)。 +function addInterval(iso: string, unit: IntervalUnit, amount: number): string { + const [y, m, d] = iso.split('-').map(Number); + const total = (m - 1) + (unit === 'year' ? amount * 12 : amount); + const ny = y + Math.floor(total / 12); + const nm = (total % 12) + 1; + const daysInMonth = new Date(ny, nm, 0).getDate(); + return `${ny}-${pad2(nm)}-${pad2(Math.min(d, daysInMonth))}`; +} + +function subDay(iso: string): string { + const [y, m, d] = iso.split('-').map(Number); + const dt = new Date(y, m - 1, d - 1); + return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`; +} + +// advance は期首、arrears は期末を基準に、billing_day 指定時は月内日数へクランプする。 +function billingDateOf(periodStart: string, periodEnd: string, s: TSettlement): string { + const boundary = s.billing_timing === 'arrears' ? periodEnd : periodStart; + if (s.billing_day == null) return boundary; + const [y, m] = boundary.split('-').map(Number); + const daysInMonth = new Date(y, m, 0).getDate(); + return `${y}-${pad2(m)}-${pad2(Math.min(s.billing_day, daysInMonth))}`; +} + +// SettlementPricing::apply 相当。items から 1回分の小計・税・税込額を再計算する。 +function applySettlementPricing(data: Partial): Partial { + const items = (data.items ?? []).map((it) => ({ + ...it, + total: (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), + })); + const subtotal = items.reduce((sum, it) => sum + it.total, 0); + const tax = Math.floor((subtotal * Number(data.tax_rate ?? 10)) / 100); + return { ...data, items, unit_subtotal: subtotal, unit_tax_amount: tax, unit_total_amount: subtotal + tax }; +} + +// SettlementScheduleGenerator::generate 相当。終了条件が無い場合は生成しない。 +function generateSchedules(s: TSettlement): TSettlementSchedule[] { + if (!s.start_date || (!s.end_date && s.occurrences_count == null)) return []; + const intervalCount = Number(s.interval_count) || 1; + const rows: TSettlementSchedule[] = []; + + for (let index = 0; ; index++) { + const sequenceNo = index + 1; + if (s.occurrences_count != null && sequenceNo > s.occurrences_count) break; + + const periodStart = addInterval(s.start_date, s.interval_unit, intervalCount * index); + if (s.end_date && periodStart > s.end_date) break; + + let periodEnd = subDay(addInterval(s.start_date, s.interval_unit, intervalCount * (index + 1))); + if (s.end_date && periodEnd > s.end_date) periodEnd = s.end_date; + + rows.push({ + id: uid(), + settlement_id: s.id, + sequence_no: sequenceNo, + period_start: periodStart, + period_end: periodEnd, + billing_date: billingDateOf(periodStart, periodEnd, s), + due_date: null, + subtotal: s.unit_subtotal ?? 0, + tax_amount: s.unit_tax_amount ?? 0, + total_amount: s.unit_total_amount ?? 0, + items: s.items ?? [], + }); + } + + return rows; +} + +function replaceSchedules(s: TSettlement): void { + const others = read(SCHEDULE_KEY).filter((r) => String(r.settlement_id) !== String(s.id)); + write(SCHEDULE_KEY, [...others, ...generateSchedules(s)]); +} + +function schedulesOf(id: ID): TSettlementSchedule[] { + return read(SCHEDULE_KEY) + .filter((r) => String(r.settlement_id) === String(id)) + .sort((a, b) => a.sequence_no - b.sequence_no); +} + +const settlementBase = makeEntity>('settlements'); + +// API と同形状(settlement_schedules を含む)で返す。 +export const Settlement: EntityAdapter = { + async list(sort?: string) { + const rows = await settlementBase.list(sort); + return rows.map((r) => ({ ...r, settlement_schedules: schedulesOf(r.id) })); + }, + async get(id: ID) { + const row = await settlementBase.get(id); + return row ? { ...row, settlement_schedules: schedulesOf(row.id) } : null; + }, + async create(data: Partial) { + const row = await settlementBase.create(applySettlementPricing(data)); + replaceSchedules(row); + return { ...row, settlement_schedules: schedulesOf(row.id) }; + }, + async update(id: ID, data: Partial) { + const row = await settlementBase.update(id, applySettlementPricing(data)); + if (!row) return null; + replaceSchedules(row); + return { ...row, settlement_schedules: schedulesOf(row.id) }; + }, + async delete(id: ID) { + write(SCHEDULE_KEY, read(SCHEDULE_KEY).filter((r) => String(r.settlement_id) !== String(id))); + return settlementBase.delete(id); + }, +}; + +// GET /api/settlement-schedules と同じフィルタ・並び順(billing_date 昇順)。 +export async function listSettlementSchedules(filters: SettlementScheduleFilters = {}): Promise { + return read(SCHEDULE_KEY) + .filter((r) => { + if (filters.settlement_id !== undefined && filters.settlement_id !== '' && String(r.settlement_id) !== String(filters.settlement_id)) return false; + if (filters.from && r.billing_date < filters.from) return false; + if (filters.to && r.billing_date > filters.to) return false; + return true; + }) + .sort((a, b) => a.billing_date.localeCompare(b.billing_date) || a.sequence_no - b.sequence_no); +} + // 取込のモック。シート解析はサーバー専用機能のため、local では実ファイルを // 解析せず、ファイル名から下書き Quote を 1 件生成して API と同形状の結果を返す。 // (API モードでの取込結果プレビューと同じ UI を e2e/デモで検証できるようにする) diff --git a/resources/js/data/store.ts b/resources/js/data/store.ts index 4fd0301..ddf60c4 100644 --- a/resources/js/data/store.ts +++ b/resources/js/data/store.ts @@ -4,12 +4,17 @@ // 切替: localStorage.setItem('zensales:adapter', 'local') import * as apiAdapter from './adapters/api'; import * as localAdapter from './adapters/local'; -import type { Customer as TCustomer, EntityAdapter, ImportResponse, Quote as TQuote, SenderProfile as TSenderProfile } from '../types'; +import type { + Customer as TCustomer, EntityAdapter, ImportResponse, Quote as TQuote, SenderProfile as TSenderProfile, + Settlement as TSettlement, SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, +} from '../types'; type Adapter = { Quote: EntityAdapter; Customer: EntityAdapter; SenderProfile: EntityAdapter; + Settlement: EntityAdapter; + listSettlementSchedules: (filters?: SettlementScheduleFilters) => Promise; importQuotes: (files: File[]) => Promise; seedIfEmpty: () => void; }; @@ -33,6 +38,8 @@ const adapter = pick(); export const Quote = adapter.Quote; export const Customer = adapter.Customer; export const SenderProfile = adapter.SenderProfile; +export const Settlement = adapter.Settlement; +export const listSettlementSchedules = adapter.listSettlementSchedules; export const importQuotes = adapter.importQuotes; export const seedIfEmpty = adapter.seedIfEmpty; diff --git a/resources/js/types.ts b/resources/js/types.ts index 9599edc..60f90fe 100644 --- a/resources/js/types.ts +++ b/resources/js/types.ts @@ -98,6 +98,64 @@ export interface Quote extends SenderSnapshot, CustomerSnapshot { updated_at?: string; } +// 仕切書(定期性取引のドキュメント)と各回の請求予定データ(issue #54) +export type SettlementStatus = 'draft' | 'active' | 'ended'; +export type IntervalUnit = 'month' | 'year'; +export type BillingTiming = 'advance' | 'arrears'; + +export interface Settlement extends SenderSnapshot, CustomerSnapshot { + id: ID; + settlement_number: string; + subject: string; + status: SettlementStatus; + // 終了条件は end_date か occurrences_count のいずれか必須(無期限は不可) + start_date: string; + end_date: string | null; + occurrences_count: number | null; + interval_unit: IntervalUnit; + interval_count: number; + // 請求日(毎月N日)。月内日数を超える指定は月末にクランプ。null は期境界に従う + billing_day: number | null; + billing_timing: BillingTiming; + payment_terms: string; + tax_rate: number; + notes: string; + sender_profile_id: ID | ''; + customer_id: ID | ''; + // 定期1回あたりの明細・金額(サーバ側で再計算される) + items: LineItem[]; + unit_subtotal?: number; + unit_tax_amount?: number; + unit_total_amount?: number; + settlement_schedules?: SettlementSchedule[]; + created_at?: string; + updated_at?: string; +} + +// 各回の請求予定(生成時点の金額・明細スナップショット。実績は持たない) +export interface SettlementSchedule { + id: ID; + settlement_id: ID; + sequence_no: number; + period_start: string; + period_end: string; + billing_date: string; + due_date: string | null; + subtotal: number; + tax_amount: number; + total_amount: number; + items: LineItem[]; + created_at?: string; + updated_at?: string; +} + +// GET /api/settlement-schedules のフィルタ(from/to は billing_date に対する範囲) +export interface SettlementScheduleFilters { + settlement_id?: ID; + from?: string; + to?: string; +} + // 取込結果(1 ファイル単位)。成功と失敗を判別可能なユニオンで表し、 // 「quote_id も error も無い」中間状態を型レベルで排除する。 export interface ImportSuccess { @@ -136,6 +194,9 @@ export interface BackupPayload { customers: Omit[]; customer_signatures: { id: ID; customer_id: ID; signature: string; created_at?: string; updated_at?: string }[]; quotes: Quote[]; + // version 3 で追加(旧バックアップには存在しない) + settlements?: Settlement[]; + settlement_schedules?: SettlementSchedule[]; } export interface RestoreResult { diff --git a/tests/Feature/BackupTest.php b/tests/Feature/BackupTest.php index a42a6dd..fd58b5e 100644 --- a/tests/Feature/BackupTest.php +++ b/tests/Feature/BackupTest.php @@ -33,8 +33,8 @@ public function test_download_returns_correct_structure(): void $res = $this->getJson('/api/backup/download'); $res->assertOk() - ->assertJsonStructure(['version', 'exported_at', 'sender_profiles', 'customers', 'customer_signatures', 'quotes']) - ->assertJsonPath('version', 2) + ->assertJsonStructure(['version', 'exported_at', 'sender_profiles', 'customers', 'customer_signatures', 'quotes', 'settlements', 'settlement_schedules']) + ->assertJsonPath('version', 3) ->assertJsonCount(1, 'sender_profiles') ->assertJsonCount(1, 'customers') ->assertJsonCount(1, 'customer_signatures') @@ -282,7 +282,7 @@ public function test_artisan_export_writes_to_file(): void $this->artisan("backup:export --output={$path}")->assertSuccessful(); $data = json_decode(file_get_contents($path), true); - $this->assertSame(2, $data['version']); + $this->assertSame(3, $data['version']); unlink($path); } diff --git a/tests/Feature/SettlementBackupTest.php b/tests/Feature/SettlementBackupTest.php new file mode 100644 index 0000000..79c8666 --- /dev/null +++ b/tests/Feature/SettlementBackupTest.php @@ -0,0 +1,190 @@ +actingAs(User::factory()->create(['role' => 'admin'])); + } + + public function test_download_includes_settlements_and_schedules(): void + { + $settlement = Settlement::create([ + 'settlement_number' => 'S-001', + 'start_date' => '2026-01-01', + 'occurrences_count' => 1, + 'interval_unit' => 'month', + 'items' => [['name' => '保守費用']], + ]); + $settlement->settlementSchedules()->create([ + 'sequence_no' => 1, + 'period_start' => '2026-01-01', + 'period_end' => '2026-01-31', + 'billing_date' => '2026-01-01', + 'items' => [['name' => '保守費用']], + ]); + + $this->getJson('/api/backup/download') + ->assertOk() + ->assertJsonCount(1, 'settlements') + ->assertJsonCount(1, 'settlement_schedules') + ->assertJsonPath('settlements.0.settlement_number', 'S-001') + ->assertJsonPath('settlements.0.items.0.name', '保守費用') + ->assertJsonPath('settlement_schedules.0.sequence_no', 1); + } + + public function test_restore_inserts_settlements_and_schedules(): void + { + $payload = $this->makeBackup( + settlements: [[ + 'id' => 1, 'settlement_number' => 'S-001', 'start_date' => '2026-01-01', + 'occurrences_count' => 1, 'interval_unit' => 'month', + 'items' => [['name' => '保守費用', 'quantity' => 1, 'unit_price' => 50000, 'total' => 50000]], + 'created_at' => now(), 'updated_at' => now(), + ]], + settlementSchedules: [[ + 'id' => 1, 'settlement_id' => 1, 'sequence_no' => 1, + 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'billing_date' => '2026-01-01', + 'items' => [['name' => '保守費用', 'quantity' => 1, 'unit_price' => 50000, 'total' => 50000]], + 'created_at' => now(), 'updated_at' => now(), + ]], + ); + + $this->postRestore($payload) + ->assertOk() + ->assertJsonPath('inserted', 2) + ->assertJsonPath('errors', []); + + $this->assertDatabaseHas('settlements', ['settlement_number' => 'S-001']); + $this->assertDatabaseHas('settlement_schedules', ['settlement_id' => 1, 'sequence_no' => 1]); + // items は JSON 文字列として保存され、model cast で配列に戻る。 + $this->assertSame('保守費用', SettlementSchedule::first()->items[0]['name']); + } + + public function test_restore_skip_does_not_link_schedules_to_existing_settlement(): void + { + $existing = Settlement::create([ + 'settlement_number' => 'S-EXISTING', + 'start_date' => '2026-01-01', + 'occurrences_count' => 1, + 'interval_unit' => 'month', + ]); + + // skip モードでは既存 id の仕切書は上書きされず、その予定も別仕切書へ紐付けない。 + $payload = $this->makeBackup( + settlements: [[ + 'id' => $existing->id, 'settlement_number' => 'S-RESTORED', 'start_date' => '2026-02-01', + 'occurrences_count' => 1, 'interval_unit' => 'month', + 'created_at' => now(), 'updated_at' => now(), + ]], + settlementSchedules: [[ + 'id' => 1, 'settlement_id' => $existing->id, 'sequence_no' => 1, + 'period_start' => '2026-02-01', 'period_end' => '2026-02-28', 'billing_date' => '2026-02-01', + 'created_at' => now(), 'updated_at' => now(), + ]], + ); + + $this->postRestore($payload) + ->assertOk() + ->assertJsonPath('inserted', 0) + ->assertJsonPath('skipped', 2); + + $this->assertDatabaseHas('settlements', ['settlement_number' => 'S-EXISTING']); + $this->assertDatabaseCount('settlement_schedules', 0); + } + + public function test_restore_overwrite_updates_settlement_and_relinks_schedules(): void + { + $existing = Settlement::create([ + 'settlement_number' => 'S-OLD', + 'start_date' => '2026-01-01', + 'occurrences_count' => 1, + 'interval_unit' => 'month', + ]); + + $payload = $this->makeBackup( + settlements: [[ + 'id' => $existing->id, 'settlement_number' => 'S-NEW', 'start_date' => '2026-01-01', + 'occurrences_count' => 1, 'interval_unit' => 'month', + 'created_at' => now(), 'updated_at' => now(), + ]], + settlementSchedules: [[ + 'id' => 1, 'settlement_id' => $existing->id, 'sequence_no' => 1, + 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'billing_date' => '2026-01-01', + 'created_at' => now(), 'updated_at' => now(), + ]], + ); + + $this->postRestore($payload, 'overwrite') + ->assertOk() + ->assertJsonPath('updated', 1) + ->assertJsonPath('inserted', 1); + + $this->assertDatabaseHas('settlements', ['settlement_number' => 'S-NEW']); + $this->assertDatabaseHas('settlement_schedules', ['settlement_id' => $existing->id]); + } + + public function test_restore_rejects_schedule_missing_required_columns(): void + { + // billing_date 欠落は DB の NOT NULL 違反(500)になる前に 422 で拒否する。 + $payload = $this->makeBackup(settlementSchedules: [[ + 'id' => 1, 'settlement_id' => 1, 'sequence_no' => 1, + 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', + ]]); + + $this->postRestore($payload)->assertUnprocessable(); + } + + public function test_restore_of_version_two_backup_without_settlements_still_works(): void + { + // 旧 version 2 バックアップ(settlements キーなし)も従来どおり復元できる。 + $payload = json_encode([ + 'version' => 2, + 'exported_at' => now()->toIso8601String(), + 'sender_profiles' => [], + 'customers' => [['id' => 1, 'customer_name' => '旧形式商事', 'created_at' => now(), 'updated_at' => now()]], + 'customer_signatures' => [], + 'quotes' => [], + ]); + + $this->postRestore($payload)->assertOk()->assertJsonPath('inserted', 1); + $this->assertDatabaseHas('customers', ['customer_name' => '旧形式商事']); + } + + // ---------- ヘルパー ---------- + + private function makeBackup(array $settlements = [], array $settlementSchedules = []): string + { + return json_encode([ + 'version' => 3, + 'exported_at' => now()->toIso8601String(), + 'sender_profiles' => [], + 'customers' => [], + 'customer_signatures' => [], + 'quotes' => [], + 'settlements' => $settlements, + 'settlement_schedules' => $settlementSchedules, + ]); + } + + /** multipart/form-data で /api/backup/restore を呼ぶ */ + private function postRestore(string $payload, string $mode = 'skip'): TestResponse + { + $file = UploadedFile::fake()->createWithContent('backup.json', $payload); + + return $this->call('POST', '/api/backup/restore', ['mode' => $mode], [], ['file' => $file], ['Accept' => 'application/json']); + } +} From 5ededc9429c3309235669e35f7f656f493dc1d43 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 19:48:15 +0900 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20PR=20#56=20=E3=81=AE=20Copilot=20r?= =?UTF-8?q?eview=20=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackupRestorer: 復元IDの親子照合を文字列キーの set で行い、JSON decode の int/string 型不一致で settlement_schedules が全件 skip される不具合を修正 - SettlementController#store: 仕切書作成と各回生成を DB::transaction で包み、 途中失敗時に親だけ残る中途半端な状態を防止 - SettlementController: status/interval_count/billing_timing/tax_rate は DB が NOT NULL + default のため、nullable ではなく sometimes に変更。 明示的な null 送信が 500 ではなく 422 になるようにした - types.ts: DB で nullable な settlement_number/subject/payment_terms/notes/items を `| null` に修正し、API の実レスポンスと型を一致させた - local adapter: 終了条件(end_date/occurrences_count)が無い場合に generateSchedules() が黙って空配列を返していたのを、サーバ同様に 422相当のエラーで弾くよう assertEndCondition を追加 - 各修正を検証する Feature テストを追加 Co-Authored-By: Claude Fable 5 --- app/Http/Controllers/SettlementController.php | 30 ++++++++++------- app/Services/BackupRestorer.php | 4 ++- resources/js/data/adapters/local.ts | 16 +++++++++ resources/js/types.ts | 13 ++++---- tests/Feature/SettlementApiTest.php | 33 +++++++++++++++++++ tests/Feature/SettlementBackupTest.php | 29 ++++++++++++++++ 6 files changed, 107 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php index b695e7c..4b12761 100644 --- a/app/Http/Controllers/SettlementController.php +++ b/app/Http/Controllers/SettlementController.php @@ -6,6 +6,7 @@ use App\Support\SettlementPricing; use App\Support\SettlementScheduleGenerator; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; class SettlementController extends Controller @@ -22,13 +23,17 @@ public function store(Request $request) { $data = SettlementPricing::apply($this->validated($request)); - $settlement = Settlement::create($data); - // interval_count 等の DB default を attributes に反映してから各回を生成する。 - $settlement->refresh(); - // 新規作成時は過去/未来の区別なく全回を予定データとして持つ。 - $settlement->settlementSchedules()->createMany( - SettlementScheduleGenerator::generate($settlement), - ); + $settlement = DB::transaction(function () use ($data) { + $settlement = Settlement::create($data); + // interval_count 等の DB default を attributes に反映してから各回を生成する。 + $settlement->refresh(); + // 新規作成時は過去/未来の区別なく全回を予定データとして持つ。 + $settlement->settlementSchedules()->createMany( + SettlementScheduleGenerator::generate($settlement), + ); + + return $settlement; + }); return response()->json($settlement->load('settlementSchedules'), 201); } @@ -65,19 +70,22 @@ private function validated(Request $request): array Rule::unique('settlements', 'settlement_number')->ignore($request->route('settlement')), ], 'subject' => ['nullable', 'string', 'max:255'], - 'status' => ['nullable', 'in:draft,active,ended'], + // status/interval_count/billing_timing/tax_rate は DB 側が NOT NULL + default。 + // nullable だと明示的な null が DB エラー(500)になるため、未指定は default に + // 任せつつ null は 422 で弾くために sometimes を使う(値が来た時だけ検証する)。 + 'status' => ['sometimes', 'in:draft,active,ended'], // 終了条件は必須: end_date か occurrences_count のいずれか(無期限は不可)。 'start_date' => ['required', 'date'], 'end_date' => ['nullable', 'required_without:occurrences_count', 'date', 'after_or_equal:start_date'], 'occurrences_count' => ['nullable', 'required_without:end_date', 'integer', 'min:1'], 'interval_unit' => ['required', 'in:month,year'], - 'interval_count' => ['nullable', 'integer', 'min:1'], + 'interval_count' => ['sometimes', 'integer', 'min:1'], 'billing_day' => ['nullable', 'integer', 'between:1,31'], - 'billing_timing' => ['nullable', 'in:advance,arrears'], + 'billing_timing' => ['sometimes', 'in:advance,arrears'], 'payment_terms' => ['nullable', 'string', 'max:255'], - 'tax_rate' => ['nullable', 'integer', 'min:0', 'max:100'], + 'tax_rate' => ['sometimes', 'integer', 'min:0', 'max:100'], 'notes' => ['nullable', 'string'], 'sender_profile_id' => ['nullable'], diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 08eae7a..387d431 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -58,10 +58,12 @@ public function restore(array $data, string $mode): array $this->restoreRows('quotes', $data['quotes'] ?? [], $mode, $summary, $encodeItems); $restoredSettlementIds = $this->restoreRows('settlements', $data['settlements'] ?? [], $mode, $summary, $encodeItems); + // JSON decode で int/string どちらにもなり得るため、文字列キーの set で照合する。 + $restoredSettlementIdSet = array_fill_keys(array_map('strval', $restoredSettlementIds), true); $scheduleRows = []; foreach ($data['settlement_schedules'] ?? [] as $row) { - if (in_array($row['settlement_id'] ?? null, $restoredSettlementIds, true)) { + if (isset($restoredSettlementIdSet[(string) ($row['settlement_id'] ?? '')])) { $scheduleRows[] = $row; } else { // skip された親や存在しない親の予定を、同じIDの別仕切書へ紐付けない。 diff --git a/resources/js/data/adapters/local.ts b/resources/js/data/adapters/local.ts index 8280022..5f9eac8 100644 --- a/resources/js/data/adapters/local.ts +++ b/resources/js/data/adapters/local.ts @@ -140,6 +140,18 @@ function applySettlementPricing(data: Partial): Partial): void { + if (s.end_date != null || s.occurrences_count != null) return; + const message = 'end_date か occurrences_count のいずれかが必要です。'; + const err = new Error(message) as Error & { status?: number; errors?: Record }; + err.status = 422; + err.errors = { end_date: [message], occurrences_count: [message] }; + throw err; +} + // SettlementScheduleGenerator::generate 相当。終了条件が無い場合は生成しない。 function generateSchedules(s: TSettlement): TSettlementSchedule[] { if (!s.start_date || (!s.end_date && s.occurrences_count == null)) return []; @@ -198,11 +210,15 @@ export const Settlement: EntityAdapter = { return row ? { ...row, settlement_schedules: schedulesOf(row.id) } : null; }, async create(data: Partial) { + assertEndCondition(data); const row = await settlementBase.create(applySettlementPricing(data)); replaceSchedules(row); return { ...row, settlement_schedules: schedulesOf(row.id) }; }, async update(id: ID, data: Partial) { + const existing = await settlementBase.get(id); + if (!existing) return null; + assertEndCondition({ ...existing, ...data }); const row = await settlementBase.update(id, applySettlementPricing(data)); if (!row) return null; replaceSchedules(row); diff --git a/resources/js/types.ts b/resources/js/types.ts index 60f90fe..7f743d6 100644 --- a/resources/js/types.ts +++ b/resources/js/types.ts @@ -105,8 +105,9 @@ export type BillingTiming = 'advance' | 'arrears'; export interface Settlement extends SenderSnapshot, CustomerSnapshot { id: ID; - settlement_number: string; - subject: string; + // DB は nullable のため、未設定時は null(quote_number と同じ「番号なし下書き」を許容) + settlement_number: string | null; + subject: string | null; status: SettlementStatus; // 終了条件は end_date か occurrences_count のいずれか必須(無期限は不可) start_date: string; @@ -117,13 +118,13 @@ export interface Settlement extends SenderSnapshot, CustomerSnapshot { // 請求日(毎月N日)。月内日数を超える指定は月末にクランプ。null は期境界に従う billing_day: number | null; billing_timing: BillingTiming; - payment_terms: string; + payment_terms: string | null; tax_rate: number; - notes: string; + notes: string | null; sender_profile_id: ID | ''; customer_id: ID | ''; - // 定期1回あたりの明細・金額(サーバ側で再計算される) - items: LineItem[]; + // 定期1回あたりの明細・金額(サーバ側で再計算される)。DB は nullable。 + items: LineItem[] | null; unit_subtotal?: number; unit_tax_amount?: number; unit_total_amount?: number; diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php index e2733aa..0d68118 100644 --- a/tests/Feature/SettlementApiTest.php +++ b/tests/Feature/SettlementApiTest.php @@ -87,6 +87,39 @@ public function test_store_requires_end_condition(): void ->assertJsonValidationErrors(['end_date', 'occurrences_count']); } + /** + * status/interval_count/billing_timing/tax_rate は DB が NOT NULL + default。 + * 明示的に null を送っても DB エラー(500)にならず 422 で弾かれることを確認する + * (レビュー指摘: nullable ルールだと null がそのまま create() に渡り 500 になっていた)。 + */ + public function test_explicit_null_on_not_null_columns_returns_422_not_500(): void + { + foreach (['status', 'interval_count', 'billing_timing', 'tax_rate'] as $field) { + $res = $this->postJson('/api/settlements', $this->payload([$field => null])); + $res->assertUnprocessable(); + } + } + + /** + * 上記フィールドを省略した場合は DB default が適用され、正常に作成できる + * (sometimes ルールは値が存在する場合のみ検証し、欠落時は create() に渡さない)。 + */ + public function test_omitting_not_null_columns_falls_back_to_db_defaults(): void + { + $payload = $this->payload(); + unset($payload['status'], $payload['interval_count'], $payload['billing_timing'], $payload['tax_rate']); + + $res = $this->postJson('/api/settlements', $payload); + + $res->assertCreated() + ->assertJsonPath('status', 'draft') + ->assertJsonPath('interval_count', 1) + ->assertJsonPath('billing_timing', 'advance'); + // tax_rate の DB default は 10。SettlementPricing::apply が明示的に埋めるが、 + // ここでは default 経路の確認として妥当な値であることのみ検証する。 + $this->assertSame(10, $res->json('tax_rate')); + } + public function test_settlement_number_must_be_unique_but_null_allows_multiple(): void { $this->postJson('/api/settlements', $this->payload())->assertCreated(); diff --git a/tests/Feature/SettlementBackupTest.php b/tests/Feature/SettlementBackupTest.php index 79c8666..b47f475 100644 --- a/tests/Feature/SettlementBackupTest.php +++ b/tests/Feature/SettlementBackupTest.php @@ -74,6 +74,35 @@ public function test_restore_inserts_settlements_and_schedules(): void $this->assertSame('保守費用', SettlementSchedule::first()->items[0]['name']); } + /** + * json_decode は数値を int/string どちらにもデコードし得る(バックアップファイルの + * 手動編集や別ツール生成では特に)。settlement の id が文字列、schedule 側の + * settlement_id が数値のように型が食い違っても、親子の紐付けが skip されないことを確認する + * (レビュー指摘: 型不一致で in_array の strict 比較が全滅する不具合の回帰テスト)。 + */ + public function test_restore_links_schedule_to_settlement_even_with_mismatched_id_types(): void + { + $payload = $this->makeBackup( + settlements: [[ + 'id' => '1', 'settlement_number' => 'S-TYPE', 'start_date' => '2026-01-01', + 'occurrences_count' => 1, 'interval_unit' => 'month', + 'created_at' => now(), 'updated_at' => now(), + ]], + settlementSchedules: [[ + 'id' => 1, 'settlement_id' => 1, 'sequence_no' => 1, + 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'billing_date' => '2026-01-01', + 'created_at' => now(), 'updated_at' => now(), + ]], + ); + + $this->postRestore($payload) + ->assertOk() + ->assertJsonPath('inserted', 2) + ->assertJsonPath('skipped', 0); + + $this->assertDatabaseHas('settlement_schedules', ['settlement_id' => 1, 'sequence_no' => 1]); + } + public function test_restore_skip_does_not_link_schedules_to_existing_settlement(): void { $existing = Settlement::create([ From e685d93420260f81ac971f79ffa746affc12cb8d Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 20:15:27 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20PR=20#56=20=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Models/Settlement.php | 2 +- app/Services/BackupRestorer.php | 17 ++++++++++++++ resources/js/types.ts | 2 +- tests/Feature/SettlementBackupTest.php | 22 +++++++++++++++++++ .../SettlementScheduleGeneratorTest.php | 15 +++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/app/Models/Settlement.php b/app/Models/Settlement.php index 1cc30a4..a4ac36a 100644 --- a/app/Models/Settlement.php +++ b/app/Models/Settlement.php @@ -24,6 +24,6 @@ class Settlement extends Model public function settlementSchedules(): HasMany { - return $this->hasMany(SettlementSchedule::class); + return $this->hasMany(SettlementSchedule::class)->orderBy('sequence_no'); } } diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 387d431..30c764e 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -90,6 +90,13 @@ private function validateRows(array $data): void 'settlements' => ['start_date', 'interval_unit'], 'settlement_schedules' => ['settlement_id', 'sequence_no', 'period_start', 'period_end', 'billing_date'], ]; + $nonNullableDefaultColumns = [ + 'settlements' => [ + 'status', 'interval_count', 'billing_timing', 'tax_rate', + 'unit_subtotal', 'unit_tax_amount', 'unit_total_amount', + ], + 'settlement_schedules' => ['subtotal', 'tax_amount', 'total_amount'], + ]; foreach ($requiredColumns as $table => $columns) { if (! array_key_exists($table, $data)) { @@ -123,6 +130,16 @@ private function validateRows(array $data): void ]); } } + + // default 付きカラムは省略を許すが、明示的な null / 非スカラ値は DB 例外になるため拒否する。 + foreach ($nonNullableDefaultColumns[$table] ?? [] as $column) { + if (array_key_exists($column, $row) + && (! is_scalar($row[$column]) || $row[$column] === '')) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} の {$column} が不正です。"], + ]); + } + } } } } diff --git a/resources/js/types.ts b/resources/js/types.ts index 7f743d6..52c8c06 100644 --- a/resources/js/types.ts +++ b/resources/js/types.ts @@ -145,7 +145,7 @@ export interface SettlementSchedule { subtotal: number; tax_amount: number; total_amount: number; - items: LineItem[]; + items: LineItem[] | null; created_at?: string; updated_at?: string; } diff --git a/tests/Feature/SettlementBackupTest.php b/tests/Feature/SettlementBackupTest.php index b47f475..62ac699 100644 --- a/tests/Feature/SettlementBackupTest.php +++ b/tests/Feature/SettlementBackupTest.php @@ -177,6 +177,28 @@ public function test_restore_rejects_schedule_missing_required_columns(): void $this->postRestore($payload)->assertUnprocessable(); } + public function test_restore_rejects_null_settlement_default_column(): void + { + $payload = $this->makeBackup(settlements: [[ + 'id' => 1, 'status' => null, 'start_date' => '2026-01-01', + 'interval_unit' => 'month', 'interval_count' => 1, 'billing_timing' => 'advance', + 'tax_rate' => 10, 'unit_subtotal' => 0, 'unit_tax_amount' => 0, 'unit_total_amount' => 0, + ]]); + + $this->postRestore($payload)->assertUnprocessable(); + } + + public function test_restore_rejects_null_schedule_default_column(): void + { + $payload = $this->makeBackup(settlementSchedules: [[ + 'id' => 1, 'settlement_id' => 1, 'sequence_no' => 1, + 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'billing_date' => '2026-01-01', + 'subtotal' => null, 'tax_amount' => 0, 'total_amount' => 0, + ]]); + + $this->postRestore($payload)->assertUnprocessable(); + } + public function test_restore_of_version_two_backup_without_settlements_still_works(): void { // 旧 version 2 バックアップ(settlements キーなし)も従来どおり復元できる。 diff --git a/tests/Feature/SettlementScheduleGeneratorTest.php b/tests/Feature/SettlementScheduleGeneratorTest.php index e17d95a..51c9b74 100644 --- a/tests/Feature/SettlementScheduleGeneratorTest.php +++ b/tests/Feature/SettlementScheduleGeneratorTest.php @@ -73,6 +73,21 @@ public function test_schedule_sequence_is_unique_per_settlement_and_cascades_on_ $duplicateSettlement->settlementSchedules()->create($attributes); } + public function test_settlement_schedules_relation_is_ordered_by_sequence_number(): void + { + $settlement = $this->settlement(); + $attributes = [ + 'period_start' => '2024-01-01', + 'period_end' => '2024-01-31', + 'billing_date' => '2024-01-01', + ]; + + $settlement->settlementSchedules()->create($attributes + ['sequence_no' => 2]); + $settlement->settlementSchedules()->create($attributes + ['sequence_no' => 1]); + + $this->assertSame([1, 2], $settlement->settlementSchedules->pluck('sequence_no')->all()); + } + public function test_generates_monthly_periods_from_month_end_and_clamps_billing_day(): void { $settlement = $this->settlement([ From 41a5a6a74e7672c1c6f5ae1912c17168e8649ab3 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 20:45:18 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20settlement=20schedule=E3=81=AEID?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E3=82=92=E5=8E=B3=E5=AF=86?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/SettlementScheduleController.php | 2 +- tests/Feature/SettlementApiTest.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/SettlementScheduleController.php b/app/Http/Controllers/SettlementScheduleController.php index f0bbf66..30ff1a9 100644 --- a/app/Http/Controllers/SettlementScheduleController.php +++ b/app/Http/Controllers/SettlementScheduleController.php @@ -20,7 +20,7 @@ public function index(Request $request) ]); return SettlementSchedule::query() - ->when($filters['settlement_id'] ?? null, fn ($query, $id) => $query->where('settlement_id', $id)) + ->when(isset($filters['settlement_id']), fn ($query) => $query->where('settlement_id', $filters['settlement_id'])) ->when($filters['from'] ?? null, fn ($query, $from) => $query->whereDate('billing_date', '>=', $from)) ->when($filters['to'] ?? null, fn ($query, $to) => $query->whereDate('billing_date', '<=', $to)) ->orderBy('billing_date') diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php index 0d68118..1b7b9c0 100644 --- a/tests/Feature/SettlementApiTest.php +++ b/tests/Feature/SettlementApiTest.php @@ -210,6 +210,11 @@ public function test_schedules_index_returns_and_filters_schedules(): void ->assertOk() ->assertJsonCount(3); + // 0 も整数フィルタとして適用し、フィルタなしの全件取得にしない。 + $this->getJson('/api/settlement-schedules?settlement_id=0') + ->assertOk() + ->assertJsonCount(0); + // from/to は billing_date に対する範囲(年次の2回目 2027-01-10 は範囲外)。 $this->getJson('/api/settlement-schedules?from=2026-02-01&to=2026-12-31') ->assertOk() From ed272dfdb65607d441b2b62db1762f60f66729b6 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 20:58:41 +0900 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20PR=20#56=20=E3=81=AE=E6=9C=AA?= =?UTF-8?q?=E8=A7=A3=E6=B1=BA=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/SettlementController.php | 12 ++++++--- resources/js/types.ts | 4 +-- tests/Feature/SettlementApiTest.php | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php index 4b12761..526112f 100644 --- a/app/Http/Controllers/SettlementController.php +++ b/app/Http/Controllers/SettlementController.php @@ -45,10 +45,14 @@ public function show(Settlement $settlement) public function update(Request $request, Settlement $settlement) { - $settlement->update(SettlementPricing::apply($this->validated($request))); - $settlement->refresh(); - // 過去回のスナップショットは不変。基準日(今日)以降の回だけ現在条件で作り直す。 - SettlementScheduleGenerator::sync($settlement); + $data = SettlementPricing::apply($this->validated($request)); + + DB::transaction(function () use ($settlement, $data) { + $settlement->update($data); + $settlement->refresh(); + // 過去回のスナップショットは不変。基準日(今日)以降の回だけ現在条件で作り直す。 + SettlementScheduleGenerator::sync($settlement); + }); return $settlement->fresh()->load('settlementSchedules'); } diff --git a/resources/js/types.ts b/resources/js/types.ts index 52c8c06..0d8d238 100644 --- a/resources/js/types.ts +++ b/resources/js/types.ts @@ -121,8 +121,8 @@ export interface Settlement extends SenderSnapshot, CustomerSnapshot { payment_terms: string | null; tax_rate: number; notes: string | null; - sender_profile_id: ID | ''; - customer_id: ID | ''; + sender_profile_id: ID | '' | null; + customer_id: ID | '' | null; // 定期1回あたりの明細・金額(サーバ側で再計算される)。DB は nullable。 items: LineItem[] | null; unit_subtotal?: number; diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php index 1b7b9c0..9abb939 100644 --- a/tests/Feature/SettlementApiTest.php +++ b/tests/Feature/SettlementApiTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use App\Models\Settlement; +use App\Models\SettlementSchedule; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; @@ -157,6 +159,31 @@ public function test_update_regenerates_future_schedules_and_keeps_past_snapshot ->assertJsonPath('settlement_schedules.2.total_amount', 66000); } + public function test_update_rolls_back_settlement_when_schedule_sync_fails(): void + { + $id = $this->postJson('/api/settlements', $this->payload([ + 'start_date' => '2030-01-01', + ]))->json('id'); + + SettlementSchedule::creating(function (): void { + throw new \RuntimeException('schedule sync failed'); + }); + $this->withoutExceptionHandling(); + + try { + $this->putJson("/api/settlements/{$id}", $this->payload([ + 'subject' => '更新後の件名', + 'start_date' => '2030-01-01', + ])); + $this->fail('schedule sync failure was not thrown'); + } catch (\RuntimeException $e) { + $this->assertSame('schedule sync failed', $e->getMessage()); + } + + $this->assertSame('サーバ保守 月額', Settlement::findOrFail($id)->subject); + $this->assertDatabaseCount('settlement_schedules', 3); + } + public function test_show_and_destroy(): void { $id = $this->postJson('/api/settlements', $this->payload())->json('id'); From ba6db01dd4353ecae529aedc88c8d4888a512e04 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 21:07:11 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20local=E3=81=AE=E4=BB=95=E5=88=87?= =?UTF-8?q?=E6=9B=B8=E9=87=91=E9=A1=8D=E8=A8=88=E7=AE=97=E3=82=92=E6=95=B4?= =?UTF-8?q?=E6=95=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/data/adapters/local.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/resources/js/data/adapters/local.ts b/resources/js/data/adapters/local.ts index 5f9eac8..969f35d 100644 --- a/resources/js/data/adapters/local.ts +++ b/resources/js/data/adapters/local.ts @@ -131,12 +131,14 @@ function billingDateOf(periodStart: string, periodEnd: string, s: TSettlement): // SettlementPricing::apply 相当。items から 1回分の小計・税・税込額を再計算する。 function applySettlementPricing(data: Partial): Partial { - const items = (data.items ?? []).map((it) => ({ - ...it, - total: (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), - })); + const items = (data.items ?? []).map((it) => { + const quantity = Math.trunc(Number(it.quantity)) || 0; + const unitPrice = Math.trunc(Number(it.unit_price)) || 0; + return { ...it, total: quantity * unitPrice }; + }); const subtotal = items.reduce((sum, it) => sum + it.total, 0); - const tax = Math.floor((subtotal * Number(data.tax_rate ?? 10)) / 100); + const taxRate = Math.trunc(Number(data.tax_rate ?? 10)) || 0; + const tax = Math.floor((subtotal * taxRate) / 100); return { ...data, items, unit_subtotal: subtotal, unit_tax_amount: tax, unit_total_amount: subtotal + tax }; } From 644d54dbada008b9550a4792ee5d478f10310c29 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 21:15:40 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20backup=E5=BE=A9=E5=85=83=E6=99=82?= =?UTF-8?q?=E3=81=AE=E7=B5=82=E4=BA=86=E6=9D=A1=E4=BB=B6=E3=82=92=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/BackupRestorer.php | 11 +++++++++++ tests/Feature/SettlementBackupTest.php | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 30c764e..6f7f34f 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -140,6 +140,17 @@ private function validateRows(array $data): void ]); } } + + if ($table === 'settlements') { + $hasEndDate = is_scalar($row['end_date'] ?? null) && $row['end_date'] !== ''; + $hasOccurrencesCount = is_scalar($row['occurrences_count'] ?? null) + && $row['occurrences_count'] !== ''; + if (! $hasEndDate && ! $hasOccurrencesCount) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} に end_date または occurrences_count が必要です。"], + ]); + } + } } } } diff --git a/tests/Feature/SettlementBackupTest.php b/tests/Feature/SettlementBackupTest.php index 62ac699..21ceb62 100644 --- a/tests/Feature/SettlementBackupTest.php +++ b/tests/Feature/SettlementBackupTest.php @@ -188,6 +188,16 @@ public function test_restore_rejects_null_settlement_default_column(): void $this->postRestore($payload)->assertUnprocessable(); } + public function test_restore_rejects_settlement_without_end_condition(): void + { + $payload = $this->makeBackup(settlements: [[ + 'id' => 1, 'start_date' => '2026-01-01', 'interval_unit' => 'month', + 'end_date' => null, 'occurrences_count' => null, + ]]); + + $this->postRestore($payload)->assertUnprocessable(); + } + public function test_restore_rejects_null_schedule_default_column(): void { $payload = $this->makeBackup(settlementSchedules: [[ From a0eddc85f62df8b0161a401e8d40ba47f398d621 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 21:24:40 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=E9=83=A8=E5=88=86=E6=9B=B4=E6=96=B0=E3=81=A7=E6=97=A2?= =?UTF-8?q?=E5=AD=98=E9=87=91=E9=A1=8D=E3=82=92=E7=B6=AD=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/SettlementController.php | 5 +++- resources/js/data/adapters/local.ts | 5 ++-- tests/Feature/SettlementApiTest.php | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php index 526112f..945c0a5 100644 --- a/app/Http/Controllers/SettlementController.php +++ b/app/Http/Controllers/SettlementController.php @@ -45,7 +45,10 @@ public function show(Settlement $settlement) public function update(Request $request, Settlement $settlement) { - $data = SettlementPricing::apply($this->validated($request)); + $data = SettlementPricing::apply(array_merge( + $settlement->only(['items', 'tax_rate']), + $this->validated($request), + )); DB::transaction(function () use ($settlement, $data) { $settlement->update($data); diff --git a/resources/js/data/adapters/local.ts b/resources/js/data/adapters/local.ts index 969f35d..dffc7cf 100644 --- a/resources/js/data/adapters/local.ts +++ b/resources/js/data/adapters/local.ts @@ -220,8 +220,9 @@ export const Settlement: EntityAdapter = { async update(id: ID, data: Partial) { const existing = await settlementBase.get(id); if (!existing) return null; - assertEndCondition({ ...existing, ...data }); - const row = await settlementBase.update(id, applySettlementPricing(data)); + const merged = { ...existing, ...data }; + assertEndCondition(merged); + const row = await settlementBase.update(id, applySettlementPricing(merged)); if (!row) return null; replaceSchedules(row); return { ...row, settlement_schedules: schedulesOf(row.id) }; diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php index 9abb939..00ad939 100644 --- a/tests/Feature/SettlementApiTest.php +++ b/tests/Feature/SettlementApiTest.php @@ -159,6 +159,29 @@ public function test_update_regenerates_future_schedules_and_keeps_past_snapshot ->assertJsonPath('settlement_schedules.2.total_amount', 66000); } + public function test_update_preserves_existing_pricing_inputs_when_omitted(): void + { + $id = $this->postJson('/api/settlements', $this->payload([ + 'start_date' => '2030-01-01', + 'tax_rate' => 8, + ]))->json('id'); + $payload = $this->payload([ + 'subject' => '件名のみ更新', + 'start_date' => '2030-01-01', + ]); + unset($payload['items'], $payload['tax_rate']); + + $this->putJson("/api/settlements/{$id}", $payload) + ->assertOk() + ->assertJsonPath('subject', '件名のみ更新') + ->assertJsonPath('tax_rate', 8) + ->assertJsonPath('items.0.name', '保守費用') + ->assertJsonPath('unit_subtotal', 50000) + ->assertJsonPath('unit_tax_amount', 4000) + ->assertJsonPath('unit_total_amount', 54000) + ->assertJsonPath('settlement_schedules.0.total_amount', 54000); + } + public function test_update_rolls_back_settlement_when_schedule_sync_fails(): void { $id = $this->postJson('/api/settlements', $this->payload([ From f1f45031487ca35e5f294e27dcbae388ec498f3e Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Wed, 8 Jul 2026 21:32:56 +0900 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20backup=E5=BE=A9=E5=85=83=E6=99=82?= =?UTF-8?q?=E3=81=AE=E4=BB=95=E5=88=87=E6=9B=B8=E6=9D=A1=E4=BB=B6=E3=82=92?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/BackupRestorer.php | 20 +++++++++++++++----- tests/Feature/SettlementBackupTest.php | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 6f7f34f..e945a36 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -5,6 +5,7 @@ use Illuminate\Database\QueryException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class BackupRestorer @@ -142,12 +143,21 @@ private function validateRows(array $data): void } if ($table === 'settlements') { - $hasEndDate = is_scalar($row['end_date'] ?? null) && $row['end_date'] !== ''; - $hasOccurrencesCount = is_scalar($row['occurrences_count'] ?? null) - && $row['occurrences_count'] !== ''; - if (! $hasEndDate && ! $hasOccurrencesCount) { + $validator = Validator::make($row, [ + 'status' => ['sometimes', 'in:draft,active,ended'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'required_without:occurrences_count', 'date', 'after_or_equal:start_date'], + 'occurrences_count' => ['nullable', 'required_without:end_date', 'integer', 'min:1'], + 'interval_unit' => ['required', 'in:month,year'], + 'interval_count' => ['sometimes', 'integer', 'min:1'], + 'billing_day' => ['nullable', 'integer', 'between:1,31'], + 'billing_timing' => ['sometimes', 'in:advance,arrears'], + 'tax_rate' => ['sometimes', 'integer', 'between:0,100'], + 'items' => ['nullable', 'array'], + ]); + if ($validator->fails()) { throw ValidationException::withMessages([ - 'file' => ["{$table}.{$index} に end_date または occurrences_count が必要です。"], + 'file' => ["{$table}.{$index} の値が不正です: {$validator->errors()->first()}"], ]); } } diff --git a/tests/Feature/SettlementBackupTest.php b/tests/Feature/SettlementBackupTest.php index 21ceb62..2810643 100644 --- a/tests/Feature/SettlementBackupTest.php +++ b/tests/Feature/SettlementBackupTest.php @@ -198,6 +198,26 @@ public function test_restore_rejects_settlement_without_end_condition(): void $this->postRestore($payload)->assertUnprocessable(); } + public function test_restore_rejects_invalid_settlement_schedule_conditions(): void + { + $invalidValues = [ + ['interval_unit' => 'foo'], + ['interval_count' => 0], + ['billing_day' => []], + ['occurrences_count' => 0], + ]; + + foreach ($invalidValues as $invalidValue) { + $settlement = array_merge([ + 'id' => 1, 'start_date' => '2026-01-01', 'occurrences_count' => 1, + 'interval_unit' => 'month', + ], $invalidValue); + + $this->postRestore($this->makeBackup(settlements: [$settlement])) + ->assertUnprocessable(); + } + } + public function test_restore_rejects_null_schedule_default_column(): void { $payload = $this->makeBackup(settlementSchedules: [[ From 964fa9ff37877cc259b5be516a67877f4d123015 Mon Sep 17 00:00:00 2001 From: strnh Date: Thu, 9 Jul 2026 08:18:43 +0900 Subject: [PATCH 12/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/Services/BackupRestorer.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index e945a36..12257d7 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -161,6 +161,26 @@ private function validateRows(array $data): void ]); } } + + if ($table === 'settlement_schedules') { + $validator = Validator::make($row, [ + 'settlement_id' => ['required', 'integer', 'min:1'], + 'sequence_no' => ['required', 'integer', 'min:1'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'billing_date' => ['required', 'date'], + 'due_date' => ['nullable', 'date'], + 'subtotal' => ['sometimes', 'integer'], + 'tax_amount' => ['sometimes', 'integer'], + 'total_amount' => ['sometimes', 'integer'], + 'items' => ['nullable', 'array'], + ]); + if ($validator->fails()) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} の値が不正です: {$validator->errors()->first()}"], + ]); + } + } } } } From 4b2078541478d6966428810fde292aae5faa631a Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Thu, 9 Jul 2026 08:32:30 +0900 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=E9=96=A2=E9=80=A3ID=E6=A4=9C=E8=A8=BC=E3=82=92?= =?UTF-8?q?=E5=8E=B3=E5=AF=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/SettlementController.php | 4 ++-- tests/Feature/SettlementApiTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php index 945c0a5..fe6836b 100644 --- a/app/Http/Controllers/SettlementController.php +++ b/app/Http/Controllers/SettlementController.php @@ -95,7 +95,7 @@ private function validated(Request $request): array 'tax_rate' => ['sometimes', 'integer', 'min:0', 'max:100'], 'notes' => ['nullable', 'string'], - 'sender_profile_id' => ['nullable'], + 'sender_profile_id' => ['nullable', 'integer'], 'sender_company' => ['nullable', 'string', 'max:255'], 'sender_zip' => ['nullable', 'string', 'max:20'], 'sender_pref' => ['nullable', 'string', 'max:50'], @@ -107,7 +107,7 @@ private function validated(Request $request): array 'sender_fax' => ['nullable', 'string', 'max:50'], 'sender_logo_url' => ['nullable', 'string'], - 'customer_id' => ['nullable'], + 'customer_id' => ['nullable', 'integer'], 'customer_name' => ['nullable', 'string', 'max:255'], 'customer_department' => ['nullable', 'string', 'max:255'], 'customer_person' => ['nullable', 'string', 'max:255'], diff --git a/tests/Feature/SettlementApiTest.php b/tests/Feature/SettlementApiTest.php index 00ad939..cd9d492 100644 --- a/tests/Feature/SettlementApiTest.php +++ b/tests/Feature/SettlementApiTest.php @@ -102,6 +102,19 @@ public function test_explicit_null_on_not_null_columns_returns_422_not_500(): vo } } + /** + * ID カラムに配列などの非スカラ値が入ると DB 例外になり得るため、 + * validation で 422 に落とす。 + */ + public function test_relation_ids_reject_non_scalar_values(): void + { + foreach (['sender_profile_id', 'customer_id'] as $field) { + $this->postJson('/api/settlements', $this->payload([$field => ['invalid']])) + ->assertUnprocessable() + ->assertJsonValidationErrors([$field]); + } + } + /** * 上記フィールドを省略した場合は DB default が適用され、正常に作成できる * (sometimes ルールは値が存在する場合のみ検証し、欠落時は create() に渡さない)。 From 1d5add97a7b31fefe28237383634fdd106570faa Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Thu, 9 Jul 2026 08:46:45 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E4=BA=88=E5=AE=9A=E5=90=8C=E6=9C=9F=E3=81=AE=E5=86=8D=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=A2=83=E7=95=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Support/SettlementScheduleGenerator.php | 11 +++++----- .../SettlementScheduleGeneratorTest.php | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/Support/SettlementScheduleGenerator.php b/app/Support/SettlementScheduleGenerator.php index 163823d..9fe91e6 100644 --- a/app/Support/SettlementScheduleGenerator.php +++ b/app/Support/SettlementScheduleGenerator.php @@ -70,20 +70,21 @@ public static function sync(Settlement $settlement, ?CarbonInterface $asOf = nul DB::transaction(function () use ($settlement, $cutoff) { $generated = self::generate($settlement); - $pastQuery = $settlement->settlementSchedules() - ->whereDate('billing_date', '<', $cutoff->toDateString()); - $nextSequence = ((int) $pastQuery->max('sequence_no')) + 1; + $keptSequenceSet = $settlement->settlementSchedules() + ->whereDate('billing_date', '<', $cutoff->toDateString()) + ->pluck('sequence_no') + ->mapWithKeys(fn ($sequenceNo) => [(int) $sequenceNo => true]) + ->all(); $settlement->settlementSchedules() ->whereDate('billing_date', '>=', $cutoff->toDateString()) ->delete(); foreach ($generated as $schedule) { - if ($schedule['billing_date'] < $cutoff->toDateString()) { + if (isset($keptSequenceSet[(int) $schedule['sequence_no']])) { continue; } - $schedule['sequence_no'] = $nextSequence++; $settlement->settlementSchedules()->create($schedule); } }); diff --git a/tests/Feature/SettlementScheduleGeneratorTest.php b/tests/Feature/SettlementScheduleGeneratorTest.php index 51c9b74..944e3b7 100644 --- a/tests/Feature/SettlementScheduleGeneratorTest.php +++ b/tests/Feature/SettlementScheduleGeneratorTest.php @@ -229,6 +229,28 @@ public function test_sync_preserves_past_snapshot_and_replaces_future_schedules( $this->assertSame('新明細', SettlementSchedule::where('sequence_no', 3)->firstOrFail()->items[0]['name']); } + public function test_sync_recreates_future_sequence_even_when_new_billing_date_moves_before_cutoff(): void + { + $settlement = $this->settlement([ + 'start_date' => '2024-01-01', + 'occurrences_count' => 3, + 'billing_timing' => 'arrears', + ]); + SettlementScheduleGenerator::sync($settlement, CarbonImmutable::parse('2024-01-01')); + $past = SettlementSchedule::where('sequence_no', 1)->firstOrFail(); + + $settlement->update(['billing_timing' => 'advance']); + SettlementScheduleGenerator::sync($settlement->fresh(), CarbonImmutable::parse('2024-02-15')); + + $schedules = $settlement->settlementSchedules()->get(); + $this->assertSame(3, $schedules->count()); + $this->assertSame($past->id, $schedules->firstWhere('sequence_no', 1)->id); + $this->assertSame( + ['2024-01-31', '2024-02-01', '2024-03-01'], + $schedules->pluck('billing_date')->map->format('Y-m-d')->all(), + ); + } + public function test_requires_a_finite_valid_schedule(): void { $settlement = $this->settlement(['end_date' => null, 'occurrences_count' => null]); From af3bab95ae1a7fbd7dcb225a95a68ed0e3b28c25 Mon Sep 17 00:00:00 2001 From: "Sotaro.Hikosaka" Date: Thu, 9 Jul 2026 08:49:42 +0900 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20=E4=BB=95=E5=88=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=E5=85=A5=E5=8A=9BUI=EF=BC=88=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E3=83=BB=E4=BD=9C=E6=88=90/=E7=B7=A8=E9=9B=86=E3=83=BB?= =?UTF-8?q?=E8=A9=B3=E7=B4=B0=EF=BC=89=E3=82=92=E8=BF=BD=E5=8A=A0=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A〜C で実装済みの仕切書API/型/アダプターに対する入力画面が 無かったため追加。見積書一覧に「仕切書として作成」のスライドスイッチを 置きつつ、仕切書は独立エンティティとして専用の一覧/フォーム/詳細ページ (請求予定一覧を含む)を新設した。 Co-Authored-By: Claude Sonnet 5 --- resources/js/App.tsx | 7 + resources/js/components/Layout.tsx | 3 +- resources/js/components/ui.tsx | 33 +++ resources/js/lib/calc.ts | 10 +- resources/js/lib/format.ts | 34 ++- resources/js/pages/QuoteList.tsx | 12 +- resources/js/pages/SettlementForm.tsx | 307 +++++++++++++++++++++++ resources/js/pages/SettlementList.tsx | 138 ++++++++++ resources/js/pages/SettlementPreview.tsx | 197 +++++++++++++++ tests/e2e/settlements.spec.js | 87 +++++++ 10 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 resources/js/pages/SettlementForm.tsx create mode 100644 resources/js/pages/SettlementList.tsx create mode 100644 resources/js/pages/SettlementPreview.tsx create mode 100644 tests/e2e/settlements.spec.js diff --git a/resources/js/App.tsx b/resources/js/App.tsx index b25e6e9..b0fa0e5 100644 --- a/resources/js/App.tsx +++ b/resources/js/App.tsx @@ -11,6 +11,9 @@ import QuoteList from './pages/QuoteList'; import QuoteForm from './pages/QuoteForm'; import Import from './pages/Import'; import QuotePreview from './pages/QuotePreview'; +import SettlementList from './pages/SettlementList'; +import SettlementForm from './pages/SettlementForm'; +import SettlementPreview from './pages/SettlementPreview'; import Summary from './pages/Summary'; import SenderProfiles from './pages/SenderProfiles'; import Customers from './pages/Customers'; @@ -74,6 +77,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/resources/js/components/Layout.tsx b/resources/js/components/Layout.tsx index 981f988..f82a23c 100644 --- a/resources/js/components/Layout.tsx +++ b/resources/js/components/Layout.tsx @@ -1,7 +1,7 @@ import { NavLink, useNavigate } from 'react-router-dom'; import clsx from 'clsx'; import { - FileText, FilePlus, Upload, BarChart3, Building2, Users, ShieldCheck, LogOut, Leaf, Database, type LucideIcon, + FileText, FilePlus, Upload, BarChart3, Building2, Users, ShieldCheck, LogOut, Leaf, Database, Repeat, type LucideIcon, } from 'lucide-react'; import type { ReactNode } from 'react'; import { useAuth } from '../context/AuthContext'; @@ -24,6 +24,7 @@ const NAV: NavGroup[] = [ { to: '/', label: '見積書一覧', icon: FileText, end: true }, { to: '/quotes/new', label: '見積書作成', icon: FilePlus }, { to: '/import', label: '見積書取込', icon: Upload }, + { to: '/settlements', label: '仕切書一覧', icon: Repeat }, { to: '/summary', label: '月末集計', icon: BarChart3 }, ], }, diff --git a/resources/js/components/ui.tsx b/resources/js/components/ui.tsx index f27b8af..eef68fd 100644 --- a/resources/js/components/ui.tsx +++ b/resources/js/components/ui.tsx @@ -87,6 +87,39 @@ export function Select({ className, children, ...props }: SelectHTMLAttributes void; + label?: ReactNode; + disabled?: boolean; +} +export function Switch({ checked, onChange, label, disabled }: SwitchProps) { + return ( + + ); +} + // ---------- Badge ---------- export function Badge({ className, children }: { className?: string; children: ReactNode }) { return ( diff --git a/resources/js/lib/calc.ts b/resources/js/lib/calc.ts index e11138b..4395bd5 100644 --- a/resources/js/lib/calc.ts +++ b/resources/js/lib/calc.ts @@ -2,7 +2,7 @@ // subtotal = Σ item.total // tax = floor(subtotal * tax_rate / 100) // total = subtotal + tax -import type { LineItem, Quote } from '../types'; +import type { LineItem } from '../types'; export function lineTotal(item: Pick): number { return Number(item.quantity || 0) * Number(item.unit_price || 0); @@ -15,7 +15,13 @@ export interface QuoteTotals { total: number; } -export function quoteTotals(quote: Pick | null | undefined): QuoteTotals { +// Quote・Settlement 共通(どちらも items + tax_rate から 1 回分の金額を出す) +interface Totalable { + items?: LineItem[] | null; + tax_rate?: number; +} + +export function quoteTotals(quote: Totalable | null | undefined): QuoteTotals { const items = quote?.items || []; const subtotal = items.reduce((sum, it) => sum + Number(it.total || 0), 0); const taxRate = Number(quote?.tax_rate ?? 10); diff --git a/resources/js/lib/format.ts b/resources/js/lib/format.ts index 5c6bdec..33149c9 100644 --- a/resources/js/lib/format.ts +++ b/resources/js/lib/format.ts @@ -1,5 +1,5 @@ // 日本語フォーマットユーティリティ -import type { QuoteStatus } from '../types'; +import type { BillingTiming, IntervalUnit, QuoteStatus, SettlementStatus } from '../types'; export const PREFECTURES: string[] = [ '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県', @@ -34,6 +34,38 @@ export const STATUS_STYLE: Record = { rejected: 'bg-danger/10 text-danger', }; +// 仕切書ステータス(issue #58) +export interface SettlementStatusOption { + value: SettlementStatus; + label: string; +} + +export const SETTLEMENT_STATUS_OPTIONS: SettlementStatusOption[] = [ + { value: 'draft', label: '下書き' }, + { value: 'active', label: '契約中' }, + { value: 'ended', label: '終了' }, +]; + +export const SETTLEMENT_STATUS_LABEL: Record = Object.fromEntries( + SETTLEMENT_STATUS_OPTIONS.map((s) => [s.value, s.label]) +) as Record; + +export const SETTLEMENT_STATUS_STYLE: Record = { + draft: 'bg-neutral-100 text-neutral-600', + active: 'bg-success/10 text-success', + ended: 'bg-neutral-200 text-neutral-500', +}; + +export const INTERVAL_UNIT_LABEL: Record = { + month: 'ヶ月', + year: '年', +}; + +export const BILLING_TIMING_OPTIONS: { value: BillingTiming; label: string }[] = [ + { value: 'advance', label: '期首(前払い)' }, + { value: 'arrears', label: '期末(後払い)' }, +]; + export function yen(n: number | string | null | undefined): string { const v = Number(n || 0); return '¥' + v.toLocaleString('ja-JP'); diff --git a/resources/js/pages/QuoteList.tsx b/resources/js/pages/QuoteList.tsx index a001b07..a69aedb 100644 --- a/resources/js/pages/QuoteList.tsx +++ b/resources/js/pages/QuoteList.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { Quote, Customer } from '../data/store'; import { quoteTotals } from '../lib/calc'; import { yen, jpDate, STATUS_OPTIONS, STATUS_LABEL, STATUS_STYLE } from '../lib/format'; -import { Button, Card, Input, Select, Badge, EmptyState } from '../components/ui'; +import { Button, Card, Input, Select, Badge, EmptyState, Switch } from '../components/ui'; import PageHeader from '../components/PageHeader'; import type { Customer as TCustomer, Quote as TQuote, QuoteStatus } from '../types'; @@ -26,6 +26,7 @@ export default function QuoteList() { const [query, setQuery] = useState(''); const [month, setMonth] = useState(''); const [customerId, setCustomerId] = useState(''); + const [createAsSettlement, setCreateAsSettlement] = useState(false); useEffect(() => { Quote.list('-created_date').then(setRows); @@ -55,7 +56,14 @@ export default function QuoteList() {
navigate('/quotes/new')}>新規作成} + actions={ + <> + + + + } /> {/* タブ */} diff --git a/resources/js/pages/SettlementForm.tsx b/resources/js/pages/SettlementForm.tsx new file mode 100644 index 0000000..9f33850 --- /dev/null +++ b/resources/js/pages/SettlementForm.tsx @@ -0,0 +1,307 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { Plus, Trash2, ArrowLeft } from 'lucide-react'; +import { Settlement, Customer, SenderProfile } from '../data/store'; +import { quoteTotals, EMPTY_LINE_ITEM } from '../lib/calc'; +import { yen, todayISO, SETTLEMENT_STATUS_OPTIONS, BILLING_TIMING_OPTIONS } from '../lib/format'; +import { Button, Card, Input, Select, Textarea, Field, useToast } from '../components/ui'; +import PageHeader from '../components/PageHeader'; +import type { + Customer as TCustomer, LineItem, Settlement as TSettlement, SenderProfile as TSenderProfile, +} from '../types'; + +const SENDER_KEYS = ['sender_company', 'sender_zip', 'sender_pref', 'sender_city', 'sender_address1', 'sender_address2', 'sender_person', 'sender_tel', 'sender_fax', 'sender_logo_url'] as const; +const CUSTOMER_KEYS = ['customer_name', 'customer_department', 'customer_person', 'customer_zip', 'customer_pref', 'customer_city', 'customer_address1', 'customer_address2', 'customer_tel'] as const; + +type SettlementForm = Omit & { id?: TSettlement['id'] }; +type EndMode = 'date' | 'count'; + +function blankSettlement(): SettlementForm { + return { + settlement_number: '', subject: '', status: 'draft', + start_date: todayISO(), end_date: '', occurrences_count: null, + interval_unit: 'month', interval_count: 1, + billing_day: null, billing_timing: 'advance', + payment_terms: '', tax_rate: 10, notes: '', + ...(Object.fromEntries(SENDER_KEYS.map((k) => [k, ''])) as Record<(typeof SENDER_KEYS)[number], string>), + ...(Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, ''])) as Record<(typeof CUSTOMER_KEYS)[number], string>), + sender_profile_id: '', customer_id: '', + items: [{ ...EMPTY_LINE_ITEM }], + }; +} + +export default function SettlementForm() { + const { id } = useParams<{ id: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const toast = useToast(); + + const [settlement, setSettlement] = useState(blankSettlement); + const [endMode, setEndMode] = useState('date'); + const [senders, setSenders] = useState([]); + const [customers, setCustomers] = useState([]); + const [loaded, setLoaded] = useState(!isEdit); + + useEffect(() => { + (async () => { + const [sp, cs] = await Promise.all([SenderProfile.list(), Customer.list('customer_name')]); + setSenders(sp); + setCustomers(cs); + + if (isEdit && id) { + const s = await Settlement.get(id); + if (s) { + setSettlement({ ...blankSettlement(), ...s, items: s.items?.length ? s.items : [{ ...EMPTY_LINE_ITEM }] }); + setEndMode(s.occurrences_count != null ? 'count' : 'date'); + } + setLoaded(true); + } else { + const def = sp.find((s) => s.is_default) || sp[0]; + setSettlement((prev) => ({ + ...prev, + ...(def ? applySender(def) : {}), + sender_profile_id: def?.id ?? '', + })); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const set = (k: K, v: SettlementForm[K]) => + setSettlement((s) => ({ ...s, [k]: v })); + + function applySender(sp: TSenderProfile): Partial { + return Object.fromEntries(SENDER_KEYS.map((k) => [k, sp[k] ?? ''])) as Partial; + } + function applyCustomer(c: TCustomer): Partial { + return Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, c[k] ?? ''])) as Partial; + } + + const onSelectSender = (sid: string) => { + const sp = senders.find((s) => String(s.id) === sid); + setSettlement((s) => ({ ...s, sender_profile_id: sid, ...(sp ? applySender(sp) : {}) })); + }; + const onSelectCustomer = (cid: string) => { + const c = customers.find((x) => String(x.id) === cid); + setSettlement((s) => ({ ...s, customer_id: cid, ...(c ? applyCustomer(c) : {}) })); + }; + + const onSelectEndMode = (mode: EndMode) => { + setEndMode(mode); + if (mode === 'date') set('occurrences_count', null); + else set('end_date', null); + }; + + // 品目 + const updateItem = (idx: number, key: keyof LineItem, value: string) => { + setSettlement((s) => { + const items = (s.items ?? []).map((it, i) => { + if (i !== idx) return it; + const next = { ...it, [key]: value } as LineItem; + if (key === 'quantity' || key === 'unit_price') { + next.total = Number(next.quantity || 0) * Number(next.unit_price || 0); + } + return next; + }); + return { ...s, items }; + }); + }; + const addItem = () => setSettlement((s) => ({ ...s, items: [...(s.items ?? []), { ...EMPTY_LINE_ITEM }] })); + const removeItem = (idx: number) => setSettlement((s) => ({ ...s, items: (s.items ?? []).filter((_, i) => i !== idx) })); + + const totals = useMemo(() => quoteTotals(settlement), [settlement]); + + const save = async () => { + if (!settlement.customer_name) { toast('取引先を選択してください', 'error'); return; } + if (!settlement.start_date) { toast('開始日を入力してください', 'error'); return; } + if (endMode === 'date' && !settlement.end_date) { toast('終了日を入力してください', 'error'); return; } + if (endMode === 'count' && !settlement.occurrences_count) { toast('回数を入力してください', 'error'); return; } + + const payload = { ...settlement }; + if (isEdit && id) { + await Settlement.update(id, payload); + toast('仕切書を更新しました'); + navigate(`/settlements/${id}`); + } else { + const created = await Settlement.create(payload); + toast('仕切書を作成しました'); + navigate(`/settlements/${created.id}`); + } + }; + + if (!loaded) return
読み込み中...
; + + const num = (v: number | string | null | undefined): number | string => (v === 0 || v ? v : ''); + + return ( +
+ + + + + } + /> + +
+
+ {/* 発行者 / 取引先 */} + +
+
+ + + + {senders.length === 0 && ( +

+ ※ 発行者が未登録です。基本情報マスタ から登録してください。 +

+ )} +
+
+ + + + {customers.length === 0 && ( +

+ ※ 取引先が未登録です。取引先マスタ から登録してください。 +

+ )} +
+
+
+ + {/* 仕切書情報 */} + +

仕切書情報

+
+ + set('settlement_number', e.target.value)} /> + + + + +
+ set('subject', e.target.value)} placeholder="件名を入力" /> +
+ + + set('start_date', e.target.value)} /> + + +
+ + {endMode === 'date' ? ( + set('end_date', e.target.value)} /> + ) : ( + set('occurrences_count', e.target.value ? Number(e.target.value) : null)} placeholder="回数" /> + )} +
+
+ + +
+ set('interval_count', Number(e.target.value) || 1)} /> + +
+
+ + + + + + set('billing_day', e.target.value ? Number(e.target.value) : null)} placeholder="例: 31" /> + + set('payment_terms', e.target.value)} placeholder="例: 月末締め翌月末払い" /> +
+
+ + {/* 品目(1回あたり) */} + +
+

品目(1回あたり)

+ +
+
+
+
+
品名
+
仕様
+
数量
+
単位
+
標準価格
+
納入単価
+
金額
+
+ {(settlement.items ?? []).map((it, idx) => ( +
+
updateItem(idx, 'name', e.target.value)} />
+
updateItem(idx, 'spec', e.target.value)} />
+
updateItem(idx, 'quantity', e.target.value)} />
+
updateItem(idx, 'unit', e.target.value)} placeholder="個" />
+
updateItem(idx, 'standard_price', e.target.value)} />
+
updateItem(idx, 'unit_price', e.target.value)} />
+
+ {yen(it.total)} + {(settlement.items ?? []).length > 1 && ( + + )} +
+
+ ))} +
+
+
+ + {/* 備考 */} + + +