- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置 - 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出 - 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣 - 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额 - 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列 涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
179 lines
8.3 KiB
PHP
179 lines
8.3 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* 厂家记账扣款判定(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_UNCONFIGURED = '待配置';
|
||
public const STATUS_REVIEW = '待确认';
|
||
|
||
public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_UNCONFIGURED, self::STATUS_REVIEW];
|
||
|
||
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
|
||
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
|
||
|
||
/**
|
||
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(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}
|
||
*/
|
||
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 ($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_UNCONFIGURED, '工单未带供应商,无法匹配厂家记账配置');
|
||
}
|
||
|
||
$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]);
|
||
}
|
||
|
||
/** @return array{0:string,1:string} [判定, 说明] */
|
||
private static function outcome(string $level1, ?array $config, string $date): array
|
||
{
|
||
if ($config === null) {
|
||
return [self::STATUS_UNCONFIGURED, '供应商未配置厂家记账方式'];
|
||
}
|
||
$from = $config['effective_from'] ?? null;
|
||
$to = $config['effective_to'] ?? null;
|
||
if (($from !== null && $date < $from) || ($to !== null && $date > $to)) {
|
||
return [self::STATUS_UNCONFIGURED, '发起日期不在配置生效期内(' . 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_UNCONFIGURED, '无法识别的记账方式'],
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|