diff --git a/app/Business/ThirdPartyWarehouseQuoteRepository.php b/app/Business/ThirdPartyWarehouseQuoteRepository.php new file mode 100644 index 00000000..ca8d3e33 --- /dev/null +++ b/app/Business/ThirdPartyWarehouseQuoteRepository.php @@ -0,0 +1,115 @@ +getTable()); + } catch (QueryException) { + return false; + } + } + + /** @return array{ready:bool,rows:Collection,summary:array} */ + public function pageData(): array + { + if (! $this->ready()) { + return $this->emptyPageData(); + } + + try { + $quotes = ThirdPartyWarehouseQuote::query() + ->with('owner') + ->orderByDesc('effective_at') + ->orderByDesc('id') + ->get(); + } catch (QueryException) { + return $this->emptyPageData(); + } + + return [ + 'ready' => true, + 'rows' => $quotes->map(fn (ThirdPartyWarehouseQuote $quote): array => $this->present($quote)), + 'summary' => [ + 'total' => $quotes->count(), + 'active' => $quotes->where('status', ThirdPartyWarehouseQuote::STATUS_ACTIVE)->count(), + 'warehouses' => $quotes->pluck('warehouse_name')->filter()->unique()->count(), + ], + ]; + } + + /** @param array $data */ + public function create(array $data, int $userId): ThirdPartyWarehouseQuote + { + return ThirdPartyWarehouseQuote::query()->create([ + 'company_id' => company_id(), + 'warehouse_name' => trim((string) $data['warehouse_name']), + 'inbound_rate_per_ton' => $data['inbound_rate_per_ton'], + 'outbound_rate_per_order' => $data['outbound_rate_per_order'], + 'pieces_per_pallet' => $data['pieces_per_pallet'], + 'pallet_area_sqm' => $data['pallet_area_sqm'], + 'storage_rate_per_sqm_month' => $data['storage_rate_per_sqm_month'], + 'effective_at' => $data['effective_at'], + 'status' => ThirdPartyWarehouseQuote::STATUS_ACTIVE, + 'remark' => trim((string) ($data['remark'] ?? '')) ?: null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + public function setStatus(ThirdPartyWarehouseQuote $quote, string $status, int $userId): ThirdPartyWarehouseQuote + { + abort_unless(in_array($status, [ + ThirdPartyWarehouseQuote::STATUS_ACTIVE, + ThirdPartyWarehouseQuote::STATUS_INACTIVE, + ], true), 422, '报价状态不合法。'); + + $quote->forceFill(['status' => $status, 'updated_by' => $userId])->save(); + + return $quote->fresh(); + } + + /** @return array */ + private function present(ThirdPartyWarehouseQuote $quote): array + { + return [ + 'id' => 'WHQ-' . str_pad((string) $quote->id, 4, '0', STR_PAD_LEFT), + 'db_id' => $quote->id, + 'warehouse_name' => $quote->warehouse_name, + 'inbound_rate_per_ton' => (float) $quote->inbound_rate_per_ton, + 'outbound_rate_per_order' => (float) $quote->outbound_rate_per_order, + 'pieces_per_pallet' => (float) $quote->pieces_per_pallet, + 'pallet_area_sqm' => (float) $quote->pallet_area_sqm, + 'storage_rate_per_sqm_month' => (float) $quote->storage_rate_per_sqm_month, + 'effective_at' => optional($quote->effective_at)->format('Y-m-d'), + 'status_code' => $quote->status, + 'status' => $quote->statusLabel(), + 'remark' => $quote->remark, + 'creator' => optional($quote->owner)->name ?: '-', + ]; + } + + /** @return array{ready:false,rows:Collection,summary:array} */ + private function emptyPageData(): array + { + return [ + 'ready' => false, + 'rows' => collect(), + 'summary' => ['total' => 0, 'active' => 0, 'warehouses' => 0], + ]; + } +} diff --git a/app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php b/app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php new file mode 100644 index 00000000..efb2183b --- /dev/null +++ b/app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php @@ -0,0 +1,112 @@ +effectiveAt = now()->toDateString(); + } + + public function save(): void + { + $this->ensureAuthenticated(); + abort_unless($this->repository()->ready(), 503, '仓储报价数据表尚未完成迁移,请联系管理员。'); + + $this->warehouseName = trim($this->warehouseName); + $data = $this->validate(); + $this->repository()->create([ + 'warehouse_name' => $data['warehouseName'], + 'effective_at' => $data['effectiveAt'], + 'inbound_rate_per_ton' => $data['inboundRatePerTon'], + 'outbound_rate_per_order' => $data['outboundRatePerOrder'], + 'pieces_per_pallet' => $data['piecesPerPallet'], + 'pallet_area_sqm' => $data['palletAreaSqm'], + 'storage_rate_per_sqm_month' => $data['storageRatePerSqmMonth'], + 'remark' => $data['remark'], + ], (int) user_id()); + + $this->remark = ''; + $this->message = '仓储报价已保存,新报价不会覆盖历史版本。'; + } + + public function setStatus(int $quoteId, string $status): void + { + $this->ensureAuthenticated(); + + $quote = ThirdPartyWarehouseQuote::query()->findOrFail($quoteId); + abort_unless((int) $quote->company_id === (int) company_id(), 404, '仓储报价不存在。'); + + $this->repository()->setStatus($quote, $status, (int) user_id()); + $this->message = '仓储报价状态已更新。'; + } + + public function render(): View + { + $data = $this->repository()->pageData(); + + return view('business.pages.courier-payables.partials.third-party-warehouse-quotes', [ + 'warehouseQuotes' => $data['rows'], + 'warehouseQuoteSummary' => $data['summary'], + 'warehouseQuotesReady' => $data['ready'], + ]); + } + + /** @return array> */ + protected function rules(): array + { + return [ + 'warehouseName' => ['required', 'string', 'max:191'], + 'effectiveAt' => ['required', 'date_format:Y-m-d'], + 'inboundRatePerTon' => ['required', 'numeric', 'min:0', 'max:999999.9999'], + 'outboundRatePerOrder' => ['required', 'numeric', 'min:0', 'max:999999.9999'], + 'piecesPerPallet' => ['required', 'numeric', 'gt:0', 'max:999999.9999'], + 'palletAreaSqm' => ['required', 'numeric', 'gt:0', 'max:999999.9999'], + 'storageRatePerSqmMonth' => ['required', 'numeric', 'min:0', 'max:999999.9999'], + 'remark' => ['nullable', 'string', 'max:2000'], + ]; + } + + /** @return array */ + protected function messages(): array + { + return [ + 'piecesPerPallet.gt' => '每托折算件数必须大于 0。', + 'palletAreaSqm.gt' => '每托占地面积必须大于 0。', + ]; + } + + private function repository(): ThirdPartyWarehouseQuoteRepository + { + return app(ThirdPartyWarehouseQuoteRepository::class); + } + + private function ensureAuthenticated(): void + { + abort_unless(auth()->check(), 401); + } +} diff --git a/app/Models/Business/ThirdPartyWarehouseQuote.php b/app/Models/Business/ThirdPartyWarehouseQuote.php new file mode 100644 index 00000000..1dfa54d4 --- /dev/null +++ b/app/Models/Business/ThirdPartyWarehouseQuote.php @@ -0,0 +1,46 @@ + 'integer', + 'inbound_rate_per_ton' => 'decimal:4', + 'outbound_rate_per_order' => 'decimal:4', + 'pieces_per_pallet' => 'decimal:4', + 'pallet_area_sqm' => 'decimal:4', + 'storage_rate_per_sqm_month' => 'decimal:4', + 'effective_at' => 'date:Y-m-d', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public function statusLabel(): string + { + return $this->status === self::STATUS_ACTIVE ? '已启用' : '已停用'; + } +} diff --git a/database/migrations/2026_09_12_180000_create_third_party_warehouse_quotes_table.php b/database/migrations/2026_09_12_180000_create_third_party_warehouse_quotes_table.php new file mode 100644 index 00000000..f5e31d77 --- /dev/null +++ b/database/migrations/2026_09_12_180000_create_third_party_warehouse_quotes_table.php @@ -0,0 +1,43 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('warehouse_name', 191); + $table->decimal('inbound_rate_per_ton', 18, 4); + $table->decimal('outbound_rate_per_order', 18, 4); + $table->decimal('pieces_per_pallet', 18, 4); + $table->decimal('pallet_area_sqm', 18, 4); + $table->decimal('storage_rate_per_sqm_month', 18, 4); + $table->date('effective_at'); + $table->string('status', 16)->default('active'); + $table->text('remark')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index( + ['company_id', 'warehouse_name', 'status', 'effective_at'], + 'warehouse_quotes_settlement_lookup_index', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_third_party_warehouse_quotes'); + } +}; diff --git a/resources/views/business/pages/courier-payables/partials/third-party-warehouse-quotes.blade.php b/resources/views/business/pages/courier-payables/partials/third-party-warehouse-quotes.blade.php new file mode 100644 index 00000000..bf25a546 --- /dev/null +++ b/resources/views/business/pages/courier-payables/partials/third-party-warehouse-quotes.blade.php @@ -0,0 +1,205 @@ +@php + $warehouseNumber = static function (mixed $value): string { + return rtrim(rtrim(number_format((float) $value, 4, '.', ','), '0'), '.'); + }; +@endphp + +
+ + +
+
+ +
+
第三方仓储报价
+
价格按生效日期留存,月度核算自动匹配当期有效版本
+
+
+
+
报价版本
+
{{ number_format($warehouseQuoteSummary['total']) }}
+
+
+
启用版本
+
{{ number_format($warehouseQuoteSummary['active']) }}
+
+
+
仓库数量
+
{{ number_format($warehouseQuoteSummary['warehouses']) }}
+
+
+ +
+
+
+

录入新报价

+
价格变化时新增一条报价,历史月份不会随新价格改变
+
+ + {{ $warehouseQuotesReady ? '可保存' : '等待数据表迁移' }} + +
+ @if ($errors->any()) + + @endif + @if ($message) +
{{ $message }}
+ @endif +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
入库管理费
+
Σ(普通商品资料重量 kg ÷ 1000 × 三方仓采购进仓数量)× 入库管理费单价
+
+
+
出库费用
+
Σ(三方仓销售出仓数量 + 其它出库数量)× 出库费单价
+
+
+
每日仓储费
+
每日库存结余 ÷ 每托折算件数 × 每托占地面积 × 仓储面积单价 ÷ 当月天数
+
+
+ + +
+ +
+
+
+

历史报价

+
同一仓库按生效日期倒序显示
+
+
+
+ + + + + + + + + + @forelse ($warehouseQuotes as $quote) + + + + + + + + + + + + + + @empty + + @endforelse + +
报价编号仓库入库费(元/吨)出库费(元/单)件/托㎡/托元/㎡·月生效日期创建人状态操作
{{ $quote['id'] }}{{ $quote['warehouse_name'] }}{{ $warehouseNumber($quote['inbound_rate_per_ton']) }}{{ $warehouseNumber($quote['outbound_rate_per_order']) }}{{ $warehouseNumber($quote['pieces_per_pallet']) }}{{ $warehouseNumber($quote['pallet_area_sqm']) }}{{ $warehouseNumber($quote['storage_rate_per_sqm_month']) }}{{ $quote['effective_at'] }}{{ $quote['creator'] }}{{ $quote['status'] }} + +
尚未录入仓储报价。
+
+
+
diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 5b3977b2..db87adb4 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -9,6 +9,7 @@ ->values() ->all(); $quoteTypes = collect($quotes)->pluck('quote_type')->unique()->sort()->values(); + $quoteView = request('quote_view') === 'warehouse' ? 'warehouse' : 'courier'; @endphp
@@ -20,6 +21,9 @@ --quote-ink: var(--vip-content-ink, #171717); --quote-muted: var(--vip-content-muted, #595959); display: grid; + width: 100%; + min-width: 0; + max-width: 100%; gap: 1rem; margin-top: 1.5rem; color: var(--quote-ink); @@ -34,6 +38,36 @@ box-sizing: border-box; } + .quote-local-tabs { + display: flex; + min-height: 2.75rem; + align-items: center; + gap: 1.5rem; + overflow-x: auto; + border-bottom: 1px solid var(--quote-border); + } + + .quote-local-tab { + position: relative; + display: inline-flex; + min-height: 2.75rem; + flex: 0 0 auto; + align-items: center; + gap: .4rem; + border: 0; + background: transparent; + color: var(--quote-muted); + cursor: pointer; + font: inherit; + font-weight: 600; + } + + .quote-local-tab .material-icons-outlined { font-size: 17px; } + .quote-local-tab.is-active { color: var(--quote-accent); } + .quote-local-tab.is-active::after { position:absolute; right:0; bottom:0; left:0; height:2px; background:var(--quote-accent); content:''; } + .quote-local-tab:focus-visible { outline:2px solid var(--quote-accent); outline-offset:2px; } + .quote-local-panel { display:grid; min-width:0; gap:1rem; } + .quote-summary, .quote-workspace { min-width: 0; @@ -613,6 +647,17 @@ } + + +
diff --git a/tests/Feature/Business/ThirdPartyWarehouseQuotePageTest.php b/tests/Feature/Business/ThirdPartyWarehouseQuotePageTest.php new file mode 100644 index 00000000..3738a172 --- /dev/null +++ b/tests/Feature/Business/ThirdPartyWarehouseQuotePageTest.php @@ -0,0 +1,140 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->createQuote(); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'courier-payables', + 'page' => 'quote-management', + 'quote_view' => 'warehouse', + ])) + ->assertOk() + ->assertSeeText('快递报价') + ->assertSeeText('仓储报价') + ->assertSeeText('录入新报价') + ->assertSeeText('南昌樵舍仓') + ->assertSeeText('入库管理费(元/吨)') + ->assertSeeText('出库费(元/单)') + ->assertSeeText('每托折算件数(件/托)') + ->assertSeeText('每托占地面积(㎡/托)') + ->assertSeeText('仓储面积单价(元/㎡·月)') + ->assertSeeText('普通商品资料重量 kg ÷ 1000 × 三方仓采购进仓数量') + ->assertSeeText('三方仓销售出仓数量 + 其它出库数量') + ->assertSeeText('系统数据为唯一核算来源,不读取仓库提供、核对或差异列') + ->assertSeeText($this->user->name) + ->assertSee('data-quote-panel="warehouse"', false); + } + + public function test_warehouse_quote_store_creates_a_new_effective_version(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->loginAs(); + $existing = $this->createQuote(); + + Livewire::test(WarehouseQuoteComponent::class) + ->set('warehouseName', '南昌樵舍仓') + ->set('inboundRatePerTon', '22.5') + ->set('outboundRatePerOrder', '0.9') + ->set('piecesPerPallet', '24') + ->set('palletAreaSqm', '3.2') + ->set('storageRatePerSqmMonth', '11') + ->set('effectiveAt', '2026-09-01') + ->set('remark', '九月新报价') + ->call('save') + ->assertHasNoErrors() + ->assertSet('message', '仓储报价已保存,新报价不会覆盖历史版本。'); + + $this->assertDatabaseCount('business_third_party_warehouse_quotes', 2); + $this->assertDatabaseHas('business_third_party_warehouse_quotes', [ + 'company_id' => company_id(), + 'warehouse_name' => '南昌樵舍仓', + 'inbound_rate_per_ton' => 22.5, + 'outbound_rate_per_order' => 0.9, + 'pieces_per_pallet' => 24, + 'pallet_area_sqm' => 3.2, + 'storage_rate_per_sqm_month' => 11, + 'effective_at' => '2026-09-01', + 'status' => ThirdPartyWarehouseQuote::STATUS_ACTIVE, + 'created_by' => user_id(), + ]); + $this->assertSame('20.0000', (string) $existing->fresh()->inbound_rate_per_ton); + } + + public function test_warehouse_quote_validation_rejects_zero_divisors(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->loginAs(); + + Livewire::test(WarehouseQuoteComponent::class) + ->set('piecesPerPallet', '0') + ->set('palletAreaSqm', '0') + ->call('save') + ->assertHasErrors(['piecesPerPallet' => 'gt', 'palletAreaSqm' => 'gt']); + } + + public function test_warehouse_quote_validation_rejects_a_blank_warehouse_name(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $this->loginAs(); + + Livewire::test(WarehouseQuoteComponent::class) + ->set('warehouseName', ' ') + ->call('save') + ->assertHasErrors(['warehouseName' => 'required']); + + $this->assertDatabaseCount('business_third_party_warehouse_quotes', 0); + } + + public function test_quote_status_action_is_company_scoped(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $quote = ThirdPartyWarehouseQuote::withoutGlobalScopes()->create([ + 'company_id' => company_id() + 999, + 'warehouse_name' => '其他公司仓库', + 'inbound_rate_per_ton' => 20, + 'outbound_rate_per_order' => 0.85, + 'pieces_per_pallet' => 23, + 'pallet_area_sqm' => 3, + 'storage_rate_per_sqm_month' => 10.5, + 'effective_at' => '2026-08-01', + 'status' => ThirdPartyWarehouseQuote::STATUS_ACTIVE, + ]); + + $this->loginAs(); + $this->expectException(ModelNotFoundException::class); + Livewire::test(WarehouseQuoteComponent::class) + ->call('setStatus', $quote->id, ThirdPartyWarehouseQuote::STATUS_INACTIVE); + } + + private function createQuote(): ThirdPartyWarehouseQuote + { + return ThirdPartyWarehouseQuote::query()->create([ + 'company_id' => company_id(), + 'warehouse_name' => '南昌樵舍仓', + 'inbound_rate_per_ton' => 20, + 'outbound_rate_per_order' => 0.85, + 'pieces_per_pallet' => 23, + 'pallet_area_sqm' => 3, + 'storage_rate_per_sqm_month' => 10.5, + 'effective_at' => '2026-08-01', + 'status' => ThirdPartyWarehouseQuote::STATUS_ACTIVE, + 'remark' => '八月合同报价', + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + } +}