新增三方仓仓储报价录入 #175
115
app/Business/ThirdPartyWarehouseQuoteRepository.php
Normal file
115
app/Business/ThirdPartyWarehouseQuoteRepository.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 三方仓仓储报价的唯一读写入口。
|
||||
*
|
||||
* 结算只需按仓库和结算期查找生效日不晚于该期间的最新启用记录,
|
||||
* 因此每次价格变化都新增版本,不覆盖旧报价。
|
||||
*/
|
||||
class ThirdPartyWarehouseQuoteRepository
|
||||
{
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable((new ThirdPartyWarehouseQuote())->getTable());
|
||||
} catch (QueryException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{ready:bool,rows:Collection,summary:array<string,int>} */
|
||||
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<string,mixed> $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<string,mixed> */
|
||||
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<string,int>} */
|
||||
private function emptyPageData(): array
|
||||
{
|
||||
return [
|
||||
'ready' => false,
|
||||
'rows' => collect(),
|
||||
'summary' => ['total' => 0, 'active' => 0, 'warehouses' => 0],
|
||||
];
|
||||
}
|
||||
}
|
||||
112
app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php
Normal file
112
app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Business;
|
||||
|
||||
use App\Business\ThirdPartyWarehouseQuoteRepository;
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ThirdPartyWarehouseQuotes extends Component
|
||||
{
|
||||
public string $warehouseName = '南昌樵舍仓';
|
||||
|
||||
public string $effectiveAt = '';
|
||||
|
||||
public string $inboundRatePerTon = '20';
|
||||
|
||||
public string $outboundRatePerOrder = '0.85';
|
||||
|
||||
public string $piecesPerPallet = '23';
|
||||
|
||||
public string $palletAreaSqm = '3';
|
||||
|
||||
public string $storageRatePerSqmMonth = '10.5';
|
||||
|
||||
public string $remark = '';
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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<string,array<int,string>> */
|
||||
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<string,string> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
app/Models/Business/ThirdPartyWarehouseQuote.php
Normal file
46
app/Models/Business/ThirdPartyWarehouseQuote.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
|
||||
class ThirdPartyWarehouseQuote extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
|
||||
protected $table = 'business_third_party_warehouse_quotes';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'warehouse_name',
|
||||
'inbound_rate_per_ton',
|
||||
'outbound_rate_per_order',
|
||||
'pieces_per_pallet',
|
||||
'pallet_area_sqm',
|
||||
'storage_rate_per_sqm_month',
|
||||
'effective_at',
|
||||
'status',
|
||||
'remark',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => '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 ? '已启用' : '已停用';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('business_third_party_warehouse_quotes')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('business_third_party_warehouse_quotes', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,205 @@
|
||||
@php
|
||||
$warehouseNumber = static function (mixed $value): string {
|
||||
return rtrim(rtrim(number_format((float) $value, 4, '.', ','), '0'), '.');
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="warehouse-quote-page" data-warehouse-quote-page>
|
||||
<style>
|
||||
.warehouse-quote-page { display:grid; width:100%; min-width:0; max-width:100%; gap:1rem; }
|
||||
.warehouse-quote-intro,.warehouse-quote-form,.warehouse-quote-history { width:100%; min-width:0; max-width:100%; border:1px solid var(--quote-border); border-radius:.5rem; background:#fff; }
|
||||
.warehouse-quote-intro { display:grid; grid-template-columns:minmax(13rem,1.4fr) repeat(3,minmax(7rem,.65fr)); overflow:hidden; }
|
||||
.warehouse-quote-intro__main,.warehouse-quote-stat { min-width:0; padding:.75rem 1rem; }
|
||||
.warehouse-quote-intro__main { display:flex; align-items:center; gap:.65rem; }
|
||||
.warehouse-quote-intro__icon { display:grid; flex:0 0 34px; width:34px; height:34px; place-items:center; border-radius:.5rem; background:var(--quote-accent-soft); color:var(--quote-accent); }
|
||||
.warehouse-quote-intro__icon .material-icons-outlined { font-size:20px; }
|
||||
.warehouse-quote-intro__title { font-weight:600; }
|
||||
.warehouse-quote-intro__text,.warehouse-quote-stat__label { color:var(--quote-muted); font-size:.75rem; }
|
||||
.warehouse-quote-stat { border-left:1px solid var(--quote-border); }
|
||||
.warehouse-quote-stat__value { margin-top:.15rem; font-size:1rem; font-variant-numeric:tabular-nums; font-weight:700; }
|
||||
.warehouse-quote-form__head,.warehouse-quote-history__head { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.75rem 1rem; border-bottom:1px solid var(--quote-border); }
|
||||
.warehouse-quote-section-title { margin:0; font-size:.875rem; font-weight:600; }
|
||||
.warehouse-quote-section-note { color:var(--quote-muted); font-size:.75rem; }
|
||||
.warehouse-quote-errors { margin:0; padding:.65rem 1rem; border-bottom:1px solid rgba(184,0,0,.3); background:#FFF5F5; color:#B80000; }
|
||||
.warehouse-quote-success { margin:0; padding:.65rem 1rem; border-bottom:1px solid rgba(83,121,62,.3); background:#F5FAF2; color:#53793E; }
|
||||
.warehouse-quote-fields { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.75rem; padding:1rem; }
|
||||
.warehouse-quote-field { display:grid; min-width:0; gap:.35rem; }
|
||||
.warehouse-quote-field--wide { grid-column:span 2; }
|
||||
.warehouse-quote-field label { color:var(--quote-muted); font-size:.75rem; font-weight:600; }
|
||||
.warehouse-quote-field input,.warehouse-quote-field textarea { width:100%; min-width:0; border:1px solid var(--quote-border); border-radius:.5rem; background:#fff; color:var(--quote-ink); font:inherit; }
|
||||
.warehouse-quote-field input { height:34px; padding:0 .625rem; }
|
||||
.warehouse-quote-field textarea { min-height:34px; padding:.5rem .625rem; resize:vertical; }
|
||||
.warehouse-quote-field input:focus,.warehouse-quote-field textarea:focus { border-color:var(--quote-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
|
||||
.warehouse-quote-formulas { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1px; margin:0 1rem 1rem; overflow:hidden; border:1px solid var(--quote-border); border-radius:.5rem; background:var(--quote-border); }
|
||||
.warehouse-quote-formula { min-width:0; padding:.7rem .75rem; background:#FCFAF5; }
|
||||
.warehouse-quote-formula dt { color:var(--quote-muted); font-size:.7rem; font-weight:600; }
|
||||
.warehouse-quote-formula dd { margin:.25rem 0 0; overflow-wrap:anywhere; font-size:.75rem; line-height:1.55; }
|
||||
.warehouse-quote-form__footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.75rem 1rem; border-top:1px solid var(--quote-border); }
|
||||
.warehouse-quote-form__footer span { color:var(--quote-muted); font-size:.75rem; }
|
||||
.warehouse-quote-table-wrap { width:100%; min-width:0; max-width:100%; overflow:auto; }
|
||||
.warehouse-quote-table { width:100%; min-width:78rem; border-collapse:collapse; font-size:.78125rem; font-variant-numeric:tabular-nums; }
|
||||
.warehouse-quote-table th,.warehouse-quote-table td { padding:.65rem .75rem; border-bottom:1px solid var(--quote-border); text-align:left; vertical-align:middle; white-space:nowrap; }
|
||||
.warehouse-quote-table th { background:var(--quote-accent-soft); color:var(--quote-muted); font-size:.75rem; font-weight:600; }
|
||||
.warehouse-quote-table tbody tr:hover td { background:#FCFAF5; }
|
||||
.warehouse-quote-table .is-number { text-align:right; }
|
||||
.warehouse-quote-empty { height:8rem; color:var(--quote-muted); text-align:center !important; }
|
||||
.warehouse-quote-inline-form { display:inline; }
|
||||
@media (max-width:1050px) {
|
||||
.warehouse-quote-intro { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.warehouse-quote-intro__main { grid-column:1/-1; border-bottom:1px solid var(--quote-border); }
|
||||
.warehouse-quote-stat:first-of-type { border-left:0; }
|
||||
.warehouse-quote-fields { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.warehouse-quote-formulas { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:640px) {
|
||||
.warehouse-quote-intro,.warehouse-quote-fields { grid-template-columns:1fr; }
|
||||
.warehouse-quote-intro__main,.warehouse-quote-field--wide { grid-column:auto; }
|
||||
.warehouse-quote-stat { border-left:0; border-top:1px solid var(--quote-border); }
|
||||
.warehouse-quote-form__head,.warehouse-quote-history__head,.warehouse-quote-form__footer { align-items:flex-start; flex-direction:column; }
|
||||
.warehouse-quote-form__footer .quote-button { width:100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="warehouse-quote-intro" aria-label="仓储报价概览">
|
||||
<div class="warehouse-quote-intro__main">
|
||||
<span class="warehouse-quote-intro__icon" aria-hidden="true"><span class="material-icons-outlined">warehouse</span></span>
|
||||
<div>
|
||||
<div class="warehouse-quote-intro__title">第三方仓储报价</div>
|
||||
<div class="warehouse-quote-intro__text">价格按生效日期留存,月度核算自动匹配当期有效版本</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-quote-stat">
|
||||
<div class="warehouse-quote-stat__label">报价版本</div>
|
||||
<div class="warehouse-quote-stat__value">{{ number_format($warehouseQuoteSummary['total']) }}</div>
|
||||
</div>
|
||||
<div class="warehouse-quote-stat">
|
||||
<div class="warehouse-quote-stat__label">启用版本</div>
|
||||
<div class="warehouse-quote-stat__value">{{ number_format($warehouseQuoteSummary['active']) }}</div>
|
||||
</div>
|
||||
<div class="warehouse-quote-stat">
|
||||
<div class="warehouse-quote-stat__label">仓库数量</div>
|
||||
<div class="warehouse-quote-stat__value">{{ number_format($warehouseQuoteSummary['warehouses']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="warehouse-quote-form" wire:submit="save">
|
||||
<div class="warehouse-quote-form__head">
|
||||
<div>
|
||||
<h2 class="warehouse-quote-section-title">录入新报价</h2>
|
||||
<div class="warehouse-quote-section-note">价格变化时新增一条报价,历史月份不会随新价格改变</div>
|
||||
</div>
|
||||
<span class="quote-badge {{ $warehouseQuotesReady ? 'quote-badge--ready' : 'quote-badge--attention' }}">
|
||||
{{ $warehouseQuotesReady ? '可保存' : '等待数据表迁移' }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($errors->any())
|
||||
<div class="warehouse-quote-errors" role="alert">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
@if ($message)
|
||||
<div class="warehouse-quote-success" role="status">{{ $message }}</div>
|
||||
@endif
|
||||
<div class="warehouse-quote-fields">
|
||||
<div class="warehouse-quote-field warehouse-quote-field--wide">
|
||||
<label for="warehouse-quote-name">仓库名称</label>
|
||||
<input id="warehouse-quote-name" name="warehouse_name" wire:model="warehouseName" maxlength="191" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-effective">生效日期</label>
|
||||
<input id="warehouse-quote-effective" type="date" name="effective_at" wire:model="effectiveAt" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-inbound">入库管理费(元/吨)</label>
|
||||
<input id="warehouse-quote-inbound" type="number" name="inbound_rate_per_ton" wire:model="inboundRatePerTon" min="0" step="0.0001" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-outbound">出库费(元/单)</label>
|
||||
<input id="warehouse-quote-outbound" type="number" name="outbound_rate_per_order" wire:model="outboundRatePerOrder" min="0" step="0.0001" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-pieces">每托折算件数(件/托)</label>
|
||||
<input id="warehouse-quote-pieces" type="number" name="pieces_per_pallet" wire:model="piecesPerPallet" min="0.0001" step="0.0001" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-area">每托占地面积(㎡/托)</label>
|
||||
<input id="warehouse-quote-area" type="number" name="pallet_area_sqm" wire:model="palletAreaSqm" min="0.0001" step="0.0001" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field">
|
||||
<label for="warehouse-quote-storage">仓储面积单价(元/㎡·月)</label>
|
||||
<input id="warehouse-quote-storage" type="number" name="storage_rate_per_sqm_month" wire:model="storageRatePerSqmMonth" min="0" step="0.0001" required>
|
||||
</div>
|
||||
<div class="warehouse-quote-field warehouse-quote-field--wide">
|
||||
<label for="warehouse-quote-remark">备注或合同依据</label>
|
||||
<textarea id="warehouse-quote-remark" name="remark" wire:model="remark" maxlength="2000" placeholder="选填"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="warehouse-quote-formulas" aria-label="核算公式">
|
||||
<div class="warehouse-quote-formula">
|
||||
<dt>入库管理费</dt>
|
||||
<dd>Σ(普通商品资料重量 kg ÷ 1000 × 三方仓采购进仓数量)× 入库管理费单价</dd>
|
||||
</div>
|
||||
<div class="warehouse-quote-formula">
|
||||
<dt>出库费用</dt>
|
||||
<dd>Σ(三方仓销售出仓数量 + 其它出库数量)× 出库费单价</dd>
|
||||
</div>
|
||||
<div class="warehouse-quote-formula">
|
||||
<dt>每日仓储费</dt>
|
||||
<dd>每日库存结余 ÷ 每托折算件数 × 每托占地面积 × 仓储面积单价 ÷ 当月天数</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="warehouse-quote-form__footer">
|
||||
<span>系统数据为唯一核算来源,不读取仓库提供、核对或差异列。</span>
|
||||
<button class="quote-button quote-button--primary" type="submit" wire:loading.attr="disabled" wire:target="save" @disabled(! $warehouseQuotesReady)>
|
||||
<span class="material-icons-outlined" aria-hidden="true">save</span><span>保存报价</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="warehouse-quote-history" aria-labelledby="warehouse-quote-history-title">
|
||||
<div class="warehouse-quote-history__head">
|
||||
<div>
|
||||
<h2 class="warehouse-quote-section-title" id="warehouse-quote-history-title">历史报价</h2>
|
||||
<div class="warehouse-quote-section-note">同一仓库按生效日期倒序显示</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-quote-table-wrap">
|
||||
<table class="warehouse-quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>报价编号</th><th>仓库</th><th class="is-number">入库费(元/吨)</th><th class="is-number">出库费(元/单)</th>
|
||||
<th class="is-number">件/托</th><th class="is-number">㎡/托</th><th class="is-number">元/㎡·月</th>
|
||||
<th>生效日期</th><th>创建人</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($warehouseQuotes as $quote)
|
||||
<tr wire:key="warehouse-quote-{{ $quote['db_id'] }}">
|
||||
<td><strong>{{ $quote['id'] }}</strong></td>
|
||||
<td>{{ $quote['warehouse_name'] }}</td>
|
||||
<td class="is-number">{{ $warehouseNumber($quote['inbound_rate_per_ton']) }}</td>
|
||||
<td class="is-number">{{ $warehouseNumber($quote['outbound_rate_per_order']) }}</td>
|
||||
<td class="is-number">{{ $warehouseNumber($quote['pieces_per_pallet']) }}</td>
|
||||
<td class="is-number">{{ $warehouseNumber($quote['pallet_area_sqm']) }}</td>
|
||||
<td class="is-number">{{ $warehouseNumber($quote['storage_rate_per_sqm_month']) }}</td>
|
||||
<td>{{ $quote['effective_at'] }}</td>
|
||||
<td>{{ $quote['creator'] }}</td>
|
||||
<td><span class="quote-badge {{ $quote['status_code'] === 'active' ? 'quote-badge--ready' : '' }}">{{ $quote['status'] }}</span></td>
|
||||
<td>
|
||||
<button
|
||||
class="quote-action"
|
||||
type="button"
|
||||
wire:click="setStatus({{ $quote['db_id'] }}, '{{ $quote['status_code'] === 'active' ? 'inactive' : 'active' }}')"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="setStatus"
|
||||
>{{ $quote['status_code'] === 'active' ? '停用' : '启用' }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td class="warehouse-quote-empty" colspan="11">尚未录入仓储报价。</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@ -9,6 +9,7 @@
|
||||
->values()
|
||||
->all();
|
||||
$quoteTypes = collect($quotes)->pluck('quote_type')->unique()->sort()->values();
|
||||
$quoteView = request('quote_view') === 'warehouse' ? 'warehouse' : 'courier';
|
||||
@endphp
|
||||
|
||||
<section class="quote-management" aria-label="报价管理" data-quote-management>
|
||||
@ -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 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="quote-local-tabs" role="tablist" aria-label="报价管理分类">
|
||||
<button type="button" role="tab" @class(['quote-local-tab', 'is-active' => $quoteView === 'courier']) data-quote-view="courier" aria-controls="quote-courier-panel" aria-selected="{{ $quoteView === 'courier' ? 'true' : 'false' }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">local_shipping</span><span>快递报价</span>
|
||||
</button>
|
||||
<button type="button" role="tab" @class(['quote-local-tab', 'is-active' => $quoteView === 'warehouse']) data-quote-view="warehouse" aria-controls="quote-warehouse-panel" aria-selected="{{ $quoteView === 'warehouse' ? 'true' : 'false' }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">warehouse</span><span>仓储报价</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="quote-local-panel" id="quote-courier-panel" role="tabpanel" data-quote-panel="courier" @if ($quoteView !== 'courier') hidden @endif>
|
||||
|
||||
<div class="quote-summary" aria-label="报价数据概览">
|
||||
<div class="quote-source">
|
||||
<span class="quote-source__icon" aria-hidden="true">
|
||||
@ -1076,4 +1121,36 @@
|
||||
}());
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="quote-local-panel" id="quote-warehouse-panel" role="tabpanel" data-quote-panel="warehouse" @if ($quoteView !== 'warehouse') hidden @endif>
|
||||
<livewire:business.third-party-warehouse-quotes />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.currentScript.closest('[data-quote-management]');
|
||||
if (!root || root.dataset.localTabsReady === 'true') return;
|
||||
root.dataset.localTabsReady = 'true';
|
||||
|
||||
const tabs = [...root.querySelectorAll('[data-quote-view]')];
|
||||
const panels = [...root.querySelectorAll('[data-quote-panel]')];
|
||||
const activate = (view, updateUrl = true) => {
|
||||
tabs.forEach((tab) => {
|
||||
const active = tab.dataset.quoteView === view;
|
||||
tab.classList.toggle('is-active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach((panel) => { panel.hidden = panel.dataset.quotePanel !== view; });
|
||||
if (updateUrl) {
|
||||
const url = new URL(window.location.href);
|
||||
if (view === 'warehouse') url.searchParams.set('quote_view', 'warehouse');
|
||||
else url.searchParams.delete('quote_view');
|
||||
history.replaceState({}, '', url);
|
||||
}
|
||||
};
|
||||
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => activate(tab.dataset.quoteView)));
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
|
||||
140
tests/Feature/Business/ThirdPartyWarehouseQuotePageTest.php
Normal file
140
tests/Feature/Business/ThirdPartyWarehouseQuotePageTest.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Http\Livewire\Business\ThirdPartyWarehouseQuotes as WarehouseQuoteComponent;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\ThirdPartyWarehouseQuote;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class ThirdPartyWarehouseQuotePageTest extends FeatureTestCase
|
||||
{
|
||||
public function test_quote_management_contains_warehouse_quote_entry_and_formula_definitions(): void
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user