218 lines
13 KiB
PHP
218 lines
13 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Repository;
|
||
use Throwable;
|
||
|
||
/** 两轮免记账之后,按原单发货记录核算实际可记账金额。 */
|
||
class SupplierBookkeepingValuation
|
||
{
|
||
public function __construct(private readonly Repository $repository) {}
|
||
|
||
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||
public function apply(array $rows): array
|
||
{
|
||
$candidates = array_filter($rows, fn ($row) => $this->needsValuation($row));
|
||
if ($candidates === []) return $rows;
|
||
try {
|
||
$shipments = $this->repository->shipmentsFor(array_values($candidates));
|
||
$readyMonths = $this->repository->readyMonths();
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
foreach (array_keys($candidates) as $key) $rows[$key] = $this->review($rows[$key], '发货数据暂不可读取,请核对后记账');
|
||
return $rows;
|
||
}
|
||
|
||
$byTracking = [];
|
||
$byOrder = [];
|
||
foreach ($shipments as $shipment) {
|
||
$shipment['tracking_numbers'] ??= array_values(array_unique(array_merge(Repository::numbers($shipment['express_no']), Repository::numbers($shipment['old_express_no'] ?? ''))));
|
||
$shipment['order_numbers'] ??= Repository::numbers($shipment['trade_no']);
|
||
foreach ($shipment['tracking_numbers'] as $reference) $byTracking[$reference][] = $shipment;
|
||
foreach ($shipment['order_numbers'] as $reference) $byOrder[$reference][] = $shipment;
|
||
}
|
||
$skus = [];
|
||
foreach ($candidates as $row) {
|
||
array_push($skus, ...Repository::numbers(($row['resolved_product_code'] ?? '') !== '' ? $row['resolved_product_code'] : ($row['product_code'] ?? '')));
|
||
}
|
||
try {
|
||
$costs = $this->repository->costsFor(array_values(array_unique($skus)));
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
$costs = [];
|
||
}
|
||
foreach (array_keys($candidates) as $key) {
|
||
$rows[$key] = $this->value($rows[$key], $byTracking, $byOrder, $costs, $readyMonths);
|
||
}
|
||
return $rows;
|
||
}
|
||
|
||
private function needsValuation(array $row): bool
|
||
{
|
||
return ($row['deduction_status'] ?? '') === SupplierBookkeepingDeductionRule::STATUS_DEDUCT
|
||
&& in_array($row['bookkeeping_type'] ?? '', ['补偿款项', '记账全款', '记账全额', '运费损耗'], true)
|
||
&& self::decimal($row['amount'] ?? null)
|
||
&& (bccomp((string) $row['amount'], '0', 6) > 0 || $row['bookkeeping_type'] === '运费损耗');
|
||
}
|
||
|
||
private function value(array $row, array $byTracking, array $byOrder, array $costs, array $readyMonths): array
|
||
{
|
||
[$from, $to] = Repository::window($row);
|
||
$row['valuation_window'] = substr($from, 0, 7) . ' 至 ' . substr((string) $row['started_at'], 0, 7);
|
||
$month = \Illuminate\Support\Carbon::parse($from);
|
||
while ($month->format('Y-m-d H:i:s') < $to) {
|
||
if (!in_array($month->format('Y-m'), $readyMonths, true)) return $this->review($row, '六个月发货文件尚未补齐:' . $month->format('Y-m'));
|
||
$month->addMonth();
|
||
}
|
||
$tracking = Repository::numbers($row['tracking_no'] ?? '');
|
||
$orders = Repository::numbers($row['online_order_no'] ?? '');
|
||
$pool = [];
|
||
foreach ($tracking !== [] ? $tracking : $orders as $number) {
|
||
foreach (($tracking !== [] ? $byTracking : $byOrder)[$number] ?? [] as $shipment) {
|
||
if ($shipment['ship_time'] !== null && ($shipment['ship_time'] < $from || $shipment['ship_time'] >= $to)) continue;
|
||
if ($tracking !== [] && $orders !== [] && array_intersect($shipment['order_numbers'] ?? [(string) $shipment['trade_no']], $orders) === []) continue;
|
||
$pool[$shipment['id']] = $shipment;
|
||
}
|
||
}
|
||
if ($pool === []) return $this->review($row, '六个月内未匹配到原订单/快递单号的发货记录');
|
||
$unshippedPackages = [];
|
||
foreach ($pool as $shipment) {
|
||
$flag = trim((string) ($shipment['unshipped'] ?? ''));
|
||
// 工作平台导出的查找辅助列:#N/A 未命中名单,返回单号则确实未发出。
|
||
if ($flag === '' || $flag === '#N/A') continue;
|
||
if (str_starts_with($flag, '#')) return $this->review($row, '未发出辅助列存在未识别错误,请核对');
|
||
$unshippedPackages[(string) $shipment['source_id']] = true;
|
||
}
|
||
if ($unshippedPackages !== []) {
|
||
$pool = array_filter($pool, static fn ($shipment) => !isset($unshippedPackages[(string) $shipment['source_id']]));
|
||
if ($pool === []) return $this->review($row, '原单标记未发出,已排除核价,不计厂家扣款');
|
||
$row['deduction_basis'] .= ';已排除未发出包裹';
|
||
}
|
||
$matchedReferences = [];
|
||
foreach ($pool as $shipment) array_push($matchedReferences, ...($tracking !== [] ? ($shipment['tracking_numbers'] ?? [$shipment['express_no']]) : ($shipment['order_numbers'] ?? [$shipment['trade_no']])));
|
||
if (array_diff($tracking !== [] ? $tracking : $orders, $matchedReferences) !== []) {
|
||
if ($unshippedPackages !== []) return $this->review($row, '原单号包含未发出包裹,暂不能按部分金额记账');
|
||
return $this->review($row, '原单号包含未匹配发货记录,暂不能按部分金额记账');
|
||
}
|
||
|
||
$packages = [];
|
||
$quantity = '0';
|
||
$cost = '0';
|
||
$wanted = array_fill_keys(array_map([Repository::class, 'sku'], Repository::numbers(($row['resolved_product_code'] ?? '') !== '' ? $row['resolved_product_code'] : ($row['product_code'] ?? ''))), false);
|
||
$freightOnly = $row['bookkeeping_type'] === '运费损耗';
|
||
$lines = [];
|
||
if (!$freightOnly && $wanted === []) return $this->review($row, '工单没有商品编码,无法匹配成本');
|
||
foreach ($pool as $shipment) {
|
||
if ($shipment['ship_time'] === null) return $this->review($row, '匹配发货日期无效,请核对');
|
||
if (trim((string) $shipment['express_no']) === '' || count(Repository::numbers($shipment['express_no'])) !== 1) return $this->review($row, '包裹快递单号缺失或不唯一,请核对');
|
||
$package = (string) $shipment['source_id'];
|
||
$packages[$package] ??= ['amount' => '0', 'has_freight' => false];
|
||
if ($shipment['freight'] !== null && $shipment['freight'] !== '') {
|
||
if (!self::decimal($shipment['freight'])) return $this->review($row, '原单运费无效,请核对');
|
||
// 汇总表的包裹运费可能随每件商品重复关联,只取一次;冲突值不能相加。
|
||
if ($packages[$package]['has_freight']
|
||
&& bccomp($packages[$package]['amount'], (string) $shipment['freight'], 6) !== 0) {
|
||
return $this->review($row, '同一包裹运费不一致,请核对');
|
||
}
|
||
$packages[$package]['amount'] = (string) $shipment['freight'];
|
||
$packages[$package]['has_freight'] = true;
|
||
}
|
||
}
|
||
$estimatedPackages = array_filter($packages, static fn ($package) => !$package['has_freight'] || bccomp($package['amount'], '0', 6) === 0);
|
||
if ($freightOnly && $estimatedPackages !== []) {
|
||
$shippedSkus = [];
|
||
foreach ($pool as $shipment) array_push($shippedSkus, ...array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode'])));
|
||
if (array_diff(array_keys($wanted), $shippedSkus) !== []) return $this->review($row, '原单发货明细未匹配全部工单商品,无法估算运费');
|
||
}
|
||
$packageCosts = [];
|
||
$usedBundleQuantity = false;
|
||
foreach ($pool as $shipment) {
|
||
$package = (string) $shipment['source_id'];
|
||
if ($freightOnly && !isset($estimatedPackages[$package])) continue;
|
||
if ($wanted === []) return $this->review($row, '工单没有商品编码,无法匹配替代运费所需成本');
|
||
$components = array_values(array_unique(array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode']))));
|
||
$bundle = count($components) > 1;
|
||
foreach ($components as $sku) {
|
||
if (!array_key_exists($sku, $wanted)) continue;
|
||
// 组合装导出只有合计数量,财务确认按各商品条码1件核价。
|
||
$lineQuantity = $bundle ? '1' : (string) ($shipment['qty'] ?? '');
|
||
if (!self::decimal($lineQuantity) || bccomp($lineQuantity, '0', 6) <= 0) {
|
||
return $this->review($row, '匹配发货数量缺失或无效');
|
||
}
|
||
$usedBundleQuantity = $usedBundleQuantity || $bundle;
|
||
$lineKey = $package . '|' . $sku;
|
||
if (isset($lines[$lineKey])) {
|
||
if (bccomp($lines[$lineKey], $lineQuantity, 6) !== 0) return $this->review($row, '同一包裹商品数量不一致,请核对');
|
||
continue;
|
||
}
|
||
$lines[$lineKey] = $lineQuantity;
|
||
$quantity = bcadd($quantity, $lineQuantity, 6);
|
||
$price = $costs[$sku] ?? null;
|
||
if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku);
|
||
$lineCost = bcmul((string) $price, $lineQuantity, 6);
|
||
$cost = bcadd($cost, $lineCost, 6);
|
||
$packageCosts[$package] = bcadd($packageCosts[$package] ?? '0', $lineCost, 6);
|
||
$wanted[$sku] = true;
|
||
}
|
||
}
|
||
if (!$freightOnly && in_array(false, $wanted, true)) return $this->review($row, '原单发货明细未匹配全部工单商品');
|
||
foreach (array_keys($estimatedPackages) as $package) {
|
||
if (!isset($packageCosts[$package])) return $this->review($row, '原单发货商品未匹配替代运费所需成本');
|
||
$packages[$package]['amount'] = bcmul($packageCosts[$package], '0.2', 6);
|
||
$packages[$package]['has_freight'] = true;
|
||
}
|
||
$freight = '0';
|
||
foreach ($packages as $package) {
|
||
if (!$package['has_freight']) return $this->review($row, '原单运费尚未补齐,不能按0记账');
|
||
$freight = bcadd($freight, $package['amount'], 6);
|
||
}
|
||
$row['matched_freight'] = self::money($freight);
|
||
$row['matched_quantity'] = $freightOnly && $estimatedPackages === [] ? '' : rtrim(rtrim($quantity, '0'), '.');
|
||
$row['matched_tracking_no'] = implode("\n", array_values(array_unique(array_column($pool, 'express_no'))));
|
||
if ($freightOnly) {
|
||
$row['amount'] = self::money($freight);
|
||
if ($estimatedPackages !== []) {
|
||
$row['matched_cost'] = self::money($cost);
|
||
$basis = '运费损耗:原单运费缺失或为0,按对应商品成本合计×0.2替代运费';
|
||
if (count($estimatedPackages) < count($packages)) $basis .= ',另加其余包裹实际运费';
|
||
} else {
|
||
$basis = '运费损耗按原单实际运费记账';
|
||
}
|
||
} else {
|
||
$row['matched_cost'] = self::money($cost);
|
||
$ceiling = self::money(bcadd($cost, $freight, 6));
|
||
$freightLabel = $estimatedPackages === [] ? '实际运费' : '运费';
|
||
if (bccomp((string) $row['amount'], $ceiling, 2) > 0) {
|
||
$row['amount'] = $ceiling;
|
||
$basis = '原记账金额大于商品成本+' . $freightLabel . ',按成本+运费记账';
|
||
} else {
|
||
$basis = '原记账金额不大于商品成本+' . $freightLabel . ',保留记账金额';
|
||
}
|
||
if ($estimatedPackages !== []) $basis .= ';原单运费缺失或为0,按商品成本×0.2替代运费';
|
||
}
|
||
if ($usedBundleQuantity) $basis .= ';组合装按各条码1件计';
|
||
$row['deduction_basis'] .= ';' . $basis;
|
||
$row['remark'] = trim((string) ($row['remark'] ?? '')) === '' ? $basis : $row['remark'] . "\n" . $basis;
|
||
if (bccomp((string) $row['amount'], '0', 2) === 0) $row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_SKIP;
|
||
return $row;
|
||
}
|
||
|
||
private function review(array $row, string $reason): array
|
||
{
|
||
$row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_REVIEW;
|
||
$row['deduction_basis'] .= ';' . $reason;
|
||
return $row;
|
||
}
|
||
|
||
private static function decimal(mixed $value): bool
|
||
{
|
||
return $value !== null && preg_match('/^\d+(?:\.\d{1,6})?$/D', (string) $value) === 1;
|
||
}
|
||
|
||
private static function money(string $value): string
|
||
{
|
||
return bcadd($value, '0.005', 2);
|
||
}
|
||
}
|