CaiWuXiTong/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php
limeng 09b4ffcb8a 供应商应付-厂家记账:新增厂家记账配置并按配置判定厂家扣款
- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置
- 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出
- 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣
- 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额
- 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列

涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
2026-10-19 10:38:36 +08:00

179 lines
8.3 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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);
}
}