## 任务占用与边界 - 任务名称:聚水潭聚工单-协同工单-打款工单-小额打款页面 - 实际开发者: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
453 lines
17 KiB
PHP
453 lines
17 KiB
PHP
<?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);
|
||
}
|
||
}
|