CaiWuXiTong/modules/EcommerceFinance/View/PlantingReimbursementWorkbenchPageData.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

136 lines
6.4 KiB
PHP

<?php
namespace Modules\EcommerceFinance\View;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Modules\EcommerceFinance\Services\PlantingReimbursementAuditor as Auditor;
use Modules\EcommerceFinance\Services\PlantingReimbursementReferences as References;
use Modules\EcommerceFinance\Services\PlantingReimbursementWorkbench as Workbench;
/** 种菜数据汇总 → 种菜报销工作台:待报销种菜工单逐单校验,出打款明细、钉钉流程明细和要取消的工单。 */
class PlantingReimbursementWorkbenchPageData
{
public const TITLE = '种菜报销工作台';
/** 聚工单镜像超过这么久没同步成功就提示数据不是最新的。 */
public const STALE_MINUTES = 20;
public const DECISION_FILTERS = ['all' => '全部', Auditor::DECISION_PASS => '可报销', Auditor::DECISION_CANCEL => '取消工单', Auditor::DECISION_RECHECK => '待复查'];
public const COLUMNS = [
'decision_label' => '处理结果', 'issue_text' => '原因', 'work_order_no' => '工单编号', 'status' => '工单状态',
'paid_at' => '付款时间', 'initiator' => '发起人', 'shop_name' => '店铺', 'profit_center' => '所属利润中心',
'online_order_no' => '线上订单号', 'task_payment' => '任务付款', 'task_freight' => '快递费', 'task_commission' => '佣金',
'jingxi_price_diff' => '京喜差价', 'task_deduction' => '扣款', 'task_empty_package_fee' => '空包费',
'reimbursement_amount' => '最终报销金额', 'actual_paid_amount' => '买家实付', 'payee_name' => '收款人',
'payee_account' => '账号', 'payee_bank' => '开户行', 'erp_order_label' => 'ERP订单标签', 'task_remark' => '任务备注',
];
public const MONEY_COLUMNS = [
'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff', 'task_deduction',
'task_empty_package_fee', 'reimbursement_amount', 'actual_paid_amount',
];
public function __construct(
private readonly Workbench $workbench,
private readonly Repository $repository,
private readonly References $references,
private readonly \Modules\EcommerceFinance\Services\PlantingReimbursementRobot $robot,
) {}
/** @return array{date:string,paid_from:string,scope:string,decision:string} */
public function filters(): array
{
$date = trim((string) request('date', '')) ?: Workbench::defaultDate();
try {
$paidFrom = trim((string) request('paid_from', '')) ?: Workbench::defaultPaidFrom($date);
Workbench::paidWindow($date, $paidFrom);
} catch (InvalidArgumentException $exception) {
abort(422, $exception->getMessage());
}
$scope = (string) request('scope', '') ?: 'pending';
abort_unless(isset(Workbench::SCOPES[$scope]), 422, '取单范围无效。');
$decision = (string) request('decision', '') ?: 'all';
abort_unless(isset(self::DECISION_FILTERS[$decision]), 422, '处理结果筛选无效。');
return ['date' => $date, 'paid_from' => $paidFrom, 'scope' => $scope, 'decision' => $decision];
}
/** @param array{date:string,paid_from:string,scope:string} $filters */
public function build(array $filters): array
{
return $this->workbench->build((int) company_id(), $filters['date'], $filters['paid_from'], Workbench::scopeStatuses($filters['scope']));
}
public function page(): array
{
$filters = $this->filters();
$sync = $this->repository->syncStatus();
$result = null;
$error = null;
if (! in_array($sync['code'], ['ready', 'failed'], true)) {
$error = '聚工单镜像未就绪,暂时无法生成报销批次。';
} else {
try {
$result = $this->build($filters);
} catch (\Throwable $exception) {
report($exception);
$error = '种菜报销工作台数据暂时无法读取,请稍后重试。';
}
}
$rows = $result['rows'] ?? [];
if ($filters['decision'] !== 'all') {
$rows = array_values(array_filter($rows, static fn (array $row): bool => $row['decision'] === $filters['decision']));
}
// 先看要处理的:取消、待复查在前。
$order = [Auditor::DECISION_CANCEL => 0, Auditor::DECISION_RECHECK => 1, Auditor::DECISION_PASS => 2];
usort($rows, static fn (array $a, array $b): int => [$order[$a['decision']], $a['initiator'], $a['paid_at']] <=> [$order[$b['decision']], $b['initiator'], $b['paid_at']]);
// 镜像停更超过 20 分钟或最近一次同步失败:数据不是最新的,提醒先别报销。
$syncStale = $sync['code'] !== 'ready' || $sync['last_succeeded_at'] === null
|| now()->diffInMinutes(\Illuminate\Support\Carbon::parse($sync['last_succeeded_at']), true) > self::STALE_MINUTES;
return [
'view' => 'business.pages.billing-transactions.planting-reimbursement-workbench',
'workbenchSyncStale' => $syncStale,
'workbenchRobot' => $this->robotSummary(),
'workbenchNoticeLog' => $this->robot->noticeLog((int) company_id(), $filters['date']),
'workbenchRosterSource' => $result['roster_source'] ?? null,
'workbenchFilters' => $filters,
'workbenchWindow' => Workbench::paidWindow($filters['date'], $filters['paid_from']),
'workbenchSync' => $sync,
'workbenchError' => $error,
'workbenchSummary' => $result['summary'] ?? null,
'workbenchRows' => $rows,
'workbenchUploads' => $result['uploads'] ?? $this->safeUploads(),
];
}
/** @return array<string,mixed> */
private function safeUploads(): array
{
try {
return $this->references->uploads((int) company_id());
} catch (\Throwable $exception) {
report($exception);
return array_fill_keys(array_keys(References::KINDS), null);
}
}
/** @return array<string,mixed> */
private function robotSummary(): array
{
try {
return $this->robot->summary();
} catch (\Throwable $exception) {
report($exception);
return ['configured' => false, 'webhook' => '', 'has_secret' => false, 'keyword' => '', 'reporters' => '', 'digest_webhook' => '', 'has_digest_secret' => false];
}
}
}