CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementWorkbench.php
limeng 951e553fae feat(种菜报销): 新增种菜报销工作台,逐单校验并生成打款明细与钉钉流程明细 (#1140)
- 种菜数据汇总新增「种菜报销工作台」:按聚工单锁单口径取单(待处理 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
2026-11-12 18:54:21 +08:00

207 lines
8.5 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,
) {}
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]));
$refs = [
'reimbursed' => $this->repository->reimbursedOrders($date, array_column($rows, 'work_order_no')),
'commission_people' => $this->references->nameSet($companyId, PlantingReimbursementReferences::KIND_COMMISSION),
'roster' => $this->references->nameSet($companyId, PlantingReimbursementReferences::KIND_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,
'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 !== '');
}
}