223 lines
9.2 KiB
PHP
223 lines
9.2 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|