CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementWorkbench.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

219 lines
9.2 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;
use App\Models\Business\StoreInformation;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Throwable;
/**
* 种菜报销工作台数据层:取一批待报销工单 + 参考名单,交给审核器逐单判定并汇总。
*
* 锁单口径(2026-11-12 报销人确认):聚工单种菜报销、工单状态「待处理」、订单付款时间 [D−7 00:00:00, D−1 23:59:59]、
* ERP订单状态不为取消 / 删除 / 空、售后单号为空。
*/
class PlantingReimbursementWorkbench
{
/** 收款人不在花名册也照常报销的例外。 */
public const ROSTER_EXCEPTIONS = ['柴嘉伟(建行)', '柴嘉伟'];
/** 聚水潭订单镜像(ERP订单标签兜底)。 */
public const ERP_ORDER_TABLE = 'ops_jst_qmcust_orders';
public function __construct(
private readonly Repository $repository,
private readonly PlantingReimbursementReferences $references,
private readonly PlantingReimbursementAuditor $auditor,
private readonly PlantingReimbursementDirectory $directory,
) {}
public static function defaultDate(?CarbonImmutable $now = null): string
{
return ($now ?? CarbonImmutable::now())->format('Y-m-d');
}
/** 取单范围:待处理(锁单前,默认)/ 处理中(已锁单)/ 两者。 */
public const SCOPES = [
'pending' => '待处理(锁单前)',
'locked' => '处理中(已锁单)',
'all' => '待处理 + 处理中',
];
/** @return list<string> */
public static function scopeStatuses(string $scope): array
{
return match ($scope) {
'locked' => [Repository::STATUS_LOCKED],
'all' => Repository::OPEN_STATUSES,
default => [Repository::STATUS_PENDING],
};
}
/** 默认付款起始日:报销日往前 7 天。 */
public static function defaultPaidFrom(string $date): string
{
return self::parseDate($date)->subDays(7)->format('Y-m-d');
}
/** @return array{from:string,to:string} 订单付款时间窗口 [起始日 00:00:00, 报销日前一天 23:59:59] */
public static function paidWindow(string $date, ?string $paidFrom = null): array
{
$day = self::parseDate($date);
$from = $paidFrom === null || $paidFrom === '' ? $day->subDays(7) : self::parseDate($paidFrom);
if ($from->greaterThanOrEqualTo($day) || $from->lessThan($day->subDays(31))) {
throw new InvalidArgumentException('付款起始日须在报销日前 1~31 天内。');
}
return [
'from' => $from->format('Y-m-d 00:00:00'),
'to' => $day->subDay()->format('Y-m-d 23:59:59'),
];
}
public static function parseDate(string $date): CarbonImmutable
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1 || $date < '2026-09-09') {
throw new InvalidArgumentException('报销日期无效。');
}
$parsed = CarbonImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new InvalidArgumentException('报销日期无效。');
}
return $parsed;
}
/**
* @param list<string>|null $statuses 默认只取「待处理」
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>,window:array{from:string,to:string},uploads:array<string,mixed>}
*/
public function build(int $companyId, string $date, ?string $paidFrom = null, ?array $statuses = null): array
{
$window = self::paidWindow($date, $paidFrom);
$rows = $this->fillErpLabels($this->repository->pendingRows($window['from'], $window['to'], $statuses ?? [Repository::STATUS_PENDING]));
// 收款人在职:优先用上传的花名册;没上传就用钉钉花名册副本里的在职真名。
$roster = $this->references->nameSet($companyId, PlantingReimbursementReferences::KIND_ROSTER);
$rosterSource = 'upload';
if ($roster === []) {
$roster = $this->directory->activeNames();
$rosterSource = $roster === [] ? 'none' : 'dingtalk';
// 两边都取不到名单时不做在职校验(页面提示),避免把所有单误判为收款人不在职。
$roster = $roster === [] ? null : $roster;
}
$refs = [
'reimbursed' => $this->repository->reimbursedOrders($date, array_column($rows, 'work_order_no')),
'commission_people' => $this->references->nameSet($companyId, PlantingReimbursementReferences::KIND_COMMISSION),
'roster' => $roster,
'roster_exceptions' => self::ROSTER_EXCEPTIONS,
'profit_centers' => $this->profitCenters($companyId, array_unique(array_column($rows, 'shop_name'))),
'monitor' => $this->references->monitor($companyId),
];
return $this->auditor->audit($rows, $refs, $date) + [
'window' => $window,
'roster_source' => $rosterSource,
'uploads' => $this->references->uploads($companyId),
];
}
/**
* 工单里 ERP订单标签为空的(如部分以旧换新单),按线上订单号补查聚水潭订单镜像的标签。
*
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function fillErpLabels(array $rows): array
{
$missing = [];
foreach ($rows as $row) {
$orderNo = trim((string) ($row['online_order_no'] ?? ''));
if ($orderNo !== '' && trim((string) ($row['erp_order_label'] ?? '')) === '') {
$missing[] = $orderNo;
}
}
if ($missing === []) {
return $rows;
}
$labels = [];
try {
$connection = app(GenericBusinessMirrorRepository::class)->connection();
if ($connection->getSchemaBuilder()->hasTable(self::ERP_ORDER_TABLE)) {
// so_id 是字符串列,必须按字符串绑定,否则走不了索引。
foreach (array_chunk(array_values(array_unique(array_map('strval', $missing))), 500) as $chunk) {
foreach ($connection->table(self::ERP_ORDER_TABLE)->whereIn('so_id', $chunk)->get(['so_id', 'labels']) as $order) {
$label = trim((string) $order->labels);
if ($label !== '') {
$labels[(string) $order->so_id] = isset($labels[(string) $order->so_id]) ? $labels[(string) $order->so_id] . ',' . $label : $label;
}
}
}
}
} catch (Throwable $exception) {
report($exception);
}
foreach ($rows as &$row) {
$orderNo = trim((string) ($row['online_order_no'] ?? ''));
if (trim((string) ($row['erp_order_label'] ?? '')) === '' && isset($labels[$orderNo])) {
$row['erp_order_label'] = $labels[$orderNo];
}
}
unset($row);
return $rows;
}
/**
* 店铺 → 利润中心:上传的店铺信息 > 店铺维度报表 ERP 店铺资料 > 系统店铺资料。
*
* @param list<string> $shops
* @return array<string,string>
*/
public function profitCenters(int $companyId, array $shops): array
{
$uploaded = $this->references->shopCenters($companyId);
$map = [];
$missing = [];
foreach ($shops as $shop) {
$shop = (string) $shop;
if (($uploaded[$shop] ?? '') !== '') {
$map[$shop] = $uploaded[$shop];
} else {
$missing[] = $shop;
}
}
if ($missing === []) {
return $map;
}
try {
$table = app(BusinessMirrorDatasetRegistry::class)->get(\App\Business\PlantingWorkOrderSynchronizer::SHOP_INFO_DATASET)->targetTable();
$connection = app(GenericBusinessMirrorRepository::class)->connection();
if ($connection->getSchemaBuilder()->hasTable($table)) {
foreach ($connection->table($table)->whereIn('erpShopName', $missing)->where('responsibleDepartment', '<>', '')
->get(['erpShopName', 'responsibleDepartment']) as $row) {
$map[trim((string) $row->erpShopName)] ??= trim((string) $row->responsibleDepartment);
}
}
} catch (Throwable $exception) {
report($exception);
}
$missing = array_values(array_filter($missing, static fn (string $shop): bool => ($map[$shop] ?? '') === ''));
if ($missing !== []) {
foreach (StoreInformation::query()->where('company_id', $companyId)->whereIn('erp_store_name', $missing)
->where('profit_center', '<>', '')->get(['erp_store_name', 'profit_center']) as $store) {
$map[(string) $store->erp_store_name] ??= trim((string) $store->profit_center);
}
}
return array_filter($map, static fn (string $center): bool => $center !== '');
}
}