数据源:jiansun.dbo.fys_shopInfo_flow(申请单明细,4572 行,含店铺/平台/负责人/利润中心/商标/变更原因/关店原因等) + 中台库 dbo.flow_flow(按 flowId 关联审批状态与当前审批人),两个数据集已登记并完成首次全量。 - 新增 ShopInfoFlowMirrorRepository:左连接两套镜像,支持单据编号/申请人/申请日期/当前审批人/ ERP店铺名称/店铺运营负责人/利润中心/商标号等文本筛选,流程类型、审批状态、平台为可搜索多选。 - 审批状态文案取自 OA 前端字典:0暂存/1流程中/2拒绝/3完成/4撤回/5待签署/6已签署/7待审批; 流程类型:8店铺开店/9店铺变更/10店铺关店/12主体注销自动流程。 - 页面按目录表渲染(查询条件、筛选字段、导出),列与外部店铺申请页对齐。 - TableRegistry 登记为只读上游页,镜像未就绪时页面给提示而不是报错。
792 lines
32 KiB
PHP
792 lines
32 KiB
PHP
<?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),
|
||
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($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(),
|
||
],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
|
||
*
|
||
* @return array{data:list<array<string,mixed>>,total:int}
|
||
*/
|
||
private function shopInfoFlowPage(int $page, int $perPage): array
|
||
{
|
||
try {
|
||
return app(\Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository::class)->paginate(
|
||
$page,
|
||
$perPage,
|
||
$this->pageFilters('financial-risk-control', 'process-statistics'),
|
||
);
|
||
} catch (\RuntimeException $exception) {
|
||
report($exception);
|
||
|
||
throw new ServiceUnavailableHttpException(null, '店铺申请流程共享镜像尚未完成首次同步。');
|
||
}
|
||
}
|
||
|
||
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
|
||
{
|
||
$filters = $this->pageFilters('company-management', 'basic-information');
|
||
return $this->page(
|
||
'company-information.read',
|
||
$page,
|
||
$perPage,
|
||
$filters,
|
||
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
|
||
{
|
||
$filters = $this->pageFilters('store-management', 'basic-information');
|
||
return $this->page(
|
||
'store-information.read',
|
||
$page,
|
||
$perPage,
|
||
$filters,
|
||
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);
|
||
|
||
// 所有店铺该月都没有业绩的月份列默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
|
||
request()->attributes->set(
|
||
'store-report.default_hidden_columns',
|
||
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
|
||
);
|
||
|
||
$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,
|
||
$this->pageFilters('store-management', 'store-accounts'),
|
||
);
|
||
} 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_name', 'store_id', 'profit_center', 'store_manager', 'department_manager'] as $key) {
|
||
$expected = trim((string) ($filters[$key] ?? ''));
|
||
if ($expected === '') {
|
||
continue;
|
||
}
|
||
$actual = trim((string) ($row[$key] ?? ''));
|
||
if (! str_contains(mb_strtolower($actual), mb_strtolower($expected))) {
|
||
return false;
|
||
}
|
||
}
|
||
$companyType = trim((string) ($filters['company_type'] ?? ''));
|
||
if ($companyType !== '' && trim((string) ($row['company_type'] ?? '')) !== $companyType) {
|
||
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.shopName AS 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
|
||
{
|
||
$filters = $this->pageFilters('company-management', 'subject-operating-report');
|
||
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
|
||
$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);
|
||
$details = $this->companyDetails(array_column($rows, 'entity'));
|
||
foreach ($rows as &$row) {
|
||
$row['company_details'] = $details[$row['entity']] ?? [];
|
||
$row['city'] = $this->cityFromDetails($row['company_details']);
|
||
}
|
||
unset($row);
|
||
|
||
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
|
||
request()->attributes->set(
|
||
'subject-operating-report.default_hidden_columns',
|
||
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
|
||
);
|
||
|
||
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
|
||
$this->sortSubjectRows($rows, $filters);
|
||
$offset = ($page - 1) * $perPage;
|
||
$pageRows = array_slice($rows, $offset, $perPage);
|
||
|
||
return ['data' => $pageRows, 'total' => count($rows)];
|
||
}
|
||
|
||
/**
|
||
* 没有数据的月份列:所有行该月都没有数值时返回该列名,页面据此默认隐藏。
|
||
*
|
||
* 收入(income_)以 null/空字符串表示无账单,金额 0 也算有账单;
|
||
* 销量(sales_)没有空值标记,数值 0 视为当月没有业绩。
|
||
*
|
||
* @param array<int,array<string,mixed>> $rows
|
||
* @return array<int,string>
|
||
*/
|
||
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
|
||
{
|
||
$columns = [];
|
||
|
||
for ($month = 1; $month <= 12; $month++) {
|
||
$key = sprintf('%s%02d', $prefix, $month);
|
||
foreach ($rows as $row) {
|
||
$value = $row[$key] ?? null;
|
||
if ($value === null || $value === '') {
|
||
continue;
|
||
}
|
||
if (! $zeroCountsAsData && (float) $value === 0.0) {
|
||
continue;
|
||
}
|
||
|
||
continue 2;
|
||
}
|
||
|
||
$columns[] = $key;
|
||
}
|
||
|
||
return $columns;
|
||
}
|
||
|
||
/** @param array<string,mixed> $row @param array<string,string> $filters */
|
||
private function subjectMatchesFilters(array $row, array $filters): bool
|
||
{
|
||
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
|
||
return false;
|
||
}
|
||
|
||
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
|
||
$needle = trim((string) ($filters[$key] ?? ''));
|
||
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
foreach (['rolling_12m_income', ...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12))] as $key) {
|
||
$needle = str_replace(',', '', trim((string) ($filters[$key] ?? '')));
|
||
if ($needle !== '' && (! is_numeric($needle) || ! is_numeric($row[$key] ?? null)
|
||
|| abs((float) $row[$key] - (float) $needle) > 0.000001)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
$canChange = trim((string) ($filters['can_change_entity'] ?? ''));
|
||
return $canChange === '' || (string) ($row['can_change_entity'] ?? '') === $canChange;
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
|
||
private function sortSubjectRows(array &$rows, array $filters): void
|
||
{
|
||
$key = (string) ($filters['_sort'] ?? 'entity');
|
||
$allowed = [
|
||
'city', 'entity', 'company_type', 'rolling_12m_income',
|
||
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
|
||
'can_change_entity', 'plan', 'new_entity',
|
||
];
|
||
if (! in_array($key, $allowed, true)) {
|
||
return;
|
||
}
|
||
|
||
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
|
||
$leftValue = $left[$key] ?? null;
|
||
$rightValue = $right[$key] ?? null;
|
||
if (is_numeric($leftValue) && is_numeric($rightValue)) {
|
||
$comparison = ((float) $leftValue <=> (float) $rightValue) * $direction;
|
||
} else {
|
||
$comparison = strcmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
|
||
}
|
||
|
||
return $comparison !== 0 ? $comparison : strcmp((string) ($left['entity'] ?? ''), (string) ($right['entity'] ?? ''));
|
||
});
|
||
}
|
||
|
||
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.shopName AS 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
|
||
{
|
||
$filters = $this->pageFilters('trademark-management', 'information');
|
||
return $this->page(
|
||
'trademark-information.read',
|
||
$page,
|
||
$perPage,
|
||
$filters,
|
||
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, array $filters, callable $resolver): array
|
||
{
|
||
if ($this->mirror->ready($scope)) {
|
||
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
|
||
}
|
||
|
||
if ($filters !== []) {
|
||
throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。');
|
||
}
|
||
|
||
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));
|
||
});
|
||
}
|
||
|
||
/** @return array<string,string|list<string>> */
|
||
private function pageFilters(string $section, string $page): array
|
||
{
|
||
$fields = collect(config('business_navigation'))
|
||
->firstWhere('slug', $section)['pages'] ?? [];
|
||
$pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page);
|
||
$allowed = collect($pageConfig['directory']['fields'] ?? [])
|
||
->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account')
|
||
->pluck('name')->all();
|
||
if ($section === 'company-management' && $page === 'basic-information') {
|
||
$allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people']));
|
||
}
|
||
$filters = [];
|
||
foreach ($allowed as $name) {
|
||
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
|
||
$raw = request($key, '');
|
||
// 多选筛选(xxx[])传进来是数组:去重后原样带下去,由各自的仓库取并集。
|
||
if (is_array($raw)) {
|
||
$values = [];
|
||
foreach ($raw as $item) {
|
||
if (! is_scalar($item)) continue;
|
||
$item = mb_substr(trim((string) $item), 0, 191);
|
||
if ($item !== '') $values[$item] = true;
|
||
if (count($values) >= 50) break;
|
||
}
|
||
if ($values !== []) $filters[$key] = array_keys($values);
|
||
continue;
|
||
}
|
||
$value = trim((string) $raw);
|
||
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
|
||
}
|
||
}
|
||
$sort = trim((string) request('sort', ''));
|
||
if (in_array($sort, $allowed, true)) {
|
||
$filters['_sort'] = $sort;
|
||
$filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc';
|
||
}
|
||
|
||
return $filters;
|
||
}
|
||
|
||
/** @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['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? 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);
|
||
}
|
||
}
|