CaiWuXiTong/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php
yueyang 80bb67a14c feat(small-payment-work-orders): 筛选打款金额合计与可筛选控件
- 汇总金额改为当前筛选条件下的全部打款金额合计
- 店铺名称、打款原因增加 datalist 自动补全
- 工单/打款状态改为下拉选择
- 完成日期支持日期时间筛选

Refs: 财务平台小额打款页面
2026-09-24 16:01:27 +08:00

654 lines
24 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\CarbonInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/** 只读聚水潭“小额打款”工单镜像;HTTP 请求不得回退直连上游。 */
class SmallPaymentWorkOrderMirrorRepository
{
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const PAYMENT_MAP_DATASET = 'jiansun.dbo.jst_small_payment_order_map';
public const SMALL_PAYMENT_WORK_TYPE_ID = '82D94F5C54544EE3833CD8A3AB8B9F73';
private const REQUIRED_QUERY_INDEXES = [
self::WORK_ORDER_DATASET => [
['woTypeId', 'woNo', 'Id'],
['woTypeId', 'completeTime', 'woNo', 'Id'],
],
self::PAYMENT_MAP_DATASET => [['woTypeId', 'woNo']],
];
private const WORK_ORDER_STATUS_LABELS = [
'WAIT_AUDIT' => '待审核',
'REJECT_AUDIT' => '审核驳回',
'WAIT_CONFIRM' => '待确认',
'CONFIRMING' => '确认中',
'CONFIRMED' => '已完成',
'DISABLED' => '已作废',
];
private const COMPLETED_WORK_ORDER_STATUSES = ['CONFIRMED', '已完成', '已确认'];
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($this->dataset(self::WORK_ORDER_DATASET)->targetTable())
&& $schema->hasTable($this->dataset(self::PAYMENT_MAP_DATASET)->targetTable());
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
return $this->mirror->ready($this->dataset(self::WORK_ORDER_DATASET))
&& $this->mirror->ready($this->dataset(self::PAYMENT_MAP_DATASET));
}
public function queryReady(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) {
$table = $this->dataset($datasetKey)->targetTable();
if (! $schema->hasTable($table)) {
return false;
}
$indexes = collect($schema->getIndexes($table))
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
));
foreach ($requiredIndexes as $required) {
if (! $indexes->contains(array_map('strtolower', $required))) {
return false;
}
}
}
} catch (Throwable) {
return false;
}
return true;
}
/** @return array<string,array<string,mixed>|null> */
public function states(): array
{
return [
self::WORK_ORDER_DATASET => $this->mirror->state($this->dataset(self::WORK_ORDER_DATASET)),
self::PAYMENT_MAP_DATASET => $this->mirror->state($this->dataset(self::PAYMENT_MAP_DATASET)),
];
}
/**
* @param array<string,array<string,mixed>|null>|null $states
* @return array{code:string,last_succeeded_at:?string}
*/
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 2) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$initialized = collect($states)->every(
static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null
);
$statuses = collect($states)->pluck('status')->map('strval');
$lastSucceededAt = collect($states)
->pluck('last_succeeded_at')
->filter()
->sort()
->first();
if (! $initialized) {
$code = $statuses->contains('failed') ? 'initial_failed' : 'initializing';
} elseif ($statuses->contains('failed')) {
$code = 'failed';
} elseif ($statuses->contains('running')) {
$code = 'syncing';
} else {
$lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null;
$lagMinutes = $lastSucceeded === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60);
$code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy';
}
return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return array{data:list<array<string,mixed>>,total:int,page_payment_amount:string,filtered_payment_amount:string}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$this->ensureReady();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$total = (clone $query)->count();
$rows = $query
->select($this->selectColumns())
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->decorate((array) $row))
->all();
return [
'data' => $rows,
'total' => $total,
'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')),
'filtered_payment_amount' => $this->filteredPaymentAmount($filters),
];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
*/
public function filteredPaymentAmount(array $filters): string
{
$this->ensureReady();
$amounts = [];
foreach ($this->exportRows($filters, 100) as $row) {
$amounts[] = $row['payment_amount'] ?? null;
}
return $this->sumMoney($amounts);
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return iterable<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $chunkSize = 100): iterable
{
$this->ensureReady();
$chunkSize = min(max($chunkSize, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$page = 1;
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $chunkSize)
->get();
foreach ($rows as $row) {
yield $this->decorate((array) $row);
}
$page++;
} while ($rows->count() === $chunkSize);
}
/**
* 返回当前小额打款镜像中出现过的店铺名称列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function shopNames(): array
{
if (! $this->available() || ! $this->ready() || ! $this->queryReady()) {
return [];
}
$table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
try {
return $this->connection->table($table)
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->selectRaw('DISTINCT shop_name')
->orderBy('shop_name')
->limit(1000)
->pluck('shop_name')
->map(static fn (mixed $name): string => (string) $name)
->all();
} catch (Throwable) {
return [];
}
}
/**
* 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。
*
* @return list<string>
*/
public function paymentReasons(): array
{
if (! $this->available() || ! $this->ready() || ! $this->queryReady()) {
return [];
}
$table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
try {
return $this->connection->table($table)
->whereNotNull('payment_reason')
->where('payment_reason', '<>', '')
->selectRaw('DISTINCT payment_reason')
->orderBy('payment_reason')
->limit(200)
->pluck('payment_reason')
->map(static fn (mixed $reason): string => (string) $reason)
->all();
} catch (Throwable) {
return [];
}
}
private function ensureReady(): void
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
}
private function baseQuery(): Builder
{
$workOrders = $this->dataset(self::WORK_ORDER_DATASET)->targetTable();
$paymentMap = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
return $this->connection->table($workOrders . ' AS w')
->join($paymentMap . ' AS p', static function ($join): void {
$join->on('p.woTypeId', '=', 'w.woTypeId')
->on('p.woNo', '=', 'w.woNo');
})
->leftJoin($workOrders . ' AS newer_work_order', static function ($join): void {
$join->on('newer_work_order.woTypeId', '=', 'w.woTypeId')
->on('newer_work_order.woNo', '=', 'w.woNo')
->on('newer_work_order.Id', '>', 'w.Id');
})
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->whereNull('newer_work_order.Id');
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'w.Id AS source_id', 'p.woNo AS work_order_no',
'p.transaction_no AS mapped_online_order_no', 'w.status AS work_order_status',
'p.status AS mapped_status', 'w.title AS work_order_title',
'p.title AS mapped_title', 'w.content AS work_order_content',
'w.grade AS priority', 'w.urging AS urging_count',
'w.createby AS creator', 'w.executors', 'w.completer',
'w.completeTime AS completed_at', 'w.created AS work_order_created_at',
'w.updated AS work_order_updated_at', 'p.shop_name',
'p.payment_reason', 'p.parse_status', 'p.parse_error',
'p.work_order_create_time', 'p.work_order_update_time',
'w.businessFieldList AS business_field_list',
];
}
/** @param array<string,string> $filters */
private function applyFilters(Builder $query, array $filters): void
{
$from = $this->filterDateTime($filters['from'], false);
$to = $this->filterDateTime($filters['to'], true);
$query->where('w.completeTime', '>=', $from)
->where('w.completeTime', '<', $to);
if ($filters['work_order_no'] !== '') {
$query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]);
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("p.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
if ($filters['transaction_no'] !== '') {
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]);
}
if ($filters['online_order_no'] !== '') {
$query->where(function (Builder $onlineOrder) use ($filters): void {
$needle = $this->contains($filters['online_order_no']);
$onlineOrder->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$needle]);
});
}
if ($filters['status'] !== '') {
$query->where(function (Builder $status) use ($filters): void {
$statuses = $this->workOrderStatusesForFilter($filters['status']);
$status->whereIn('w.status', $statuses)
->orWhereIn('p.status', $statuses)
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]);
$paymentMethod = $this->paymentMethodForStatus($filters['status']);
if ($paymentMethod !== null || trim($filters['status']) === '已打款') {
$status->orWhere(function (Builder $payment) use ($paymentMethod): void {
$payment->whereIn('w.status', self::COMPLETED_WORK_ORDER_STATUSES);
if ($paymentMethod !== null) {
$this->applyPaymentMethodFilter($payment, $paymentMethod);
}
});
}
});
}
if ($filters['payment_reason'] !== '') {
$query->whereRaw("p.payment_reason LIKE ? ESCAPE '!'", [$this->contains($filters['payment_reason'])]);
}
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function decorate(array $row): array
{
$fields = $this->businessFields($row['business_field_list'] ?? null);
$sourceWorkOrderStatus = $this->firstFilled(
$row['work_order_status'] ?? null,
$row['mapped_status'] ?? null,
);
$row['work_order_status'] = $this->workOrderStatusLabel($sourceWorkOrderStatus);
$row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null);
$row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款';
$paymentMethod = $this->field($fields, [
'打款方式', '收款方式', '支付方式', 'SELECTField1413856620250724113227',
]);
$row['payment_status'] = $this->firstFilled(
$this->field($fields, ['打款状态', '支付打款状态', '付款状态']),
$this->completedPaymentStatus($sourceWorkOrderStatus, $paymentMethod),
'未提供',
);
$row['alipay_batch_no'] = $this->field($fields, [
'支付宝批次号', '支付宝打款批次号', '打款批次号', '批次号', 'alipayBatchNo', 'batchNo',
]);
$row['payment_amount'] = $this->money($this->field($fields, [
'打款金额', '付款金额', '小额打款金额', 'amountField',
]));
$row['online_order_no'] = $this->firstFilled(
$this->field($fields, ['线上订单号', '线上单号', '订单号', 'orderField']),
$row['mapped_online_order_no'] ?? null,
);
$row['buyer_account'] = $this->field($fields, [
'买家账号', '买家帐号', '买家', 'buyerIdField',
]);
$row['alipay_account'] = $this->field($fields, [
'支付宝账号', '支付宝帐号', 'alipayAccountField',
]);
$row['alipay_name'] = $this->field($fields, [
'支付宝姓名', '支付宝实名', '支付宝账户姓名', 'alipayNameField',
]);
$row['product_code'] = $this->field($fields, [
'商品编码', '商品ID', '商品编号', 'iid',
]);
$row['sku_code'] = $this->field($fields, [
'SKU编码', 'SKU', '规格编码', 'skuId',
]);
$row['platform'] = $this->field($fields, ['平台', '平台名称', 'platform']);
$row['actual_paid_amount'] = $this->money($this->field($fields, [
'实际已付金额', '实付金额', '买家实付金额', 'actualPaidAmountField',
]));
$row['payment_time'] = $this->field($fields, [
'支付时间', '打款时间', '付款时间', 'payDateField',
]);
unset(
$row['business_field_list'],
$row['mapped_online_order_no'],
$row['mapped_status'],
$row['mapped_title'],
);
return $row;
}
/** @return array<string,string> */
private function businessFields(mixed $json): array
{
if (! is_string($json) || trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
$fields = [];
$this->flattenFields($decoded, $fields);
return $fields;
}
/** @param array<string,string> $fields */
private function flattenFields(mixed $node, array &$fields): void
{
if (! is_array($node)) {
return;
}
$label = $this->arrayValue($node, [
'fieldName', 'field_name', 'label', 'name', 'title', 'fieldId', 'field_id',
]);
$value = $this->arrayValue($node, [
'fieldValue', 'field_value', 'valueLabel', 'value_label', 'value', 'text',
]);
if ($label !== '' && $value !== '') {
$fields[$this->normalizeLabel($label)] = $value;
}
foreach ($node as $key => $child) {
if (is_string($key) && ! is_array($child) && ! is_object($child)) {
$text = trim((string) $child);
if ($text !== '') {
$fields[$this->normalizeLabel($key)] ??= $text;
}
}
if (is_array($child)) {
$this->flattenFields($child, $fields);
}
}
}
/** @param array<mixed> $row @param list<string> $keys */
private function arrayValue(array $row, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $row) && is_scalar($row[$key])) {
$value = trim((string) $row[$key]);
if ($value !== '') {
return $value;
}
}
}
return '';
}
/** @param array<string,string> $fields @param list<string> $labels */
private function field(array $fields, array $labels): string
{
foreach ($labels as $label) {
$value = $fields[$this->normalizeLabel($label)] ?? '';
if ($value !== '') {
return $value;
}
}
return '';
}
private function normalizeLabel(string $label): string
{
return mb_strtolower((string) preg_replace('/[\s::()()_\-]+/u', '', trim($label)));
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (preg_match('/^(-?)(\d+)(?:\.(\d{1,6}))?$/', $normalized, $matches) !== 1) {
return null;
}
$fraction = str_pad($matches[3] ?? '', 3, '0');
$cents = ((int) $matches[2] * 100) + (int) substr($fraction, 0, 2);
if ((int) $fraction[2] >= 5) {
$cents++;
}
$sign = $matches[1] === '-' && $cents !== 0 ? '-' : '';
return $sign . intdiv($cents, 100) . '.' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT);
}
/** @param list<mixed> $amounts */
private function sumMoney(array $amounts): string
{
$cents = 0;
foreach ($amounts as $amount) {
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) {
continue;
}
$value = ((int) $matches[2] * 100) + (int) $matches[3];
$cents += $matches[1] === '-' ? -$value : $value;
}
$sign = $cents < 0 ? '-' : '';
$absolute = abs($cents);
return $sign . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT);
}
private function firstFilled(mixed ...$values): string
{
foreach ($values as $value) {
$text = trim((string) $value);
if ($text !== '') {
return $text;
}
}
return '';
}
private function workOrderStatusLabel(string $status): string
{
$code = strtoupper(trim($status));
return self::WORK_ORDER_STATUS_LABELS[$code] ?? $status;
}
private function completedPaymentStatus(string $workOrderStatus, string $paymentMethod): string
{
$normalizedStatus = strtoupper(trim($workOrderStatus));
if (! in_array($normalizedStatus, self::COMPLETED_WORK_ORDER_STATUSES, true)) {
return '';
}
if (str_contains($paymentMethod, '支付宝')) {
return '支付宝已打款';
}
if (str_contains($paymentMethod, '银行卡')) {
return '银行卡已打款';
}
return '已打款';
}
private function paymentMethodForStatus(string $status): ?string
{
return match (trim($status)) {
'支付宝已打款' => '支付宝账号',
'银行卡已打款' => '银行卡',
default => null,
};
}
private function applyPaymentMethodFilter(Builder $query, string $paymentMethod): void
{
$query->where(function (Builder $fields) use ($paymentMethod): void {
foreach (['value', 'fieldValue', 'valueLabel'] as $key) {
$fields->orWhereRaw(
"w.businessFieldList LIKE ? ESCAPE '!'",
[$this->contains(sprintf('"%s":"%s"', $key, $paymentMethod))],
);
}
});
}
/** @return list<string> */
private function workOrderStatusesForFilter(string $status): array
{
$status = trim($status);
$code = array_search($status, self::WORK_ORDER_STATUS_LABELS, true);
return array_values(array_unique(array_filter([
$status,
is_string($code) ? $code : null,
], static fn (?string $value): bool => $value !== null && $value !== '')));
}
/**
* 将前端传来的日期或日期时间字符串统一为 SQL 比较用的日期时间。
* 仅日期时:from 取 00:00:00,to 取次日 00:00:00(半开区间)。
* 含时间时:from 取原值,to 取下一秒(半开区间,等价于包含该秒)。
*/
private function filterDateTime(string $value, bool $isUpperBound): string
{
$value = trim($value);
$hasTime = str_contains($value, ':') || str_contains($value, 'T');
if (! $hasTime) {
return $isUpperBound
? Carbon::parse($value)->addDay()->format('Y-m-d 00:00:00')
: $value . ' 00:00:00';
}
$carbon = Carbon::parse($value);
return $isUpperBound
? $carbon->clone()->addSecond()->format('Y-m-d H:i:s')
: $carbon->format('Y-m-d H:i:s');
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
private function dataset(string $key): BusinessMirrorDataset
{
return $this->datasets->get($key);
}
}