CaiWuXiTong/modules/EcommerceFinance/Repositories/PlantingReimbursementRepository.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

489 lines
19 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\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/**
* 聚水潭聚工单「打款工单 → 种菜报销」:只读工单镜像 jiansun.dbo.jst_workOrderInfo。
*
* 口径(2026-10-19 财务确认):工单类型“种菜报销”、工单状态“已完成”,按完成时间筛选,
* 自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的明细,所以 M 月种菜报销明细
* = 完成时间 [M 月 2 日 00:00, M+1 月 2 日 00:00)。同一工单号只取最新一版。
*/
class PlantingReimbursementRepository
{
public const DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
public const STATUS_CONFIRMED = 'CONFIRMED';
/** 聚工单「待处理」(锁单前)。 */
public const STATUS_PENDING = 'WAIT_CONFIRM';
/** 聚工单「处理中」(已锁单)。 */
public const STATUS_LOCKED = 'CONFIRMING';
public const OPEN_STATUSES = [self::STATUS_PENDING, self::STATUS_LOCKED];
/** 锁单时 ERP订单状态不勾的项:取消、删除、为空。 */
public const EXCLUDED_ERP_STATUSES = ['', '已取消', '取消', 'Cancelled', 'Delete', 'Deleted', '已删除', '删除'];
public const STATUS_LABELS = [
'CONFIRMED' => '已完成', 'DISABLED' => '已取消', 'WAIT_CONFIRM' => '待处理', 'CONFIRMING' => '处理中',
'WAIT_AUDIT' => '待审核', 'REJECT_AUDIT' => '审核驳回',
];
public const START_AT = '2026-09-02 00:00:00';
public const FIRST_MONTH = '2026-09';
/** 聚工单只返回 fieldId;对应关系由历史种菜明细逐列对撞及打款工单列表核对得出。 */
public const FIELDS = [
'online_order_no' => 'orderField',
'internal_order_no' => 'o_id',
'platform' => 'platform',
'shop_name' => 'shopField',
'actual_paid_amount' => 'actualPaidAmountField',
'buyer_paid_amount' => 'buyerPaidAmount',
'task_remark' => 'TEXTField1413856620251113150546',
'seller_remark' => 'businessRemarkField',
'task_payment' => 'NUMBERField1413856620250924131615',
'task_freight' => 'NUMBERField1413856620251113150537',
'task_commission' => 'NUMBERField1413856620251230154457',
'jingxi_price_diff' => 'NUMBERField1413856620250924131623',
'task_deduction' => 'NUMBERField1413856620251113150612',
'task_empty_package_fee' => 'NUMBERField1413856620251113145620',
'reimbursement_amount' => 'NUMBERField1413856620251117101250',
'payee_name' => 'TEXTField1413856620251117180931',
'payee_account' => 'TEXTField1413856620251117113451',
'payee_bank' => 'TEXTField1413856620251117112729',
'payee_mobile' => 'TEXTField1413856620260618171005',
'erp_order_label' => 'erpOrderLabel',
'erp_order_status' => 'erpOrderStatus',
'platform_order_status' => 'platformOrderStatus',
'paid_at' => 'payDateField',
'after_sale_no' => 'asid',
];
public const MONEY_FIELDS = [
'actual_paid_amount', 'buyer_paid_amount', 'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff',
'task_deduction', 'task_empty_package_fee', 'reimbursement_amount',
];
private const ERP_ORDER_STATUS = [
'Sent' => '已发货', 'Merged' => '被合并', 'Cancelled' => '已取消', 'Split' => '被拆分',
'WaitConfirm' => '待审核', 'WaitFConfirm' => '待财审', 'Delivering' => '发货中',
'Question' => '异常', 'WaitOuterSent' => '等供销商发货',
];
private const ROWS_CACHE_SECONDS = 600;
private const QUERY_TIMEOUT_SECONDS = 90;
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
/**
* 报销月份 → 完成时间窗口 [M-02 00:00, M+1-02 00:00)。
*
* @return array{from:string,to:string}
*/
public static function window(string $month): array
{
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1 || $month < self::FIRST_MONTH) {
throw new InvalidArgumentException('报销月份无效。');
}
$start = CarbonImmutable::createFromFormat('!Y-m-d', $month . '-02');
return ['from' => $start->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s')];
}
/** 默认月份:完成时间窗口已开始的最近一个报销月(每月 1 日仍算上月)。 */
public static function defaultMonth(?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::now();
$month = $now->subDay()->format('Y-m');
return max($month, self::FIRST_MONTH);
}
/** @return list<string> 可选报销月份(新到旧) */
public static function months(?CarbonImmutable $now = null): array
{
$months = [];
$cursor = CarbonImmutable::createFromFormat('!Y-m', self::defaultMonth($now));
while ($cursor->format('Y-m') >= self::FIRST_MONTH) {
$months[] = $cursor->format('Y-m');
$cursor = $cursor->subMonthNoOverflow();
}
return $months;
}
/** @return array{code:string,last_succeeded_at:?string} */
public function syncStatus(): array
{
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$state = $this->mirror->state($this->dataset());
} catch (Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null;
if (($state['initial_completed_at'] ?? null) === null) {
return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last];
}
return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last];
}
/**
* 某报销月份的全部种菜报销(已完成,每个工单号取最新一版),按完成时间倒序。
* 结果按月份缓存 10 分钟;单月约 1.5~2 万行。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$window = self::window($month);
$status = $this->syncStatus();
if (! in_array($status['code'], ['ready', 'failed'], true)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
// 镜像每几分钟增量一次,按版本做键会让缓存几乎每次失效;固定按月份缓存 10 分钟。
$key = 'planting-reimbursements:rows:v2:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$rows = $this->load($window['from'], $window['to']);
try {
Cache::put($key, $rows, self::ROWS_CACHE_SECONDS);
} catch (Throwable $exception) {
report($exception);
}
return $rows;
}
/**
* 种菜报销工作台:付款时间落在 [$paidFrom, $paidTo] 的种菜工单(每个工单号取最新一版,且最新一版状态在 $statuses 中),
* 并按锁单口径排除 ERP订单状态为取消 / 删除 / 空、以及有售后单号的单。
* $completedWindow 用于按完成时间回放某一批已报销的单做对照。
*
* @param list<string> $statuses
* @param array{from:string,to:string}|null $completedWindow
* @return list<array<string,mixed>>
*/
public function pendingRows(string $paidFrom, string $paidTo, array $statuses = self::OPEN_STATUSES, ?array $completedWindow = null): array
{
$status = $this->syncStatus();
if (! in_array($status['code'], ['ready', 'failed'], true)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$table = $this->dataset()->targetTable();
// 工单都在订单付款之后发起,用发起时间先收窄范围(走索引),再按表单里的付款时间精确过滤。
$query = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('created', '>=', $paidFrom)
->select(['Id', 'woNo', 'status', 'createby', 'executors', 'completer', 'completeTime', 'created', 'businessFieldList'])
->orderByDesc('Id');
if ($completedWindow !== null) {
$query->where('completeTime', '>=', $completedWindow['from'])->where('completeTime', '<', $completedWindow['to']);
}
$latest = [];
foreach ($this->boundedGet($query) as $record) {
$woNo = (string) $record->woNo;
if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) {
$latest[$woNo] = $record;
}
}
$newestIds = $this->newestIds(array_keys($latest));
$rows = [];
foreach ($latest as $woNo => $record) {
if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id
|| ! in_array((string) $record->status, $statuses, true)) {
continue;
}
$row = $this->decorate($record);
$paidAt = str_replace('/', '-', (string) $row['paid_at']);
if ($paidAt === '' || $paidAt < $paidFrom || $paidAt > $paidTo) {
continue;
}
// 与聚工单锁单筛选一致:ERP订单状态不为取消 / 删除 / 空,且售后单号为空。
if (in_array(trim((string) $row['erp_order_status']), self::EXCLUDED_ERP_STATUSES, true) || trim((string) $row['after_sale_no']) !== '') {
continue;
}
$rows[] = $row;
}
usort($rows, static fn (array $a, array $b): int => [$a['paid_at'], $a['work_order_no']] <=> [$b['paid_at'], $b['work_order_no']]);
return $rows;
}
/**
* 已报销过的线上订单号 → 报销日期(工单完成日期),取本报销月和上一个报销月的已完成种菜报销。
*
* @param list<string> $excludeWorkOrders 不计入的工单号(回放对照时排除本批自身)
* @return array<string,string>
*/
public function reimbursedOrders(string $reimburseDate, array $excludeWorkOrders = []): array
{
$month = self::defaultMonth(CarbonImmutable::createFromFormat('!Y-m-d', $reimburseDate));
$months = [$month];
$previous = CarbonImmutable::createFromFormat('!Y-m', $month)->subMonthNoOverflow()->format('Y-m');
if ($previous >= self::FIRST_MONTH) {
$months[] = $previous;
}
$exclude = array_fill_keys($excludeWorkOrders, true);
$orders = [];
foreach ($months as $item) {
foreach ($this->rows($item) as $row) {
$orderNo = trim((string) $row['online_order_no']);
if ($orderNo === '' || isset($exclude[$row['work_order_no']])) {
continue;
}
$date = substr((string) $row['completed_at'], 0, 10);
if (! isset($orders[$orderNo]) || $date > $orders[$orderNo]) {
$orders[$orderNo] = $date;
}
}
}
return $orders;
}
/**
* @param list<string|int> $workOrders
* @return array<string,int> 工单号 → 最新一版 Id
*/
private function newestIds(array $workOrders): array
{
$newestIds = [];
foreach (array_chunk(array_map('strval', $workOrders), 1000) as $chunk) {
$versions = $this->connection->table($this->dataset()->targetTable())
->where('woTypeId', self::WORK_TYPE_ID)
->whereIn('woNo', $chunk)
->groupBy('woNo')
->selectRaw('woNo, MAX(Id) AS newest_id')
->get();
foreach ($versions as $version) {
$newestIds[(string) $version->woNo] = (int) $version->newest_id;
}
}
return $newestIds;
}
/** @return list<array<string,mixed>> */
private function load(string $from, string $to): array
{
$table = $this->dataset()->targetTable();
$query = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('completeTime', '>=', $from)
->where('completeTime', '<', $to)
->select([
'Id', 'woNo', 'status', 'createby', 'executors', 'completer',
'completeTime', 'created', 'businessFieldList',
])
->orderByDesc('completeTime')
->orderByDesc('woNo');
/** @var array<string,object> $latest 窗口内每个工单号的最新一版 */
$latest = [];
foreach ($this->boundedGet($query) as $record) {
$woNo = (string) $record->woNo;
if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) {
$latest[$woNo] = $record;
}
}
// 同一工单若有更新的一版(可能已改完成时间或被作废)落在窗口外,以最新一版为准,这里剔除。
$newestIds = $this->newestIds(array_keys($latest));
$rows = [];
foreach ($latest as $woNo => $record) {
if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id) {
continue;
}
// 最新一版已不是“已完成”(如完成后被作废)的工单不计入。
if ((string) $record->status !== self::STATUS_CONFIRMED) {
continue;
}
$rows[] = $this->decorate($record);
}
return $rows;
}
/** @return array<string,mixed> */
private function decorate(object $record): array
{
$fields = $this->fields((string) ($record->businessFieldList ?? ''));
$row = [
'work_order_no' => (string) $record->woNo,
'started_at' => $this->time($record->created ?? null),
'completed_at' => $this->time($record->completeTime ?? null),
'status' => self::STATUS_LABELS[(string) ($record->status ?? '')] ?? (string) ($record->status ?? ''),
'initiator' => (string) ($record->createby ?? ''),
'executor' => (string) ($record->executors ?? ''),
'completer' => (string) ($record->completer ?? ''),
];
foreach (self::FIELDS as $key => $fieldId) {
$row[$key] = $this->display($fields[$fieldId] ?? null);
}
foreach (self::MONEY_FIELDS as $key) {
$row[$key] = $this->money($row[$key]);
}
$row['erp_order_status'] = self::ERP_ORDER_STATUS[$row['erp_order_status']] ?? $row['erp_order_status'];
$items = $this->items($fields['itemInfo']['raw'] ?? null);
$row['product_code'] = implode("\n", array_filter(array_column($items, 'skuId')));
$row['product_name'] = implode("\n", array_filter(array_column($items, 'name')));
$row['product_quantity'] = implode("\n", array_filter(array_column($items, 'qty')));
$row['platform_product_id'] = implode("\n", array_filter(array_column($items, 'platformItemId')));
return $row;
}
/** @return array<string,array{value:mixed,label:mixed,raw:array<mixed>}> */
private function fields(string $json): array
{
if (trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
if (isset($decoded['businessField']) && is_array($decoded['businessField'])) {
$decoded = $decoded['businessField'];
}
$fields = [];
foreach (is_array($decoded) ? $decoded : [] as $item) {
if (is_array($item) && isset($item['fieldId'])) {
$fields[(string) $item['fieldId']] = [
'value' => $item['value'] ?? ($item['valueList'] ?? null),
'label' => $item['valueLabel'] ?? null,
'raw' => $item,
];
}
}
return $fields;
}
/** @param array{value:mixed,label:mixed}|null $field */
private function display(?array $field): string
{
if ($field === null) {
return '';
}
foreach ([$field['label'], $field['value']] as $value) {
if (is_array($value)) {
$value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value)));
}
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
}
/**
* @param array<mixed>|null $raw
* @return list<array<string,string>>
*/
private function items(?array $raw): array
{
$items = [];
foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) {
$item = [];
foreach ((array) $childRow as $child) {
if (is_array($child) && isset($child['fieldId'])) {
$v = $child['valueLabel'] ?? $child['value'] ?? '';
$item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : '';
}
}
if ($item !== []) {
$items[] = $item;
}
}
foreach ((array) ($raw['value'] ?? []) as $item) {
if (is_array($item) && ! array_is_list($item)) {
$items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item);
}
}
return $items;
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) {
return null;
}
return number_format(round((float) $normalized, 2), 2, '.', '');
}
private function time(mixed $value): string
{
return substr(trim((string) $value), 0, 19);
}
/** @return iterable<int,object> */
private function boundedGet(Builder $query): iterable
{
$connection = $query->getConnection();
if ($connection->getDriverName() !== 'mysql') {
return $query->get();
}
return $connection->select(
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
);
}
private function dataset(): BusinessMirrorDataset
{
return $this->datasets->get(self::DATASET);
}
}