172 lines
7.4 KiB
PHP
172 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use App\Business\ThirdPartyWarehouseQuoteRepository;
|
|
use Illuminate\Database\DatabaseManager;
|
|
use Illuminate\Support\Collection;
|
|
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
|
|
|
/** 根据三方仓进销存日明细和生效报价核算仓储结算。 */
|
|
class ThirdPartyWarehouseStorageSettlementService
|
|
{
|
|
public function __construct(
|
|
private readonly ThirdPartyWarehouseStorageMirrorRepository $mirror,
|
|
private readonly ThirdPartyWarehouseQuoteRepository $quotes,
|
|
private readonly DatabaseManager $databases,
|
|
) {
|
|
}
|
|
|
|
/** @param array<string,mixed> $filters @return array<string,mixed> */
|
|
public function settle(array $filters): array
|
|
{
|
|
$filters = $this->normaliseFilters($filters);
|
|
if (! $this->mirror->ready()) {
|
|
return $this->emptyResult('进销存镜像尚未完成首次同步。', false);
|
|
}
|
|
|
|
$rawRows = iterator_to_array(
|
|
$this->mirror->exportRows($filters, 5000, false),
|
|
false,
|
|
);
|
|
if ($rawRows === []) {
|
|
return $this->emptyResult('当前期间及筛选条件下没有进销存明细。', true);
|
|
}
|
|
|
|
$weights = $this->weights($rawRows);
|
|
$daysInMonth = $this->daysInMonth((string) $filters['period'], (string) $filters['dimension']);
|
|
$quoteCache = [];
|
|
$rows = [];
|
|
$missingWeight = [];
|
|
$missingQuote = [];
|
|
$inbound = $outbound = $storage = 0.0;
|
|
|
|
foreach ($rawRows as $row) {
|
|
$date = (string) ($row['date'] ?? '');
|
|
// Keep a null result in the cache as well. When no quote exists,
|
|
// `??=` would query the same date again for every SKU/day row and
|
|
// turn an otherwise bounded page render into an N+1 timeout.
|
|
if (array_key_exists($date, $quoteCache)) {
|
|
$quote = $quoteCache[$date];
|
|
} else {
|
|
$quote = $this->quoteForDate($date);
|
|
$quoteCache[$date] = $quote;
|
|
}
|
|
if ($quote === null) {
|
|
$missingQuote[$date] = true;
|
|
}
|
|
|
|
$sku = (string) ($row['sku_code'] ?? '');
|
|
$weight = $weights[$sku] ?? null;
|
|
$purchaseIn = max(0.0, (float) ($row['purchase_in'] ?? 0));
|
|
if ($weight === null && $purchaseIn > 0.0) {
|
|
$missingWeight[$sku] = true;
|
|
}
|
|
|
|
$salesOut = abs(min(0.0, (float) ($row['sales_out'] ?? 0)));
|
|
$otherOut = abs(min(0.0, (float) ($row['other_out'] ?? 0)));
|
|
$closing = max(0.0, (float) ($row['closing_qty'] ?? 0));
|
|
// A zero inbound quantity does not require a product weight. Only a
|
|
// positive inbound quantity is blocked by a missing weight.
|
|
$inFee = $purchaseIn <= 0.0
|
|
? 0.0
|
|
: ($quote !== null && $weight !== null
|
|
? $purchaseIn * $weight / 1000 * (float) $quote['inbound_rate_per_ton']
|
|
: null);
|
|
$outFee = $quote !== null
|
|
? ($salesOut + $otherOut) * (float) $quote['outbound_rate_per_order']
|
|
: null;
|
|
$storageFee = $quote !== null
|
|
? $closing / max((float) $quote['pieces_per_pallet'], 0.0001)
|
|
* (float) $quote['pallet_area_sqm']
|
|
* (float) $quote['storage_rate_per_sqm_month'] / $daysInMonth
|
|
: null;
|
|
$total = $inFee === null || $outFee === null || $storageFee === null
|
|
? null
|
|
: $inFee + $outFee + $storageFee;
|
|
|
|
if ($inFee !== null) $inbound += $inFee;
|
|
if ($outFee !== null) $outbound += $outFee;
|
|
if ($storageFee !== null) $storage += $storageFee;
|
|
$rows[] = $row + [
|
|
'weight_kg' => $weight,
|
|
'inbound_fee' => $inFee,
|
|
'outbound_fee' => $outFee,
|
|
'storage_fee' => $storageFee,
|
|
'total_fee' => $total,
|
|
'quote_effective_at' => $quote['effective_at'] ?? null,
|
|
];
|
|
}
|
|
|
|
$complete = $missingWeight === [] && $missingQuote === [];
|
|
return [
|
|
'ready' => true,
|
|
'complete' => $complete,
|
|
'message' => $complete ? null : '报价或商品重量资料不完整,金额仅供核对,不能作为最终结算。',
|
|
'rows' => $rows,
|
|
'inbound_fee' => $complete ? $inbound : null,
|
|
'outbound_fee' => $complete ? $outbound : null,
|
|
'storage_fee' => $complete ? $storage : null,
|
|
'total_fee' => $complete ? $inbound + $outbound + $storage : null,
|
|
'missing_weight_skus' => array_keys($missingWeight),
|
|
'missing_quote_dates' => array_keys($missingQuote),
|
|
'days_in_month' => $daysInMonth,
|
|
'filters' => $filters,
|
|
];
|
|
}
|
|
|
|
/** @param list<array<string,mixed>> $rows @return array<string,float> */
|
|
private function weights(array $rows): array
|
|
{
|
|
$codes = collect($rows)->pluck('sku_code')->filter()->unique()->values();
|
|
if ($codes->isEmpty()) return [];
|
|
$connection = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
|
$result = [];
|
|
foreach ($codes->chunk(1000) as $chunk) {
|
|
try {
|
|
$connection->table('ops_jst_sku')->where('sku_type', 'normal')->whereIn('sku_id', $chunk->all())
|
|
->orderByDesc('autoid')->get(['sku_id', 'weight'])->each(function (object $item) use (&$result): void {
|
|
if (! array_key_exists((string) $item->sku_id, $result) && is_numeric($item->weight)) {
|
|
$result[(string) $item->sku_id] = (float) $item->weight;
|
|
}
|
|
});
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
private function quoteForDate(string $date): ?array
|
|
{
|
|
$quotes = $this->quotes->settlementQuotes('三方-南昌樵舍仓', $date);
|
|
return $quotes->last() ?: null;
|
|
}
|
|
|
|
private function daysInMonth(string $period, string $dimension): int
|
|
{
|
|
try {
|
|
return $dimension === 'day' ? (int) date('t', strtotime($period)) : (int) date('t', strtotime($period . '-01'));
|
|
} catch (\Throwable) {
|
|
return 31;
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $filters @return array<string,mixed> */
|
|
private function normaliseFilters(array $filters): array
|
|
{
|
|
$dimension = in_array(($filters['dimension'] ?? 'month'), ['month', 'day'], true) ? $filters['dimension'] : 'month';
|
|
$period = trim((string) ($filters['period'] ?? '2026-08'));
|
|
if ($dimension === 'month' && preg_match('/^\d{4}-\d{2}$/', $period) !== 1) $period = '2026-08';
|
|
if ($dimension === 'day' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $period) !== 1) $period = '2026-08-01';
|
|
return ['dimension' => $dimension, 'period' => $period] + $filters;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function emptyResult(string $message, bool $ready): array
|
|
{
|
|
return ['ready' => $ready, 'complete' => false, 'message' => $message, 'rows' => [], 'inbound_fee' => null, 'outbound_fee' => null, 'storage_fee' => null, 'total_fee' => null, 'missing_weight_skus' => [], 'missing_quote_dates' => [], 'days_in_month' => 31, 'filters' => []];
|
|
}
|
|
}
|