CaiWuXiTong/app/Business/ThirdPartyWarehouseStorageFeeCalculator.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

204 lines
8.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Business;
use Carbon\Carbon;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
/**
* 三方仓月度仓储费核算引擎。
*
* 口径(与「第三方仓储报价」页公式一致;各移动列按数量/绝对值计入):
* - 入库管理费 = Σ( 普通商品资料重量kg ÷ 1000 ×
* (加工进仓+加工出仓+采购进仓+采购退货+其它退货+销售退货+盘点+调拨入) ) × 入库单价
* - 出库费用 = Σ( 销售出仓 + 其它出库 + 调拨出 ) × 出库单价
* - 每日仓储费 = Σ_日( 当日库存结余 ÷ 每托件数 × 每托面积 × 面积单价 ÷ 当月天数 )
* - 合计 = 入库 + 出库 + 仓储 + 记账 + 盘亏扣款 + 赔偿
* 系统数据为唯一核算来源,不读取仓库提供/核对/差异列。
*/
class ThirdPartyWarehouseStorageFeeCalculator
{
/** 入库计费口径涉及的移动列(取绝对数量求和)。 */
private const INBOUND_COLUMNS = [
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
'other_return', 'sales_return', 'stocktake', 'transfer_in',
];
/** 出库计费口径涉及的移动列(取绝对数量求和)。 */
private const OUTBOUND_COLUMNS = ['sales_out', 'other_out', 'transfer_out'];
public function __construct(
private readonly ThirdPartyWarehouseStorageMirrorRepository $mirror,
private readonly ThirdPartyWarehouseQuoteRepository $quotes,
private readonly ThirdPartyWarehouseProductWeightRepository $weights,
) {
}
/**
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>}
*/
public function calculate(int $warehouseId, string $warehouseName, string $period): array
{
abort_unless(
preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1,
422,
'结算月份格式应为 YYYY-MM。',
);
$quote = $this->quotes->effectiveFor($warehouseName, $period);
abort_if($quote === null, 422, '请先在「第三方仓储报价」录入该仓库在 ' . $period . ' 生效的报价。');
$monthStart = Carbon::createFromFormat('Y-m-d', $period . '-01')->startOfMonth();
$days = (int) $monthStart->daysInMonth;
$inboundRate = (float) $quote->inbound_rate_per_ton;
$outboundRate = (float) $quote->outbound_rate_per_order;
$piecesPerPallet = (float) $quote->pieces_per_pallet;
$palletArea = (float) $quote->pallet_area_sqm;
$sqmRate = (float) $quote->storage_rate_per_sqm_month;
/** @var array<string,array{closing:float,inbound_qty:float,outbound_qty:float}> $daily */
$daily = [];
/** @var array<string,array<string,mixed>> $skus */
$skus = [];
foreach ($this->mirror->exportRows(['dimension' => 'month', 'period' => $period], 5000) as $row) {
$date = trim((string) ($row['date'] ?? ''));
$code = trim((string) ($row['sku_code'] ?? ''));
if ($date === '' || $code === '') {
continue;
}
$opening = (float) ($row['opening_qty'] ?? 0);
$closing = (float) ($row['closing_qty'] ?? 0);
$inboundQty = 0.0;
foreach (self::INBOUND_COLUMNS as $column) {
$inboundQty += abs((float) ($row[$column] ?? 0));
}
$outboundQty = 0.0;
foreach (self::OUTBOUND_COLUMNS as $column) {
$outboundQty += abs((float) ($row[$column] ?? 0));
}
if (! isset($daily[$date])) {
$daily[$date] = ['closing' => 0.0, 'inbound_qty' => 0.0, 'outbound_qty' => 0.0];
}
$daily[$date]['closing'] += $closing;
$daily[$date]['inbound_qty'] += $inboundQty;
$daily[$date]['outbound_qty'] += $outboundQty;
if (! isset($skus[$code])) {
$skus[$code] = [
'owner' => (string) ($row['owner'] ?? ''),
'sku_code' => $code,
'product_name' => (string) ($row['product_name'] ?? ''),
'supplier' => (string) ($row['supplier'] ?? ''),
'category' => (string) ($row['category'] ?? ''),
'unit' => (string) ($row['unit'] ?? ''),
'first_date' => $date,
'last_date' => $date,
'opening' => $opening,
'closing' => $closing,
'inbound_qty' => 0.0,
'outbound_qty' => 0.0,
];
}
$sku = &$skus[$code];
if ($date <= $sku['first_date']) {
$sku['first_date'] = $date;
$sku['opening'] = $opening;
}
if ($date >= $sku['last_date']) {
$sku['last_date'] = $date;
$sku['closing'] = $closing;
}
$sku['inbound_qty'] += $inboundQty;
$sku['outbound_qty'] += $outboundQty;
unset($sku);
}
$weightMap = $this->weights->weightsBySkuCodes(array_keys($skus));
$inboundTons = 0.0;
$outboundQtyTotal = 0.0;
$perSku = [];
foreach ($skus as $code => $sku) {
$weight = (float) ($weightMap[$code] ?? 0.0);
$perTon = $weight / 1000;
$totalTon = $perTon * $sku['inbound_qty'];
$inboundTons += $totalTon;
$outboundQtyTotal += $sku['outbound_qty'];
$perSku[] = [
'owner' => $sku['owner'],
'sku_code' => $code,
'product_name' => $sku['product_name'],
'supplier' => $sku['supplier'],
'category' => $sku['category'],
'unit' => $sku['unit'],
'weight' => round($weight, 4),
'per_ton' => round($perTon, 6),
'inbound_qty' => round($sku['inbound_qty'], 2),
'total_ton' => round($totalTon, 4),
'outbound_qty' => round($sku['outbound_qty'], 2),
'opening' => round($sku['opening'], 2),
'closing' => round($sku['closing'], 2),
];
}
$inboundFee = round($inboundTons * $inboundRate, 2);
$outboundFee = round($outboundQtyTotal * $outboundRate, 2);
ksort($daily);
$dailyRows = [];
$storageFee = 0.0;
foreach ($daily as $date => $agg) {
$dayStorage = $days > 0 && $piecesPerPallet > 0
? $agg['closing'] / $piecesPerPallet * $palletArea * $sqmRate / $days
: 0.0;
$storageFee += $dayStorage;
$dailyRows[] = [
'date' => $date,
'inbound_qty' => round($agg['inbound_qty'], 2),
'outbound_qty' => round($agg['outbound_qty'], 2),
'closing' => round($agg['closing'], 2),
'storage_fee' => round($dayStorage, 4),
'outbound_fee' => round($agg['outbound_qty'] * $outboundRate, 2),
'total' => round($dayStorage + $agg['outbound_qty'] * $outboundRate, 2),
];
}
$storageFee = round($storageFee, 2);
$totalFee = round($inboundFee + $outboundFee + $storageFee, 2);
return [
'summary' => [
'period' => $period,
'warehouse_id' => $warehouseId,
'warehouse_name' => $warehouseName,
'quote_id' => (int) $quote->id,
'days_in_month' => $days,
'inbound_rate_per_ton' => $inboundRate,
'outbound_rate_per_order' => $outboundRate,
'pieces_per_pallet' => $piecesPerPallet,
'pallet_area_sqm' => $palletArea,
'storage_rate_per_sqm_month' => $sqmRate,
'inbound_tons' => round($inboundTons, 4),
'inbound_fee' => $inboundFee,
'outbound_qty' => round($outboundQtyTotal, 2),
'outbound_fee' => $outboundFee,
'storage_fee' => $storageFee,
'memo_fee' => 0.0,
'loss_fee' => 0.0,
'compensation_fee' => 0.0,
'total_fee' => $totalFee,
],
'daily' => $dailyRows,
'per_sku' => $perSku,
];
}
}