CaiWuXiTong/app/Http/Livewire/Business/ThirdPartyWarehouseQuotes.php
limeng 0b00f01566 feat: 三方仓仓储费一键核算+导出核算明细+每期存档
在「第三方仓储报价」页新增仓储费核算:选月份一键按当期生效报价核算
(入库管理费/出库费用/每日仓储费),结果存档并可导出汇总/每日/逐商品三表。

- 新增计算服务 ThirdPartyWarehouseStorageFeeCalculator(口径与报价页公式一致,
  重量取普通商品资料 ops_jst_sku.weight)
- 新增存档表 business_third_party_warehouse_storage_settlements + 模型
- 新增 maatwebsite/excel 三表导出 ThirdPartyWarehouseStorageSettlementExport
- 报价仓库新增 effectiveFor(按仓库+结算月取当期生效报价)
- Livewire 组件与报价页新增核算/导出/历史 UI
- 新增计算服务测试(附 2026-08 真实验收基准 1538.20/6099.60/8376.48/16014.28)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVQ6NsLsbtxm5Ng7Eew3s9
2026-09-15 09:21:05 +08:00

256 lines
9.7 KiB
PHP

<?php
namespace App\Http\Livewire\Business;
use App\Business\ThirdPartyWarehouseQuoteRepository;
use App\Business\ThirdPartyWarehouseStorageFeeCalculator;
use App\Exports\ThirdPartyWarehouseStorageSettlementExport;
use App\Models\Business\ThirdPartyWarehouseQuote;
use App\Models\Business\ThirdPartyWarehouseStorageSettlement;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ThirdPartyWarehouseQuotes extends Component
{
/** 固定核算「三方-南昌樵舍仓」,与镜像仓库一致。 */
public const WAREHOUSE_ID = 15045364;
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 string $settlementPeriod = '';
/** @var array<string,mixed>|null */
public ?array $settlementSummary = null;
public ?string $settlementMessage = null;
public ?string $settlementError = null;
public function mount(): void
{
$this->effectiveAt = now()->toDateString();
$this->settlementPeriod = now()->startOfMonth()->subMonth()->format('Y-m');
}
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 calculateStorageFee(): void
{
$this->ensureAuthenticated();
$this->settlementMessage = null;
$this->settlementError = null;
$this->validate([
'settlementPeriod' => ['required', 'regex:/^\d{4}-(0[1-9]|1[0-2])$/'],
], [
'settlementPeriod.regex' => '结算月份格式应为 YYYY-MM。',
]);
try {
$result = app(ThirdPartyWarehouseStorageFeeCalculator::class)
->calculate(self::WAREHOUSE_ID, $this->warehouseName, $this->settlementPeriod);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$this->settlementError = $e->getMessage();
return;
}
$summary = $result['summary'];
ThirdPartyWarehouseStorageSettlement::query()->updateOrCreate(
[
'company_id' => (int) company_id(),
'warehouse_id' => self::WAREHOUSE_ID,
'period' => $this->settlementPeriod,
],
[
'warehouse_name' => $this->warehouseName,
'quote_id' => $summary['quote_id'] ?? null,
'inbound_rate_per_ton' => $summary['inbound_rate_per_ton'],
'outbound_rate_per_order' => $summary['outbound_rate_per_order'],
'pieces_per_pallet' => $summary['pieces_per_pallet'],
'pallet_area_sqm' => $summary['pallet_area_sqm'],
'storage_rate_per_sqm_month' => $summary['storage_rate_per_sqm_month'],
'days_in_month' => $summary['days_in_month'],
'inbound_tons' => $summary['inbound_tons'],
'outbound_qty' => $summary['outbound_qty'],
'inbound_fee' => $summary['inbound_fee'],
'outbound_fee' => $summary['outbound_fee'],
'storage_fee' => $summary['storage_fee'],
'memo_fee' => $summary['memo_fee'],
'loss_fee' => $summary['loss_fee'],
'compensation_fee' => $summary['compensation_fee'],
'total_fee' => $summary['total_fee'],
'detail_json' => ['daily' => $result['daily'], 'per_sku' => $result['per_sku']],
'calculated_by' => (int) user_id(),
],
);
$this->settlementSummary = $summary;
$this->settlementMessage = $this->settlementPeriod . ' 仓储费核算完成,已存档。';
}
public function exportSettlement(string $period): ?BinaryFileResponse
{
$this->ensureAuthenticated();
$settlement = ThirdPartyWarehouseStorageSettlement::query()
->where('warehouse_id', self::WAREHOUSE_ID)
->where('period', $period)
->first();
if ($settlement === null) {
$this->settlementError = '未找到 ' . $period . ' 的核算存档,请先核算。';
return null;
}
$detail = $settlement->detail_json ?: ['daily' => [], 'per_sku' => []];
$result = [
'summary' => $this->summaryFromSettlement($settlement),
'daily' => $detail['daily'] ?? [],
'per_sku' => $detail['per_sku'] ?? [],
];
$fileName = $settlement->warehouse_name . '仓储费用_' . $period . '.xlsx';
return Excel::download(new ThirdPartyWarehouseStorageSettlementExport($result), $fileName);
}
public function render(): View
{
$data = $this->repository()->pageData();
$history = ThirdPartyWarehouseStorageSettlement::query()
->where('warehouse_id', self::WAREHOUSE_ID)
->orderByDesc('period')
->limit(24)
->get()
->map(fn (ThirdPartyWarehouseStorageSettlement $row): array => [
'period' => $row->period,
'total_fee' => (float) $row->total_fee,
'inbound_fee' => (float) $row->inbound_fee,
'outbound_fee' => (float) $row->outbound_fee,
'storage_fee' => (float) $row->storage_fee,
'calculated_at' => optional($row->updated_at)->format('Y-m-d H:i'),
])
->all();
return view('business.pages.courier-payables.partials.third-party-warehouse-quotes', [
'warehouseQuotes' => $data['rows'],
'warehouseQuoteSummary' => $data['summary'],
'warehouseQuotesReady' => $data['ready'],
'settlementHistory' => $history,
]);
}
/** @return array<string,mixed> */
private function summaryFromSettlement(ThirdPartyWarehouseStorageSettlement $s): array
{
return [
'period' => $s->period,
'warehouse_id' => (int) $s->warehouse_id,
'warehouse_name' => $s->warehouse_name,
'quote_id' => $s->quote_id,
'days_in_month' => (int) $s->days_in_month,
'inbound_rate_per_ton' => (float) $s->inbound_rate_per_ton,
'outbound_rate_per_order' => (float) $s->outbound_rate_per_order,
'pieces_per_pallet' => (float) $s->pieces_per_pallet,
'pallet_area_sqm' => (float) $s->pallet_area_sqm,
'storage_rate_per_sqm_month' => (float) $s->storage_rate_per_sqm_month,
'inbound_tons' => (float) $s->inbound_tons,
'inbound_fee' => (float) $s->inbound_fee,
'outbound_qty' => (float) $s->outbound_qty,
'outbound_fee' => (float) $s->outbound_fee,
'storage_fee' => (float) $s->storage_fee,
'memo_fee' => (float) $s->memo_fee,
'loss_fee' => (float) $s->loss_fee,
'compensation_fee' => (float) $s->compensation_fee,
'total_fee' => (float) $s->total_fee,
];
}
/** @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);
}
}