## 任务占用与边界
- 任务名称:修复本地经营信息直读分页参数丢失
- 实际开发者:WuYongJie
- 目标结果:公司、店铺、商标信息页面在本地直读模式下不再因分页参数丢失返回 500。
- 实际修改路径:\`app/Business/UpstreamBusinessInformation.php\`;\`tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php\`
- 上游 \`source_key\` / \`scope\`:\`operations_replica\`;\`company-information.read\`、\`store-information.read\`、\`trademark-information.read\`
- 明确不修改:上游连接资料、查询范围、共享快照刷新器、数据库迁移、生产配置、页面视觉和部署。
## 开工与合并前碰撞检查
- [x] 已在开工和合并前分别执行 \`scripts/codex-task-preflight.ps1\`
- [x] 已检查最新 \`origin/main\` 与全部远程任务分支
- [x] 无目标路径或公共契约重叠;历史共享库验证分支仅涉及其他测试文件
## 实际改动
- 将三个本地页面 resolver 中只写在 \`compact()\` 字符串里的分页变量,改为显式数组参数,使 PHP 箭头函数正确捕获 \`$page\`、\`$perPage\`。
- 新增回归测试:实际执行三个页面的 resolver,并断言固定快照收到正确分页参数。
- 无新增 scope、接口、权限、表结构、查询字段或缓存策略。
## 验证证据
- [x] PHP 语法检查:两个改动文件通过。
- [x] 相关单元测试:19 passed,57 assertions。
- [x] 受影响管理页面功能测试:4 passed,270 assertions。
- [x] 对比 \`origin/main...任务分支\` 已逐文件复查:仅 2 个目标文件,+88 / -3;无敏感文件或无关改动。
- [x] 同类扫描:三个错误 resolver 已全部改为显式参数;保留的 \`compact('page', 'perPage')\` 位于普通闭包中,变量已通过 \`use\` 正确捕获。
## 风险与回滚
- [x] 不涉及金额、权限、审批、数据库迁移、生产配置、数据修复或部署。
- [x] 未提交 \`.env\`、密码、Token、私钥、真实财务数据、生产日志或构建产物。
- [x] 无前端改动,\`DESIGN.md\` 不适用。
- 回滚方式:回退本次 Squash 合并提交即可;不会影响生产共享快照路径,因为生产不启用本地直读模式。
## 合并前复查
- [x] PR 已移除 \`WIP:\`,测试与自审已补齐。
- [x] 当前任务 Codex 已按完整 diff 自审,通过后执行 Squash merge、核验远程 \`main\` 并清理任务分支。
Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/10
265 lines
8.6 KiB
PHP
265 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Business;
|
|
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use InvalidArgumentException;
|
|
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
|
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
|
|
|
class UpstreamBusinessInformation
|
|
{
|
|
private const SOURCE = 'operations_replica';
|
|
|
|
public function __construct(private readonly UpstreamQueryCacheFactory $caches)
|
|
{
|
|
}
|
|
|
|
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
|
|
{
|
|
$currentPage = max((int) request('page', 1), 1);
|
|
$perPage = min(max($perPage, 1), 100);
|
|
$key = "{$section}/{$page}";
|
|
|
|
$payload = match ($key) {
|
|
'company-management/basic-information' => $this->companyPage($currentPage, $perPage),
|
|
'store-management/basic-information' => $this->storePage($currentPage, $perPage),
|
|
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
|
|
default => ['data' => [], 'total' => 0],
|
|
};
|
|
|
|
return new LengthAwarePaginator(
|
|
array_map(static fn (array $row) => (object) $row, $payload['data']),
|
|
(int) $payload['total'],
|
|
$perPage,
|
|
$currentPage,
|
|
[
|
|
'path' => request()->url(),
|
|
'query' => request()->query(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。
|
|
*
|
|
* 生产后台刷新器使用这个入口生成共享快照;页面仍然只能通过
|
|
* UpstreamQueryCache 读取快照,不能绕过缓存直接访问上游。
|
|
*
|
|
* @return array{data: array<int,array<string,mixed>>, total: int}
|
|
*/
|
|
public function snapshot(string $scope, array $parameters): array
|
|
{
|
|
$page = max((int) ($parameters['page'] ?? 1), 1);
|
|
$perPage = min(max((int) ($parameters['perPage'] ?? 50), 1), 100);
|
|
$query = new UpstreamReadOnlyQuery(self::SOURCE);
|
|
|
|
return match ($scope) {
|
|
'company-information.read' => $this->companySnapshot($query, $page, $perPage),
|
|
'store-information.read' => $this->storeSnapshot($query, $page, $perPage),
|
|
'trademark-information.read' => $this->trademarkSnapshot($query, $page, $perPage),
|
|
default => throw new InvalidArgumentException('未知的经营分析副本库快照类型:' . $scope),
|
|
};
|
|
}
|
|
|
|
private function companyPage(int $page, int $perPage): array
|
|
{
|
|
return $this->remember(
|
|
'company-information.read',
|
|
$page,
|
|
$perPage,
|
|
fn () => $this->snapshot('company-information.read', [
|
|
'page' => $page,
|
|
'perPage' => $perPage,
|
|
]),
|
|
);
|
|
}
|
|
|
|
private function companySnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
|
|
{
|
|
$offset = ($page - 1) * $perPage;
|
|
$rows = $query->select(<<<SQL
|
|
SELECT
|
|
Id AS id,
|
|
unifiedSocialCreditCode AS unified_social_credit_code,
|
|
businessLicenseName AS company_name,
|
|
registeredCapital AS subscribed_capital,
|
|
businessLicenseType AS company_type,
|
|
IncorporationDate AS establishment_date,
|
|
legalRepresentative AS legal_representative,
|
|
registeredAddress AS registered_address,
|
|
businessScope AS business_scope,
|
|
accountSerialNumber AS basic_deposit_account_number,
|
|
companyStatus AS company_status,
|
|
financialManager AS financial_officer,
|
|
supervisor AS supervisor,
|
|
taxCategoryAndType AS taxpayer_category,
|
|
NULL AS shareholders
|
|
FROM rpt.fys_companyInfo
|
|
ORDER BY Id DESC
|
|
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
|
|
SQL);
|
|
|
|
return [
|
|
'data' => array_map(fn (array $row) => $this->mapCompany($row), $rows),
|
|
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_companyInfo'),
|
|
];
|
|
}
|
|
|
|
private function storePage(int $page, int $perPage): array
|
|
{
|
|
return $this->remember(
|
|
'store-information.read',
|
|
$page,
|
|
$perPage,
|
|
fn () => $this->snapshot('store-information.read', [
|
|
'page' => $page,
|
|
'perPage' => $perPage,
|
|
]),
|
|
);
|
|
}
|
|
|
|
private function storeSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
|
|
{
|
|
$offset = ($page - 1) * $perPage;
|
|
$rows = $query->select(<<<SQL
|
|
SELECT
|
|
s.Id AS id,
|
|
s.openTime AS opened_at,
|
|
s.erpShopName AS erp_store_name,
|
|
s.unifiedSocialCreditCode AS unified_social_credit_code,
|
|
c.businessLicenseName AS company_name,
|
|
s.trademarkNumber AS trademark_number,
|
|
NULL AS trademark_name,
|
|
s.platform AS platform,
|
|
s.responsibleDepartment AS profit_center,
|
|
s.shopHead AS store_manager,
|
|
s.shopState AS store_status,
|
|
s.shopType AS store_type,
|
|
s.channelType AS channel_mode
|
|
FROM rpt.fys_shopInfo AS s
|
|
OUTER APPLY (
|
|
SELECT TOP 1 company.businessLicenseName
|
|
FROM rpt.fys_companyInfo AS company
|
|
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
|
|
ORDER BY company.Id DESC
|
|
) AS c
|
|
ORDER BY s.Id DESC
|
|
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
|
|
SQL);
|
|
|
|
return [
|
|
'data' => array_map(fn (array $row) => $this->mapStore($row), $rows),
|
|
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_shopInfo'),
|
|
];
|
|
}
|
|
|
|
private function trademarkPage(int $page, int $perPage): array
|
|
{
|
|
return $this->remember(
|
|
'trademark-information.read',
|
|
$page,
|
|
$perPage,
|
|
fn () => $this->snapshot('trademark-information.read', [
|
|
'page' => $page,
|
|
'perPage' => $perPage,
|
|
]),
|
|
);
|
|
}
|
|
|
|
private function trademarkSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
|
|
{
|
|
$offset = ($page - 1) * $perPage;
|
|
$rows = $query->select(<<<SQL
|
|
SELECT
|
|
Id AS id,
|
|
trademarkName AS trademark_name,
|
|
internationalClassification AS trademark_type,
|
|
trademarkStatus AS trademark_status,
|
|
registrationDate AS registration_date,
|
|
termOfValidity AS expiry_date,
|
|
remark AS notes
|
|
FROM rpt.fys_TrademarkInfo
|
|
WHERE isDelete = 0
|
|
ORDER BY Id DESC
|
|
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
|
|
SQL);
|
|
|
|
return [
|
|
'data' => array_map(fn (array $row) => $this->mapTrademark($row), $rows),
|
|
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_TrademarkInfo WHERE isDelete = 0'),
|
|
];
|
|
}
|
|
|
|
private function remember(string $scope, int $page, int $perPage, callable $resolver): array
|
|
{
|
|
$cache = $this->caches->forSource(self::SOURCE);
|
|
|
|
return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) {
|
|
return $resolver(new UpstreamReadOnlyQuery(self::SOURCE));
|
|
});
|
|
}
|
|
|
|
private function mapCompany(array $row): array
|
|
{
|
|
$row = $this->toUtf8($row);
|
|
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
|
|
$row['company_status'] = match ((string) ($row['company_status'] ?? '')) {
|
|
'0' => '正常',
|
|
'1' => '已注销',
|
|
default => $row['company_status'] ?? null,
|
|
};
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function mapStore(array $row): array
|
|
{
|
|
$row = $this->toUtf8($row);
|
|
$row['opened_at'] = $this->date($row['opened_at'] ?? null);
|
|
$row['store_status'] = match ((string) ($row['store_status'] ?? '')) {
|
|
'0' => '正常',
|
|
'3' => '关闭中',
|
|
'6' => '已关闭',
|
|
default => $row['store_status'] ?? null,
|
|
};
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function mapTrademark(array $row): array
|
|
{
|
|
$row = $this->toUtf8($row);
|
|
$row['registration_date'] = $this->date($row['registration_date'] ?? null);
|
|
$row['expiry_date'] = $this->date($row['expiry_date'] ?? null);
|
|
$row['trademark_status'] = match ((string) ($row['trademark_status'] ?? '')) {
|
|
'0' => '未启用',
|
|
'1' => '有效',
|
|
default => $row['trademark_status'] ?? null,
|
|
};
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function toUtf8(array $row): array
|
|
{
|
|
foreach ($row as &$value) {
|
|
if (is_string($value) && ! mb_check_encoding($value, 'UTF-8')) {
|
|
$value = mb_convert_encoding($value, 'UTF-8', 'GB18030');
|
|
}
|
|
}
|
|
unset($value);
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function date($value): ?string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
return substr((string) $value, 0, 10);
|
|
}
|
|
}
|