CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementAuditor.php
limeng 951e553fae feat(种菜报销): 新增种菜报销工作台,逐单校验并生成打款明细与钉钉流程明细 (#1140)
- 种菜数据汇总新增「种菜报销工作台」:按聚工单锁单口径取单(待处理 WAIT_CONFIRM、付款 D-7~D-1、ERP订单状态非取消/删除/空、售后单号为空;可切换处理中 CONFIRMING 和付款起始日),逐单校验重复报销、金额公式、佣金资格、收款人在职、以旧换新买家实付(取买家实付/看板订单实付中最低,查不到标签的待复查)、看板超额(同日按付款先后报到日限额)与看板未匹配
- 导出前须勾选「已在聚工单核对条数和金额一致」;页面给出应核对的条数/金额、各口径合计自检,聚工单镜像停更超过 20 分钟时提示先别报销
- 页面给出可报销合计(三大天猫/其他)、钉钉「种菜报销流程」明细行、按发起人的发群通知与取消清单
- 导出种菜对账、种菜报销明细(打款卡号明细表)、聚工单种菜任务工单导入模版,格式对齐手工文件
- 财务可上传种菜监控看板、报销佣金人员、花名册在职、店铺信息;名单存 storage 持久卷 JSON,花名册只存姓名
- 聚工单镜像读取补充 ERP订单标签、待报销工单和已报销订单查询
涉及:只读聚工单镜像;不涉及迁移、上游写入、钉钉和聚工单写操作。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-12 18:54:21 +08:00

467 lines
22 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;
/**
* 种菜报销工作台:对一批待报销的种菜工单逐单校验,并汇总出打款明细、钉钉流程明细和种菜任务工单。
*
* 规则(2026-11-12 报销人逐条确认,详见《种菜报销规则与操作步骤》):
* - 重复报销:线上订单号在当月或上月已报销过 → 取消;
* - 报销金额:最终报销金额 ≠ 任务付款金额 + 任务快递费 + 任务佣金 + 京喜差价 − 任务扣款费用 − 任务空包费 → 取消;
* - 佣金资格:任务佣金 ≠ 0 且发起人不在「报销佣金人员」名单 → 取消;
* - 收款人在职:收款人不在花名册在职名单(例外名单除外)→ 取消;
* - 买家实付:ERP订单标签含「以旧换新」且店铺以「天猫-」「淘宝-」开头,最终报销金额 > 买家实付 → 取消
* (买家实付取工单买家实付、看板订单实付金额、工单实付金额中最低的);
* - 超额:看板标「超额」的单按 ID负责人 分组;每日超额且付款日期都在同一天 → 按付款时间先后报到日限额为止,
* 其余取消;不在同一天或其他超额类型 → 全部取消;
* - 看板没匹配到 → 待复查(隔 20 分钟重新导入看板再看,仍匹配不到则取消);
* - 天猫 / 淘宝店查不到 ERP订单标签、但报销金额大于买家实付 → 待复查(到聚水潭确认是否以旧换新);
* - 店铺查不到利润中心 → 只提示,需手工补,不取消。
*
* 纯计算类:不读数据库,输入由工作台数据层准备,便于测试与对照。
*/
final class PlantingReimbursementAuditor
{
public const DECISION_PASS = 'pass';
public const DECISION_CANCEL = 'cancel';
public const DECISION_RECHECK = 'recheck';
public const DECISION_LABELS = [
self::DECISION_PASS => '可报销',
self::DECISION_CANCEL => '取消工单',
self::DECISION_RECHECK => '待复查',
];
/** 单独成组、钉钉流程里一店一行的三家天猫店(钉钉表单里的店铺简称)。 */
public const BIG_TMALL_SHOPS = [
'天猫-欧意朗旗舰店' => '欧意朗',
'天猫-亿家达旗舰店' => '亿家达',
'天猫-卓禾家具旗舰店' => '卓禾',
];
public const GROUP_BIG_TMALL = '三大天猫';
public const GROUP_OTHER = '其他';
/** 种菜任务工单里非三大天猫店铺统一填写的店铺名称。 */
public const SAMPLE_SHOP = '样品专用店铺';
public const ISSUE_DUPLICATE = 'duplicate';
public const ISSUE_AMOUNT = 'amount';
public const ISSUE_COMMISSION = 'commission';
public const ISSUE_ROSTER = 'roster';
public const ISSUE_OVER_PAID = 'over_paid';
public const ISSUE_OVER_LIMIT = 'over_limit';
public const ISSUE_MONITOR_MISSING = 'monitor_missing';
public const ISSUE_PROFIT_CENTER = 'profit_center';
public const ISSUE_LABEL_MISSING = 'label_missing';
public const ISSUE_LABELS = [
self::ISSUE_DUPLICATE => '重复报销',
self::ISSUE_AMOUNT => '金额不符',
self::ISSUE_COMMISSION => '无佣金资格',
self::ISSUE_ROSTER => '收款人不在职',
self::ISSUE_OVER_PAID => '超买家实付',
self::ISSUE_OVER_LIMIT => '超额',
self::ISSUE_MONITOR_MISSING => '看板未匹配',
self::ISSUE_PROFIT_CENTER => '利润中心待补',
self::ISSUE_LABEL_MISSING => '标签待确认',
];
/**
* @param list<array<string,mixed>> $rows 工单行(PlantingReimbursementRepository 的字段)
* @param array{
* reimbursed?: array<string,string>,
* commission_people?: array<string,bool>,
* roster?: array<string,bool>,
* roster_exceptions?: list<string>,
* profit_centers?: array<string,string>,
* monitor?: array<string,array<string,mixed>>|null,
* } $refs
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function audit(array $rows, array $refs, string $reimburseDate): array
{
$reimbursed = $refs['reimbursed'] ?? [];
$commission = $refs['commission_people'] ?? [];
$roster = $refs['roster'] ?? [];
$rosterExceptions = array_map([self::class, 'normName'], $refs['roster_exceptions'] ?? []);
$centers = $refs['profit_centers'] ?? [];
$monitor = $refs['monitor'] ?? null;
$results = [];
$seen = [];
foreach ($rows as $row) {
$orderNo = trim((string) ($row['online_order_no'] ?? ''));
$shop = trim((string) ($row['shop_name'] ?? ''));
$issues = [];
if ($orderNo !== '' && isset($reimbursed[$orderNo])) {
$issues[] = $this->issue(self::ISSUE_DUPLICATE, '重复报销:该单已在 ' . $reimbursed[$orderNo] . ' 报销过');
} elseif ($orderNo !== '' && isset($seen[$orderNo])) {
$issues[] = $this->issue(self::ISSUE_DUPLICATE, '重复报销:本批次工单 ' . $seen[$orderNo] . ' 已报同一订单');
}
if ($orderNo !== '') {
$seen[$orderNo] ??= (string) ($row['work_order_no'] ?? '');
}
$expected = self::cents($row['task_payment'] ?? null) + self::cents($row['task_freight'] ?? null)
+ self::cents($row['task_commission'] ?? null) + self::cents($row['jingxi_price_diff'] ?? null)
- self::cents($row['task_deduction'] ?? null) - self::cents($row['task_empty_package_fee'] ?? null);
$final = self::cents($row['reimbursement_amount'] ?? null);
if ($expected !== $final) {
$issues[] = $this->issue(self::ISSUE_AMOUNT, sprintf(
'最终报销金额 %s 与明细合计 %s 不符(任务付款+快递费+佣金+京喜差价−扣款−空包费)',
self::money($final),
self::money($expected),
));
}
$initiator = self::normName((string) ($row['initiator'] ?? ''));
if (self::cents($row['task_commission'] ?? null) !== 0 && ! isset($commission[$initiator])) {
$issues[] = $this->issue(self::ISSUE_COMMISSION, '发起人 ' . $initiator . ' 不在报销佣金人员名单,不能报佣金');
}
$payee = self::normName((string) ($row['payee_name'] ?? ''));
if (! isset($roster[$payee]) && ! $this->isRosterException($payee, $rosterExceptions)) {
$issues[] = $this->issue(self::ISSUE_ROSTER, '收款人 ' . ($payee === '' ? '(空)' : $payee) . ' 不在在职花名册');
}
$risk = null;
if ($monitor !== null) {
$risk = $monitor[$orderNo] ?? null;
if ($risk === null) {
$issues[] = $this->issue(self::ISSUE_MONITOR_MISSING, '种菜监控看板没匹配到该单,请 20 分钟后重新导入看板再看,仍匹配不到则取消');
}
}
$label = trim((string) ($row['erp_order_label'] ?? ''));
if (str_starts_with($shop, '天猫-') || str_starts_with($shop, '淘宝-')) {
$paid = self::buyerPaid($row, $risk);
if ($paid !== null && $final > $paid) {
if (str_contains($label, '以旧换新')) {
$issues[] = $this->issue(self::ISSUE_OVER_PAID, sprintf('以旧换新:报销金额 %s 大于买家实付 %s', self::money($final), self::money($paid)));
} elseif ($label === '' && self::cents($row['task_payment'] ?? null) > $paid) {
// 标签缺失时只看任务付款(不含佣金、运费)是否高于买家实付,避免把正常带佣金的单都标出来。
$issues[] = $this->issue(self::ISSUE_LABEL_MISSING, sprintf('查不到 ERP订单标签,任务付款 %s 大于买家实付 %s,请到聚水潭确认是否以旧换新', self::money(self::cents($row['task_payment'] ?? null)), self::money($paid)));
}
}
}
$center = $centers[$shop] ?? '';
if ($center === '') {
$issues[] = $this->issue(self::ISSUE_PROFIT_CENTER, '店铺 ' . $shop . ' 查不到所属利润中心,请手工补');
}
$results[] = $row + [
'issues' => $issues,
'risk' => $risk,
'profit_center' => $center,
'group' => isset(self::BIG_TMALL_SHOPS[$shop]) ? self::GROUP_BIG_TMALL : self::GROUP_OTHER,
];
}
$this->applyOverLimit($results);
foreach ($results as &$result) {
$codes = array_column($result['issues'], 'code');
$recheck = [self::ISSUE_MONITOR_MISSING, self::ISSUE_LABEL_MISSING];
$blocking = array_diff($codes, [self::ISSUE_PROFIT_CENTER, ...$recheck]);
$result['decision'] = $blocking !== []
? self::DECISION_CANCEL
: (array_intersect($recheck, $codes) !== [] ? self::DECISION_RECHECK : self::DECISION_PASS);
$result['decision_label'] = self::DECISION_LABELS[$result['decision']];
$result['issue_text'] = implode(';', array_column($result['issues'], 'message'));
}
unset($result);
return ['rows' => $results, 'summary' => $this->summarize($results, $reimburseDate)];
}
/**
* 超额:看板标「超额」且其他校验都通过的单,按 ID负责人 分组判断。
*
* @param list<array<string,mixed>> $results
*/
private function applyOverLimit(array &$results): void
{
$groups = [];
foreach ($results as $index => $result) {
$risk = $result['risk'];
if (! is_array($risk) || trim((string) ($risk['risk'] ?? '')) !== '超额') {
continue;
}
$blocking = array_diff(array_column($result['issues'], 'code'), [self::ISSUE_PROFIT_CENTER]);
if ($blocking !== []) {
continue;
}
$owner = trim((string) ($risk['owner'] ?? ''));
$groups[$owner][] = $index;
}
foreach ($groups as $indexes) {
$days = [];
$dailyOnly = true;
$limit = null;
foreach ($indexes as $index) {
$risk = $results[$index]['risk'];
$days[substr(self::paidAt($results[$index]), 0, 10)] = true;
if (trim((string) ($risk['risk_type'] ?? '')) !== '每日超额') {
$dailyOnly = false;
}
if (is_numeric($risk['daily_limit'] ?? null)) {
$limit = $limit === null ? self::cents($risk['daily_limit']) : min($limit, self::cents($risk['daily_limit']));
}
}
usort($indexes, static fn (int $a, int $b): int => [self::paidAt($results[$a]), (string) $results[$a]['online_order_no']]
<=> [self::paidAt($results[$b]), (string) $results[$b]['online_order_no']]);
$sameDay = count($days) === 1 && $dailyOnly && $limit !== null;
$total = 0;
$stopped = ! $sameDay;
foreach ($indexes as $index) {
$amount = self::cents($results[$index]['reimbursement_amount'] ?? null);
if (! $stopped && $total + $amount <= $limit) {
$total += $amount;
continue;
}
$stopped = true;
$hint = trim((string) ($results[$index]['risk']['hint'] ?? ''));
$results[$index]['issues'][] = $this->issue(self::ISSUE_OVER_LIMIT, $sameDay
? '超额:' . ($hint !== '' ? $hint : '超过日限额') . ',本单超出可报额度'
: '超额:' . ($hint !== '' ? $hint : '超过限额') . ',超额订单不在同一天,全部取消');
}
}
}
/**
* @param list<array<string,mixed>> $results
* @return array<string,mixed>
*/
private function summarize(array $results, string $reimburseDate): array
{
$billDate = date('Y-m-d', strtotime($reimburseDate . ' -1 day'));
$purpose = (int) substr($billDate, 5, 2) . '.' . (int) substr($billDate, 8, 2) . '种菜报销';
$counts = array_fill_keys(array_keys(self::DECISION_LABELS), 0);
$amounts = array_fill_keys(array_keys(self::DECISION_LABELS), 0);
$groups = [self::GROUP_BIG_TMALL => 0, self::GROUP_OTHER => 0];
$pivots = [self::GROUP_BIG_TMALL => [], self::GROUP_OTHER => []];
$cards = [self::GROUP_BIG_TMALL => [], self::GROUP_OTHER => []];
$ding = [];
$notices = [];
foreach ($results as $row) {
$amount = self::cents($row['reimbursement_amount'] ?? null);
$counts[$row['decision']]++;
$amounts[$row['decision']] += $amount;
if ($row['decision'] !== self::DECISION_PASS) {
$initiator = self::normName((string) ($row['initiator'] ?? '')) ?: '(未知发起人)';
$notices[$initiator][] = [
'order_no' => (string) $row['online_order_no'],
'work_order_no' => (string) ($row['work_order_no'] ?? ''),
'decision' => $row['decision'],
'reason' => $row['issue_text'],
];
continue;
}
$group = $row['group'];
$shop = (string) $row['shop_name'];
$center = (string) $row['profit_center'];
$payee = (string) ($row['payee_name'] ?? '');
$account = (string) ($row['payee_account'] ?? '');
$groups[$group] += $amount;
$pivotKey = implode("\x1f", [$row['initiator'] ?? '', $center, $shop, $payee, $account]);
$pivots[$group][$pivotKey] ??= [
'initiator' => (string) ($row['initiator'] ?? ''), 'profit_center' => $center, 'shop_name' => $shop,
'payee_name' => $payee, 'payee_account' => $account, 'cents' => 0,
];
$pivots[$group][$pivotKey]['cents'] += $amount;
$cardKey = $group === self::GROUP_BIG_TMALL
? implode("\x1f", [$center, $shop, $payee, $account])
: implode("\x1f", [$center, $payee, $account]);
$cards[$group][$cardKey] ??= [
'profit_center' => $center, 'shop_name' => $group === self::GROUP_BIG_TMALL ? $shop : '',
'payee_name' => $payee, 'payee_account' => $account, 'payee_bank' => '', 'payee_mobile' => '', 'cents' => 0,
];
$cards[$group][$cardKey]['cents'] += $amount;
foreach (['payee_bank', 'payee_mobile'] as $field) {
if ($cards[$group][$cardKey][$field] === '' && trim((string) ($row[$field] ?? '')) !== '') {
$cards[$group][$cardKey][$field] = trim((string) $row[$field]);
}
}
$dingKey = $group === self::GROUP_BIG_TMALL ? 'S' . $shop : 'C' . $center;
$ding[$dingKey] ??= [
'department' => $center,
'shop' => $group === self::GROUP_BIG_TMALL ? self::BIG_TMALL_SHOPS[$shop] : '其他',
'purpose' => $purpose,
'payment_method' => '银行卡',
'cents' => 0,
'order' => $group === self::GROUP_BIG_TMALL ? array_search($shop, array_keys(self::BIG_TMALL_SHOPS), true) : 10,
];
$ding[$dingKey]['cents'] += $amount;
}
$sortBy = static function (array $rows, array $keys): array {
$rows = array_values($rows);
usort($rows, static function (array $a, array $b) use ($keys): int {
foreach ($keys as $key) {
$cmp = self::compareText((string) $a[$key], (string) $b[$key]);
if ($cmp !== 0) {
return $cmp;
}
}
return 0;
});
return $rows;
};
$dingLines = array_values($ding);
usort($dingLines, static fn (array $a, array $b): int => [$a['order'], $a['department']] <=> [$b['order'], $b['department']]);
$noticeList = [];
foreach ($notices as $initiator => $items) {
$noticeList[] = ['initiator' => $initiator, 'items' => $items, 'message' => self::noticeMessage($initiator, $items)];
}
usort($noticeList, static fn (array $a, array $b): int => self::compareText($a['initiator'], $b['initiator']));
// 自检:各汇总口径的合计必须一致。
$passCents = $amounts[self::DECISION_PASS];
$sumCents = static fn (array $lines): int => array_sum(array_column($lines, 'cents'));
$checks = [
['label' => '钉钉明细合计 = 可报销合计', 'ok' => $sumCents($dingLines) === $passCents],
['label' => '打款卡号明细合计 = 可报销合计', 'ok' => $sumCents($cards[self::GROUP_BIG_TMALL]) + $sumCents($cards[self::GROUP_OTHER]) === $passCents],
['label' => '种菜任务工单合计 = 可报销合计', 'ok' => $sumCents($pivots[self::GROUP_BIG_TMALL]) + $sumCents($pivots[self::GROUP_OTHER]) === $passCents],
['label' => '三大天猫 + 其他 = 可报销合计', 'ok' => array_sum($groups) === $passCents],
['label' => '可报销 + 取消 + 待复查 条数 = 取单条数', 'ok' => array_sum($counts) === count($results)],
['label' => '可报销 + 取消 + 待复查 金额 = 取单金额', 'ok' => array_sum($amounts) === $sumCents(array_map(static fn (array $row): array => ['cents' => self::cents($row['reimbursement_amount'] ?? null)], $results))],
];
return [
'checks' => $checks,
'source' => ['count' => count($results), 'amount' => self::money(array_sum($amounts))],
'reimburse_date' => $reimburseDate,
'bill_date' => $billDate,
'purpose' => $purpose,
'counts' => $counts,
'amounts' => array_map([self::class, 'money'], $amounts),
'group_amounts' => array_map([self::class, 'money'], $groups),
'total' => self::money(array_sum($groups)),
'pivots' => [
// 与手工数据透视表一致:按创建人排序。
self::GROUP_BIG_TMALL => $sortBy($pivots[self::GROUP_BIG_TMALL], ['initiator', 'profit_center', 'shop_name', 'payee_name']),
self::GROUP_OTHER => $sortBy($pivots[self::GROUP_OTHER], ['initiator', 'profit_center', 'shop_name', 'payee_name']),
],
'cards' => [
self::GROUP_BIG_TMALL => $sortBy($cards[self::GROUP_BIG_TMALL], ['profit_center', 'shop_name', 'payee_name']),
self::GROUP_OTHER => $sortBy($cards[self::GROUP_OTHER], ['profit_center', 'payee_name']),
],
'ding_lines' => $dingLines,
'notices' => $noticeList,
];
}
/** @param list<array<string,string>> $items */
private static function noticeMessage(string $initiator, array $items): string
{
$lines = [];
foreach ($items as $item) {
$lines[] = $item['order_no'] . ' ' . $item['reason'];
}
return implode("\n", $lines) . "\n@" . $initiator . ' 以上订单本次不予报销,工单已取消(待复查的单稍后复核)。';
}
private function isRosterException(string $payee, array $exceptions): bool
{
foreach ($exceptions as $exception) {
if ($exception !== '' && ($payee === $exception || str_starts_with($payee, $exception))) {
return true;
}
}
return false;
}
/** @return array{code:string,label:string,message:string} */
private function issue(string $code, string $message): array
{
return ['code' => $code, 'label' => self::ISSUE_LABELS[$code], 'message' => $message];
}
/** 买家实付:取工单「买家实付」、看板「订单实付金额」、工单「实付金额」中最小的有效值(以旧换新补贴后实付更低)。 */
private static function buyerPaid(array $row, ?array $risk): ?int
{
$values = [];
foreach ([$row['buyer_paid_amount'] ?? null, is_array($risk) ? ($risk['order_paid'] ?? null) : null, $row['actual_paid_amount'] ?? null] as $value) {
if ($value !== null && $value !== '' && is_numeric(str_replace([',', ' '], '', (string) $value))) {
$values[] = self::cents($value);
}
}
return $values === [] ? null : min($values);
}
private static function paidAt(array $row): string
{
$risk = $row['risk'] ?? null;
$value = trim((string) ($row['paid_at'] ?? ''));
if ($value === '' && is_array($risk)) {
$value = trim((string) ($risk['paid_at'] ?? ''));
}
return str_replace('/', '-', $value);
}
public static function normName(string $name): string
{
return trim(preg_replace('/[\s\x{3000}]+/u', '', $name) ?? '');
}
public static function cents(mixed $value): int
{
if ($value === null || $value === '') {
return 0;
}
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim((string) $value));
if (! is_numeric($normalized)) {
return 0;
}
return (int) round(((float) $normalized) * 100);
}
public static function money(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
private static function compareText(string $a, string $b): int
{
static $collator = false;
if ($collator === false) {
$collator = class_exists(\Collator::class) ? new \Collator('zh_CN') : null;
}
return $collator ? (int) $collator->compare($a, $b) : strcmp($a, $b);
}
}