808 lines
30 KiB
PHP
808 lines
30 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Cache;
|
||
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 QUERY_TIMEOUT_SECONDS = 30;
|
||
|
||
private const SUMMARY_CACHE_SECONDS = 900;
|
||
|
||
private const CACHE_LOCK_SECONDS = 180;
|
||
|
||
private const CACHE_WAIT_SECONDS = 10;
|
||
|
||
private const FILTER_OPTIONS_CACHE_SECONDS = 900;
|
||
|
||
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', '已完成', '已确认'];
|
||
|
||
/** 打款金额在 businessFieldList 中的候选字段名(按优先级) */
|
||
private const PAYMENT_AMOUNT_FIELDS = ['打款金额', '付款金额', '小额打款金额', 'amountField'];
|
||
|
||
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);
|
||
|
||
$summary = $this->filteredSummary($filters);
|
||
$rows = $this->boundedGet($query
|
||
->select($this->selectColumns())
|
||
->orderByDesc('w.completeTime')
|
||
->orderByDesc('p.woNo')
|
||
->forPage($page, $perPage))
|
||
->map(fn (object $row): array => $this->decorate((array) $row))
|
||
->all();
|
||
|
||
return [
|
||
'data' => $rows,
|
||
'total' => $summary['count'],
|
||
'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')),
|
||
'filtered_payment_amount' => $summary['payment_amount'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 统计当前筛选条件下全部记录的打款金额合计。
|
||
*
|
||
* 只取金额所需字段,按 Id 做键集分块批量读取:不使用逐行游标(本地/远端库时每次取行都是一次网络往返),
|
||
* 也不复用导出用的分页排序查询(会为大结果集重复执行连接与排序)。
|
||
*
|
||
* @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, int $chunkSize = 1000): string
|
||
{
|
||
return $this->filteredSummary($filters, $chunkSize)['payment_amount'];
|
||
}
|
||
|
||
/**
|
||
* @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{count:int,payment_amount:string}
|
||
*/
|
||
private function filteredSummary(array $filters, int $chunkSize = 1000): array
|
||
{
|
||
$this->ensureReady();
|
||
|
||
$key = $this->summaryCacheKey($filters);
|
||
|
||
return $this->rememberArray(
|
||
$key,
|
||
self::SUMMARY_CACHE_SECONDS,
|
||
fn (): array => $this->calculateFilteredSummary($filters, $chunkSize),
|
||
'small_payment_work_order_summary_busy',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @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{count:int,payment_amount:string}
|
||
*/
|
||
private function calculateFilteredSummary(array $filters, int $chunkSize): array
|
||
{
|
||
$this->ensureReady();
|
||
|
||
$query = $this->baseQuery();
|
||
$this->applyFilters($query, $filters);
|
||
|
||
$chunkSize = min(max($chunkSize, 100), 5000);
|
||
$cents = 0;
|
||
$count = 0;
|
||
$lastId = null;
|
||
|
||
do {
|
||
$chunk = $this->boundedGet((clone $query)
|
||
->select(['w.Id AS source_id', 'w.businessFieldList'])
|
||
->when($lastId !== null, static fn (Builder $q): Builder => $q->where('w.Id', '>', $lastId))
|
||
->orderBy('w.Id')
|
||
->limit($chunkSize));
|
||
|
||
foreach ($chunk as $row) {
|
||
$cents += $this->centsFromMoney($this->paymentAmountOf($row->businessFieldList ?? null));
|
||
}
|
||
|
||
$count += $chunk->count();
|
||
$lastId = $chunk->isEmpty() ? null : $chunk->last()->source_id;
|
||
} while ($chunk->count() === $chunkSize);
|
||
|
||
return ['count' => $count, 'payment_amount' => $this->formatCents($cents)];
|
||
}
|
||
|
||
private function paymentAmountOf(mixed $businessFieldList): ?string
|
||
{
|
||
return $this->money($this->field(
|
||
$this->businessFields($businessFieldList),
|
||
self::PAYMENT_AMOUNT_FIELDS,
|
||
));
|
||
}
|
||
|
||
/**
|
||
* @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
|
||
{
|
||
return $this->filterOptions()['shops'];
|
||
}
|
||
|
||
/**
|
||
* 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function paymentReasons(): array
|
||
{
|
||
return $this->filterOptions()['payment_reasons'];
|
||
}
|
||
|
||
/** @return array{shops:list<string>,payment_reasons:list<string>} */
|
||
public function filterOptions(): array
|
||
{
|
||
if (! $this->available() || ! $this->ready() || ! $this->queryReady()) {
|
||
return ['shops' => [], 'payment_reasons' => []];
|
||
}
|
||
|
||
$table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
|
||
|
||
try {
|
||
return $this->rememberArray(
|
||
'small-payment-work-orders:filter-options:v1',
|
||
self::FILTER_OPTIONS_CACHE_SECONDS,
|
||
function () use ($table): array {
|
||
$shops = $this->boundedGet($this->connection->table($table)
|
||
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
|
||
->whereNotNull('shop_name')
|
||
->where('shop_name', '<>', '')
|
||
->distinct()
|
||
->select('shop_name')
|
||
->orderBy('shop_name')
|
||
->limit(1000))
|
||
->pluck('shop_name')
|
||
->map(static fn (mixed $name): string => (string) $name)
|
||
->all();
|
||
$reasons = $this->boundedGet($this->connection->table($table)
|
||
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
|
||
->whereNotNull('payment_reason')
|
||
->where('payment_reason', '<>', '')
|
||
->distinct()
|
||
->select('payment_reason')
|
||
->orderBy('payment_reason')
|
||
->limit(200))
|
||
->pluck('payment_reason')
|
||
->map(static fn (mixed $reason): string => (string) $reason)
|
||
->all();
|
||
|
||
return ['shops' => $shops, 'payment_reasons' => $reasons];
|
||
},
|
||
'small_payment_work_order_filter_options_busy',
|
||
);
|
||
} catch (Throwable) {
|
||
return ['shops' => [], 'payment_reasons' => []];
|
||
}
|
||
}
|
||
|
||
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, self::PAYMENT_AMOUNT_FIELDS));
|
||
$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) {
|
||
$cents += $this->centsFromMoney($amount);
|
||
}
|
||
|
||
return $this->formatCents($cents);
|
||
}
|
||
|
||
/** 将 '123.45' / '-123.45' 形式的金额字符串转成分(无效值计 0)。 */
|
||
private function centsFromMoney(mixed $amount): int
|
||
{
|
||
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) {
|
||
return 0;
|
||
}
|
||
|
||
$value = ((int) $matches[2] * 100) + (int) $matches[3];
|
||
|
||
return $matches[1] === '-' ? -$value : $value;
|
||
}
|
||
|
||
private function formatCents(int $cents): string
|
||
{
|
||
$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 !== '')));
|
||
}
|
||
|
||
/** @param array<string,string> $filters */
|
||
private function summaryCacheKey(array $filters): string
|
||
{
|
||
$normalized = array_map(static fn (mixed $value): string => trim((string) $value), $filters);
|
||
ksort($normalized);
|
||
|
||
return 'small-payment-work-orders:summary:v1:' . hash(
|
||
'sha256',
|
||
(string) json_encode($normalized, JSON_UNESCAPED_UNICODE),
|
||
);
|
||
}
|
||
|
||
/** @param callable():array<string,mixed> $resolver @return array<string,mixed> */
|
||
private function rememberArray(string $key, int $seconds, callable $resolver, string $busyMessage): array
|
||
{
|
||
$cached = Cache::get($key);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
|
||
try {
|
||
return Cache::lock($key . ':lock', self::CACHE_LOCK_SECONDS)
|
||
->block(self::CACHE_WAIT_SECONDS, function () use ($key, $seconds, $resolver): array {
|
||
$cached = Cache::get($key);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
|
||
$resolved = $resolver();
|
||
Cache::put($key, $resolved, $seconds);
|
||
|
||
return $resolved;
|
||
});
|
||
} catch (LockTimeoutException $exception) {
|
||
$cached = Cache::get($key);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
|
||
throw new RuntimeException($busyMessage, 0, $exception);
|
||
}
|
||
}
|
||
|
||
/** @return Collection<int,object> */
|
||
private function boundedGet(Builder $query): Collection
|
||
{
|
||
$connection = $query->getConnection();
|
||
if ($connection->getDriverName() !== 'mysql') {
|
||
return $query->get();
|
||
}
|
||
|
||
return collect($connection->select(
|
||
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
|
||
$query->getBindings(),
|
||
));
|
||
}
|
||
|
||
/**
|
||
* 将前端传来的日期或日期时间字符串统一为 SQL 比较用的日期时间。
|
||
* 仅日期时:from 取 00:00:00,to 取次日 00:00:00(半开区间)。
|
||
* 含分钟时:to 取下一分钟;含秒时: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);
|
||
|
||
if (! $isUpperBound) {
|
||
return $carbon->format('Y-m-d H:i:s');
|
||
}
|
||
|
||
return preg_match('/:\d{2}:\d{2}$/', str_replace('T', ' ', $value)) === 1
|
||
? $carbon->clone()->addSecond()->format('Y-m-d H:i:s')
|
||
: $carbon->clone()->addMinute()->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);
|
||
}
|
||
}
|