- 工作台新增「钉钉机器人设置」: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
219 lines
9.2 KiB
PHP
219 lines
9.2 KiB
PHP
<?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 !== '');
|
||
}
|
||
}
|