CaiWuXiTong/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php

218 lines
13 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
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);
}
}