- 种菜数据汇总新增「种菜报销工作台」:按聚工单锁单口径取单(待处理 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
467 lines
22 KiB
PHP
467 lines
22 KiB
PHP
<?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);
|
||
}
|
||
}
|