CaiWuXiTong/app/Business/UpstreamBusinessInformation.php
WuYongJie d48cb7d639 feat: 店铺维度报表条件查询与主体归属字段接入
实际修改:
- 店铺维度报表新增真实数据库条件查询:店铺字段走共享镜像 SQL
  WHERE,主体/能否变更/生效/失效走生命周期行级过滤(左闭右开)
- 店铺负责人/部门负责人/利润中心改为店铺账号 PostgreSQL 镜像值,
  按店铺名称关联,未匹配时回退经营分析副本库原值
- 能否变更按主表 platform 判定(唯品会MP/淘工厂=否,其余=是),
  筛选改为 是/否 下拉;默认生效时间 2026-07-01,失效未记录显示存续中
- 主体维度经营报表接入生命周期收入窗口与注册地址解析市字段
- 同步更新视图测试并重写 StoreReportRepositoryTest 匹配 SQLite 实现

验证:容器内 tests/Feature/Business + tests/Unit/EcommerceFinance +
tests/Unit/Business 共 324 passed / 4 skipped;真实数据端到端验证
(无筛选 1935 行、负责人/利润中心/失效区间筛选抽样全对)。
无新增数据库迁移,不涉及生产数据写入。
2026-09-06 19:27:51 +08:00

612 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 App\Business;
use Illuminate\Pagination\LengthAwarePaginator;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class UpstreamBusinessInformation
{
private const SOURCE = 'operations_replica';
public function __construct(
private readonly UpstreamQueryCacheFactory $caches,
private readonly BusinessInformationMirrorRepository $mirror,
private readonly InstallationDetailMirrorRepository $installationMirror,
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
private readonly ?StoreReportRepository $storeReports = null,
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
) {}
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),
'store-management/store-accounts' => $this->storeAccountPage($currentPage, $perPage),
'store-management/store-report' => $this->storeReportPage($currentPage, $perPage),
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
'financial-operations/installation-details' => $this->installationPage($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(),
],
);
}
private function installationPage(int $page, int $perPage): array
{
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
if (! $payload['ready']) {
throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步');
}
return ['data' => $payload['data'], 'total' => $payload['total']];
}
/**
* 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。
*
* 生产后台刷新器使用这个入口生成共享快照;页面仍然只能通过
* 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->page(
'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,
registeredCapitalVaildTime AS subscribed_at,
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->page(
'store-information.read',
$page,
$perPage,
fn () => $this->snapshot('store-information.read', [
'page' => $page,
'perPage' => $perPage,
]),
);
}
private function storeReportPage(int $page, int $perPage): array
{
$filters = StoreReportRepository::requestFilters();
$rows = $this->storeReportRows($filters);
$offset = ($page - 1) * $perPage;
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
private function storeAccountPage(int $page, int $perPage): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate($page, $perPage);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。');
}
}
/** @param array<string,string> $filters @return array{data:array<int,array<string,mixed>>,total:int} */
private function allStores(array $filters = []): array
{
if ($this->mirror->ready('store-information.read')) {
$rows = $this->mirror->allStores($filters);
return ['data' => array_map(fn (array $row): array => $this->mapStore($row), $rows), 'total' => count($rows)];
}
$cached = $this->caches->forSource(self::SOURCE)->remember('store-information.read', ['all' => true],
fn () => $this->storeSnapshotAll(new UpstreamReadOnlyQuery(self::SOURCE)));
$rows = array_values(array_filter(
$cached['data'],
fn (array $row): bool => $this->storeMatchesFilters($row, $filters),
));
return ['data' => $rows, 'total' => count($rows)];
}
/**
* 镜像不可用时的兜底路径:在已映射的快照行上应用同样的店铺字段条件。
*
* @param array<string,mixed> $row
* @param array<string,string> $filters
*/
private function storeMatchesFilters(array $row, array $filters): bool
{
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'company_type'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected === '') {
continue;
}
// 页面“店铺ID(唯一值)”展示 ERP 店铺名称,筛选按同一列匹配。
$actual = $key === 'store_id'
? trim((string) ($row['erp_store_name'] ?? ''))
: trim((string) ($row[$key] ?? ''));
if ($actual !== $expected) {
return false;
}
}
$statusMap = ['0' => '正常', '3' => '关闭中', '6' => '已关闭'];
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '' && (string) ($row['store_status'] ?? '') !== ($statusMap[$status] ?? $status)) {
return false;
}
$from = trim((string) ($filters['opened_at_from'] ?? ''));
$to = trim((string) ($filters['opened_at_to'] ?? ''));
if ($from !== '' || $to !== '') {
$value = trim((string) ($row['opened_at'] ?? ''));
if ($value === '') {
return false;
}
if ($from !== '' && strcmp($value, $from) < 0) {
return false;
}
if ($to !== '' && strcmp($value, $to) >= 0) {
return false;
}
}
return true;
}
private function storeSnapshotAll(UpstreamReadOnlyQuery $query): array
{
$rows = $query->select(<<<SQL
SELECT s.Id AS id, s.sotreId AS store_id, s.openTime AS opened_at, s.renewalTime AS renewed_at,
s.erpShopName AS erp_store_name, s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name, c.businessLicenseType AS company_type,
s.trademarkNumber AS trademark_number, t.trademarkName AS trademark_name, s.platform AS platform,
s.responsibleDepartment AS profit_center, NULL AS store_manager, NULL AS department_manager,
s.shopState AS store_status, s.shopBankAccount AS withdrawal_account, s.shopType AS store_type, s.channelType AS channel_mode
FROM rpt.fys_shopInfo AS s
OUTER APPLY (SELECT TOP 1 businessLicenseName,businessLicenseType FROM rpt.fys_companyInfo WHERE unifiedSocialCreditCode=NULLIF(s.unifiedSocialCreditCode,'') ORDER BY Id DESC) c
OUTER APPLY (SELECT TOP 1 trademarkName FROM rpt.fys_TrademarkInfo WHERE trademarkNumber=NULLIF(s.trademarkNumber,'') AND isDelete=0 ORDER BY Id DESC) t
ORDER BY s.Id DESC
SQL);
$rows = array_map(fn (array $row): array => $this->mapStore($row), $rows);
return ['data' => $rows, 'total' => count($rows)];
}
private function subjectOperatingReportPage(int $page, int $perPage): array
{
$rows = $this->storeReportRows();
$entities = [];
foreach ($rows as $row) {
$entity = trim((string) ($row['entity'] ?? ''));
if ($entity === '') {
continue;
}
$entities[$entity] ??= [
'city' => null,
'entity' => $entity,
'company_type' => $row['company_type'] ?? null,
'rolling_12m_income' => null,
'can_change_entity' => null,
'plan' => null,
'new_entity' => null,
];
if (empty($entities[$entity]['company_type']) && ! empty($row['company_type'])) {
$entities[$entity]['company_type'] = $row['company_type'];
}
$this->sumNullable($entities[$entity], 'rolling_12m_income', $row['rolling_12m_income'] ?? null);
for ($month = 1; $month <= 12; $month++) {
$key = sprintf('income_%02d', $month);
$entities[$entity][$key] ??= null;
$this->sumNullable($entities[$entity], $key, $row[$key] ?? null);
}
}
$rows = array_values($entities);
usort($rows, static fn (array $left, array $right): int => strcmp($left['entity'], $right['entity']));
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($rows, $offset, $perPage);
$details = $this->companyDetails(array_column($pageRows, 'entity'));
foreach ($pageRows as &$row) {
$row['company_details'] = $details[$row['entity']] ?? [];
$row['city'] = $this->cityFromDetails($row['company_details']);
}
unset($row);
return ['data' => $pageRows, 'total' => count($rows)];
}
private function sumNullable(array &$row, string $key, mixed $value): void
{
if ($value === null || $value === '') {
return;
}
$row[$key] = (float) ($row[$key] ?? 0) + (float) $value;
}
/**
* 「市」来自主体基础资料的注册地址:取第一条能解析出地级市的记录
* (详情按 Id 倒序,即优先最新资料);资料缺失或地址无法识别时返回 null,
* 页面按统一缺失值展示。
*
* @param array<int,array<string,mixed>> $details
*/
private function cityFromDetails(array $details): ?string
{
foreach ($details as $detail) {
$city = RegisteredAddressCity::parse($detail['registered_address'] ?? null);
if ($city !== null) {
return $city;
}
}
return null;
}
/** @return array<string,array<int,array<string,mixed>>> */
private function companyDetails(array $names): array
{
$names = array_values(array_unique(array_filter(array_map('trim', $names))));
if ($names === []) {
return [];
}
try {
if ($this->mirror->ready('company-information.read')) {
$rows = $this->mirror->companiesByNames($names);
} else {
$placeholders = implode(',', array_fill(0, count($names), '?'));
$rawRows = (new UpstreamReadOnlyQuery(self::SOURCE))->select(
"SELECT * FROM rpt.fys_companyInfo WHERE businessLicenseName IN ($placeholders) ORDER BY Id DESC",
$names,
);
$rows = [];
foreach ($rawRows as $rawRow) {
$rows[(string) ($rawRow['businessLicenseName'] ?? '')][] = $rawRow;
}
}
} catch (Throwable) {
return [];
}
$result = [];
foreach ($rows as $name => $companyRows) {
$result[$name] = array_map(fn (array $row): array => $this->mapCompanyDetail($row), $companyRows);
}
return $result;
}
private function mapCompanyDetail(array $row): array
{
return $this->mapCompany([
'id' => $row['Id'] ?? $row['id'] ?? null,
'unified_social_credit_code' => $row['unifiedSocialCreditCode'] ?? $row['unified_social_credit_code'] ?? null,
'company_name' => $row['businessLicenseName'] ?? $row['company_name'] ?? null,
'subscribed_capital' => $row['registeredCapital'] ?? $row['subscribed_capital'] ?? null,
'company_type' => $row['businessLicenseType'] ?? $row['company_type'] ?? null,
'establishment_date' => $row['IncorporationDate'] ?? $row['establishment_date'] ?? null,
'legal_representative' => $row['legalRepresentative'] ?? $row['legal_representative'] ?? null,
'registered_address' => $row['registeredAddress'] ?? $row['registered_address'] ?? null,
'business_scope' => $row['businessScope'] ?? $row['business_scope'] ?? null,
'basic_deposit_account_number' => $row['accountSerialNumber'] ?? $row['basic_deposit_account_number'] ?? null,
'subscribed_at' => $row['registeredCapitalVaildTime'] ?? $row['subscribed_at'] ?? null,
'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null,
'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null,
'supervisor' => $row['supervisor'] ?? null,
'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
'shareholders' => $row['shareholders'] ?? null,
]);
}
/** @param array<string,string> $filters @return array<int,array<string,mixed>> */
private function storeReportRows(array $filters = []): array
{
// 店铺负责人、部门负责人、利润中心展示店铺账号镜像(PostgreSQL)中的值;
// 这三个字段的筛选条件随行级过滤按覆盖后的值匹配,不再走镜像 SQL。
$ownership = $this->storeOwnership();
$storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center']));
$stores = $this->allStores($storeFilters);
foreach ($stores['data'] as &$store) {
$match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null;
if ($match === null) {
continue;
}
// 未匹配到账号或字段为空时保留经营分析副本库原值。
foreach ([
'store_manager' => $match['operation_owner'],
'department_manager' => $match['operation_director'],
'profit_center' => $match['profit_center'],
] as $field => $value) {
if ($value !== '') {
$store[$field] = $value;
}
}
}
unset($store);
try {
return ($this->storeReports ?? app(StoreReportRepository::class))->rows($stores['data'], null, $filters);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺报表数据暂时无法读取,请稍后重试。');
}
}
/** @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}> */
private function storeOwnership(): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName();
} catch (\RuntimeException | \InvalidArgumentException $exception) {
// 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。
return [];
}
}
private function storeSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
{
$offset = ($page - 1) * $perPage;
$rows = $query->select(<<<SQL
SELECT
s.Id AS id,
s.sotreId AS store_id,
s.openTime AS opened_at,
s.renewalTime AS renewed_at,
s.erpShopName AS erp_store_name,
s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name,
c.businessLicenseType AS company_type,
s.trademarkNumber AS trademark_number,
t.trademarkName AS trademark_name,
s.platform AS platform,
s.responsibleDepartment AS profit_center,
s.shopHead AS store_manager,
s.customerDeptManager AS department_manager,
s.shopState AS store_status,
s.shopBankAccount AS withdrawal_account,
s.shopType AS store_type,
s.channelType AS channel_mode
FROM rpt.fys_shopInfo AS s
OUTER APPLY (
SELECT TOP 1 company.businessLicenseName, company.businessLicenseType
FROM rpt.fys_companyInfo AS company
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
ORDER BY company.Id DESC
) AS c
OUTER APPLY (
SELECT TOP 1 trademark.trademarkName
FROM rpt.fys_TrademarkInfo AS trademark
WHERE trademark.trademarkNumber = NULLIF(s.trademarkNumber, '')
AND trademark.isDelete = 0
ORDER BY trademark.Id DESC
) AS t
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->page(
'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,
trademarkNumber AS trademark_number,
trademarkName AS trademark_name,
internationalClassification AS trademark_type,
trademarkStatus AS trademark_status,
registrationDate AS registration_date,
termOfValidity AS expiry_date,
approvedCommodities AS approved_goods,
transferenceUser AS final_holder,
registrationAddress AS registered_address,
custodyDepartment AS custody_department,
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 page(string $scope, int $page, int $perPage, callable $resolver): array
{
if ($this->mirror->ready($scope)) {
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage));
}
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
&& ! config('upstreams.local_configuration.enabled', false)) {
throw new ServiceUnavailableHttpException(null, '当前仍使用本机临时引导库。请在“系统设置 → 数据库管理 → 系统数据库”连接团队共享数据库,然后重新登录;无需另配上游 SQL Server。');
}
$cache = $this->caches->forSource(self::SOURCE);
return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) {
return $resolver(new UpstreamReadOnlyQuery(self::SOURCE));
});
}
/** @param array{data: array<int,array<string,mixed>>, total: int} $payload */
private function normalize(string $scope, array $payload): array
{
$mapper = match ($scope) {
'company-information.read' => fn (array $row): array => $this->mapCompany($row),
'store-information.read' => fn (array $row): array => $this->mapStore($row),
'trademark-information.read' => fn (array $row): array => $this->mapTrademark($row),
default => throw new InvalidArgumentException('未知的经营分析副本库资料类型:' . $scope),
};
return [
'data' => array_map($mapper, $payload['data']),
'total' => (int) $payload['total'],
];
}
private function mapCompany(array $row): array
{
$row = $this->toUtf8($row);
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
$row['subscribed_at'] = $this->date($row['subscribed_at'] ?? 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['renewed_at'] = $this->date($row['renewed_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);
}
}