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