CaiWuXiTong/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php
yueyang 4ef1208b2f 聚水潭小额打款工单页面 (#102)
## 任务占用与边界

- 任务名称:聚水潭聚工单-协同工单-打款工单-小额打款页面
- 实际开发者:yueyang
- 任务来源/团队分工:退款数据子页面接入
- 目标结果:在“退款”下新增小额打款子页面,读取共享业务镜像,支持分页、筛选、汇总、导出和同步状态提示。
- 预计修改路径:Pages Controller、business_navigation、upstream_mirrors、镜像 Repository、Blade 页面、admin 路由、测试、镜像清单文档。
- 将写入的公共契约:页面 slug、管理路由、镜像数据集注册(jiansun 两张表)、Mirror Repository。
- 仅复用、明确不修改的公共契约:现有共享镜像连接、同步框架、权限校验、导航结构和通用分页/导出模式。
- 数据源、表/视图和字段:jiansun.dbo.jst_workOrderInfo、jiansun.dbo.jst_small_payment_order_map;固定小额打款工单类型 82D94F5C54544EE3833CD8A3AB8B9F73。
- 上游 source_key / scope:source_key=jiansun;沿用团队共享连接 scope。
- 明确不修改的范围:不执行共享数据库迁移、不执行首次全量同步、不修改 .env/APP_KEY、不配置 operations_replica。

## 开工碰撞检查

- [x] 已执行团队预检并核对 origin/main 与远程任务分支
- [x] 已搜索主线既有页面、接口、Repository 和数据查询
- [x] 已区分将写入与仅复用的公共契约
- [x] 未发现实际写入重叠

## 实际改动

- 新增退款“小额打款”页面及导航入口。
- 新增 SmallPaymentWorkOrderMirrorRepository:共享镜像分页、稳定排序、筛选、金额汇总、导出行和状态判断。
- 注册 jst_workOrderInfo 与 jst_small_payment_order_map 镜像数据集,并补充真实 businessFieldList 解析字段。
- 增加页面、Repository、导航和同步器相关测试。

## 验证证据

- 相关测试:19 项通过,143 个断言通过。
- Blade 缓存、PHP 语法检查、git diff --check 通过。
- 实际本地页面已打开,导航显示“小额打款”;首次同步未执行时页面明确显示“业务镜像尚未建立”。
- route:cache 受项目已有重复 livewire.update 路由影响失败,与本次改动无关;普通路由列表和视图缓存正常。

## 风险检查

- [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [x] 不涉及共享数据库迁移或生产数据写入
- [x] 前端复用现有 VIP 黑金设计令牌和共享页面结构

## 合并前复查

- [ ] PR 不再是 WIP,需在完成最终自审和测试后移除 WIP 前缀。
- [ ] Codex 已用 origin/main...任务分支复核完整 diff。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/102
2026-09-04 13:54:17 +08:00

453 lines
17 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']],
self::PAYMENT_MAP_DATASET => [['woTypeId', 'work_order_create_time', 'woNo']],
];
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}
*/
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('p.work_order_create_time')
->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')),
];
}
/**
* @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('p.work_order_create_time')
->orderByDesc('p.woNo')
->forPage($page, $chunkSize)
->get();
foreach ($rows as $row) {
yield $this->decorate((array) $row);
}
$page++;
} while ($rows->count() === $chunkSize);
}
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();
$latestWorkOrders = $this->connection->table($workOrders . ' AS work_order_versions')
->selectRaw('woNo, MAX(Id) AS Id')
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->groupBy('woNo');
return $this->connection->table($paymentMap . ' AS p')
->joinSub($latestWorkOrders, 'work_order_versions', static function ($join): void {
$join->on('work_order_versions.woNo', '=', 'p.woNo');
})
->join($workOrders . ' AS w', 'w.Id', '=', 'work_order_versions.Id')
->where('p.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID);
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'w.Id AS source_id', 'p.woNo AS work_order_no',
'p.transaction_no AS alipay_batch_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
{
$query->where('p.work_order_create_time', '>=', $filters['from'] . ' 00:00:00')
->where('p.work_order_create_time', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
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("p.transaction_no LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]);
}
if ($filters['online_order_no'] !== '') {
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['online_order_no'])]);
}
if ($filters['status'] !== '') {
$query->where(function (Builder $status) use ($filters): void {
$status->where('w.status', $filters['status'])
->orWhere('p.status', $filters['status'])
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]);
});
}
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);
$row['work_order_status'] = $this->firstFilled($row['work_order_status'] ?? null, $row['mapped_status'] ?? null);
$row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null);
$row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款';
$row['payment_status'] = $this->firstFilled(
$this->field($fields, ['打款状态', '支付打款状态', '付款状态']),
$row['mapped_status'] ?? null,
);
$row['payment_amount'] = $this->money($this->field($fields, [
'打款金额', '付款金额', '小额打款金额', 'amountField',
]));
$row['online_order_no'] = $this->field($fields, [
'线上订单号', '线上单号', '订单号', 'orderField',
]);
$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_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 contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
private function dataset(string $key): BusinessMirrorDataset
{
return $this->datasets->get($key);
}
}