CaiWuXiTong/app/Business/ThirdPartyWarehouseStorageFeeCalculator.php
limeng 9dbdef5e2e 每日商品仓储费明细 (#187)
增加逐日逐商品仓储费明细、日期切换、Excel 导出和公司数据隔离。
2026-09-15 11:44:20 +08:00

223 lines
9.2 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>>,daily_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 = [];
$dailySku = [];
$storagePerPiecePerDay = $days > 0 && $piecesPerPallet > 0
? $palletArea * $sqmRate / $piecesPerPallet / $days
: 0.0;
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;
$dailySku[] = [
'date' => $date,
'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'] ?? ''),
'opening' => round($opening, 2),
'net_movement' => round($closing - $opening, 2),
'closing' => round($closing, 2),
'storage_fee' => round($closing * $storagePerPiecePerDay, 6),
];
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,
'daily_sku' => $dailySku,
];
}
}