CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementAuditor.php
limeng f78149e630 feat(种菜报销): 接入钉钉群机器人(取消通知 @ 发起人、每天 10:25 推送汇总),看板职级为空改为取消 (#1150)
- 工作台新增「钉钉机器人设置」:Webhook、加签密钥加密存公司设置,页面不回显;支持自定义关键词、汇总单独发群、@ 报销人花名
- 核对后点「发取消通知到钉钉群」:按发起人一人一条,按钉钉花名册(花名 → userid)@ 发起人;同一批已发过的不重复发,可勾选重发
- 新增 planting-reimbursement:daily-digest,每天 10:25(宿主机 cron)生成当天批次,把待核对条数/金额、可报销合计、取消/待复查数、自检结果和工作台链接推到群里;数据停更或看板未上传会提醒
- 种菜监控看板提示「职级为空」的单改为取消工单,群通知原因写「职级为空」(2026-11-13 报销人确认)
- 没上传花名册时,收款人在职改按钉钉花名册在职真名判断;两边都取不到时不做该项校验并提示,避免误判
涉及:只读聚工单镜像与钉钉花名册副本;钉钉群机器人发消息;不涉及迁移,不写聚工单。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-13 11:15:04 +08:00

474 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_RANK_MISSING = 'rank_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 => '标签待确认',
self::ISSUE_RANK_MISSING => '职级为空',
];
/**
* @param list<array<string,mixed>> $rows 工单行(PlantingReimbursementRepository 的字段)
* @param array{
* reimbursed?: array<string,string>,
* commission_people?: array<string,bool>,
* roster?: array<string,bool>|null,
* 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 = array_key_exists('roster', $refs) ? $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 ($roster !== null && ! 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 分钟后重新导入看板再看,仍匹配不到则取消');
} elseif (str_contains((string) ($risk['risk_type'] ?? '') . (string) ($risk['hint'] ?? ''), '职级为空')) {
// 2026-11-13 报销人确认:看板提示职级为空的直接取消,群里告知原因。
$issues[] = $this->issue(self::ISSUE_RANK_MISSING, '职级为空:种菜监控看板里该单的 ID负责人' . (trim((string) ($risk['owner'] ?? '')) !== '' ? '(' . trim((string) $risk['owner']) . ')' : '') . '没有职级,无法判断报销额度,本次不予报销');
}
}
$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);
}
}