222 lines
10 KiB
PHP
222 lines
10 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* 厂家记账扣款判定(2026-10-19 由财务确认):
|
||
*
|
||
* - 记账原因一级为“厂家记账”:按供应商的厂家记账配置判定,仅“正常记账”的供应商作为扣款项;
|
||
* - 记账原因一级为“仓库记账”:只有订单发货仓库为“某某代发仓”且二级原因含“仓库发错货”时,
|
||
* 才可作为厂家记账扣款项(供应商配置为“不记账”时仍不扣);其余仓库记账一律不作为扣款项;
|
||
* - 配置表中没有的供应商(含配置不在生效期内)视同正常记账,作为可扣款项(2026-10-19 财务更正);
|
||
* - 工单未带供应商、或多件商品分属不同供应商且判定不一致的,标记“待确认”,不计入扣款。
|
||
*/
|
||
class SupplierBookkeepingDeductionRule
|
||
{
|
||
public const MODE_NONE = 'none';
|
||
public const MODE_DROPSHIP_WRONG = 'dropship_wrong';
|
||
public const MODE_NORMAL = 'normal';
|
||
|
||
public const MODES = [
|
||
self::MODE_NORMAL => '正常记账',
|
||
self::MODE_DROPSHIP_WRONG => '仅代发仓发错货记账',
|
||
self::MODE_NONE => '不记账',
|
||
];
|
||
|
||
public const STATUS_DEDUCT = '扣款';
|
||
public const STATUS_SKIP = '不扣款';
|
||
public const STATUS_REVIEW = '待确认';
|
||
|
||
public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_REVIEW];
|
||
|
||
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
|
||
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
|
||
|
||
public const SMALL_AMOUNT_NOTE = '小于50且小于0.4,记账改0';
|
||
|
||
/**
|
||
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。
|
||
*
|
||
* @return array{mode:string,effective_from:?string,effective_to:?string}
|
||
*/
|
||
public static function parseRuleText(string $text): array
|
||
{
|
||
$normalized = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], trim($text));
|
||
if ($normalized === '') {
|
||
throw new InvalidArgumentException('厂家是否记账为空。');
|
||
}
|
||
$label = array_search($normalized, self::MODES, true);
|
||
if (is_string($label)) {
|
||
return ['mode' => $label, 'effective_from' => null, 'effective_to' => null];
|
||
}
|
||
if (str_contains($normalized, '代发仓') && (str_contains($normalized, '发货错') || str_contains($normalized, '发错货'))) {
|
||
$mode = self::MODE_DROPSHIP_WRONG;
|
||
} elseif (str_starts_with($normalized, '正常记账')) {
|
||
$mode = self::MODE_NORMAL;
|
||
} elseif (str_starts_with($normalized, '不记账')) {
|
||
$mode = self::MODE_NONE;
|
||
} else {
|
||
throw new InvalidArgumentException('无法识别的记账方式:' . $text);
|
||
}
|
||
|
||
[$from, $to] = [null, null];
|
||
$date = '(\d{4})[.\/年](\d{1,2})月?(?:[.\/](\d{1,2})日?)?';
|
||
if (preg_match('/(' . $date . '[-—~至]+' . $date . ')/u', $normalized, $m)) {
|
||
$from = self::date((int) $m[1], (int) $m[2], ($m[3] ?? '') === '' ? 1 : (int) $m[3]);
|
||
$to = self::date((int) $m[4], (int) $m[5], ($m[6] ?? '') === '' ? null : (int) $m[6]);
|
||
if ($from > $to) {
|
||
throw new InvalidArgumentException('生效期开始晚于结束:' . $text);
|
||
}
|
||
} elseif (str_contains($normalized, '(')) {
|
||
throw new InvalidArgumentException('无法识别的生效期:' . $text);
|
||
}
|
||
|
||
return ['mode' => $mode, 'effective_from' => $from, 'effective_to' => $to];
|
||
}
|
||
|
||
/** 配置的展示文字,例如“不记账(2025-03-01 至 2026-02-28)”。 */
|
||
public static function describe(array|object $config): string
|
||
{
|
||
$config = (array) $config;
|
||
$label = self::MODES[$config['mode']] ?? (string) $config['mode'];
|
||
$from = $config['effective_from'] ?? null;
|
||
$to = $config['effective_to'] ?? null;
|
||
if ($from === null && $to === null) {
|
||
return $label;
|
||
}
|
||
|
||
return $label . '(' . ($from ?? '不限') . ' 至 ' . ($to ?? '不限') . ')';
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $row 厂家记账明细行
|
||
* @param array<string,array<string,mixed>|object> $configs 供应商名称 => 生效中的配置
|
||
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string,amount?:string,remark?:string}
|
||
*/
|
||
public static function decide(array $row, array $configs): array
|
||
{
|
||
$level1 = (string) ($row['reason_level_1'] ?? '');
|
||
$level2 = (string) ($row['reason_level_2'] ?? '');
|
||
$date = substr((string) ($row['started_at'] ?? ''), 0, 10);
|
||
$suppliers = array_values(array_unique(array_filter(array_map('trim', explode("\n", (string) ($row['supplier'] ?? '')))), SORT_STRING));
|
||
|
||
$found = [];
|
||
foreach ($suppliers as $supplier) {
|
||
$found[$supplier] = isset($configs[$supplier]) ? (array) $configs[$supplier] : null;
|
||
}
|
||
$configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found));
|
||
|
||
if (self::isSmallCompensation($row)) {
|
||
$remark = (string) ($row['remark'] ?? '');
|
||
|
||
return self::result($configText, self::STATUS_SKIP, self::SMALL_AMOUNT_NOTE) + [
|
||
'amount' => '0.00',
|
||
'remark' => $remark === '' ? self::SMALL_AMOUNT_NOTE : $remark . "\n" . self::SMALL_AMOUNT_NOTE,
|
||
];
|
||
}
|
||
|
||
if ($level1 === '仓库记账') {
|
||
$warehouse = (string) ($row['shipping_warehouse'] ?? '');
|
||
if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) {
|
||
return self::result($configText, self::STATUS_SKIP, '仓库记账仅“代发仓 + 仓库发错货”作为厂家扣款,本单不扣');
|
||
}
|
||
} elseif ($level1 !== '厂家记账') {
|
||
return self::result($configText, self::STATUS_SKIP, '非厂家记账/仓库记账原因');
|
||
}
|
||
|
||
if ($suppliers === []) {
|
||
return self::result('', self::STATUS_REVIEW, '工单未带供应商,无法判定扣款,请人工确认');
|
||
}
|
||
|
||
$outcomes = [];
|
||
foreach ($found as $supplier => $config) {
|
||
$outcomes[$supplier] = self::outcome($level1, $config, $date);
|
||
}
|
||
$statuses = array_values(array_unique(array_column($outcomes, 0)));
|
||
if (count($statuses) > 1) {
|
||
$detail = implode(';', array_map(static fn (string $supplier, array $o): string => $supplier . ':' . $o[0], array_keys($outcomes), $outcomes));
|
||
|
||
return self::result($configText, self::STATUS_REVIEW, '多个供应商判定不一致,请人工确认(' . $detail . ')');
|
||
}
|
||
|
||
return self::result($configText, $statuses[0], reset($outcomes)[1]);
|
||
}
|
||
|
||
private static function isSmallCompensation(array $row): bool
|
||
{
|
||
if (! in_array(trim((string) ($row['bookkeeping_type'] ?? '')), ['补偿款项', '记账全款', '记账全额'], true)) {
|
||
return false;
|
||
}
|
||
|
||
$amount = trim((string) ($row['amount'] ?? ''));
|
||
if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $amount)
|
||
|| bccomp($amount, '0', 6) <= 0 || bccomp($amount, '50', 6) >= 0) {
|
||
return false;
|
||
}
|
||
|
||
// 两轮依次使用商品买家实付合计、工单订单实付;一轮缺失不影响另一轮独立判断。
|
||
foreach (['buyer_paid_amount', 'actual_paid_amount'] as $field) {
|
||
$paid = '0';
|
||
foreach (explode("\n", (string) ($row[$field] ?? '')) as $line) {
|
||
$line = trim($line);
|
||
if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $line)) {
|
||
continue 2;
|
||
}
|
||
$paid = bcadd($paid, $line, 6);
|
||
}
|
||
// 交叉相乘避免除零、比例取整和浮点边界误差。
|
||
if (bccomp($paid, '0', 6) > 0
|
||
&& bccomp(bcmul($amount, '5', 6), bcmul($paid, '2', 6), 6) < 0) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/** @return array{0:string,1:string} [判定, 说明] */
|
||
private static function outcome(string $level1, ?array $config, string $date): array
|
||
{
|
||
if ($config === null) {
|
||
return [self::STATUS_DEDUCT, $level1 === '厂家记账' ? '供应商不在配置表,按正常记账扣款' : '代发仓发错货,记厂家扣款(供应商不在配置表)'];
|
||
}
|
||
$from = $config['effective_from'] ?? null;
|
||
$to = $config['effective_to'] ?? null;
|
||
if (($from !== null && $date < $from) || ($to !== null && $date > $to)) {
|
||
return [self::STATUS_DEDUCT, '发起日期不在配置生效期内(' . self::describe($config) . '),按正常记账扣款'];
|
||
}
|
||
|
||
return match ($config['mode']) {
|
||
self::MODE_NONE => [self::STATUS_SKIP, '供应商配置为不记账'],
|
||
self::MODE_NORMAL => $level1 === '厂家记账'
|
||
? [self::STATUS_DEDUCT, '供应商正常记账']
|
||
: [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'],
|
||
self::MODE_DROPSHIP_WRONG => $level1 === '仓库记账'
|
||
? [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款']
|
||
: [self::STATUS_SKIP, '供应商仅代发仓发错货记账,其他原因不记账'],
|
||
default => [self::STATUS_REVIEW, '无法识别的记账方式,请人工确认'],
|
||
};
|
||
}
|
||
|
||
private static function result(string $config, string $status, string $basis): array
|
||
{
|
||
return ['supplier_config' => $config, 'deduction_status' => $status, 'deduction_basis' => $basis];
|
||
}
|
||
|
||
private static function date(int $year, int $month, ?int $day): string
|
||
{
|
||
if ($month < 1 || $month > 12) {
|
||
throw new InvalidArgumentException('生效期月份无效。');
|
||
}
|
||
$last = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
|
||
$day ??= $last;
|
||
if ($day < 1 || $day > $last) {
|
||
throw new InvalidArgumentException('生效期日期无效。');
|
||
}
|
||
|
||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||
}
|
||
}
|