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 行、负责人/利润中心/失效区间筛选抽样全对)。 无新增数据库迁移,不涉及生产数据写入。
This commit is contained in:
parent
0584aa3742
commit
d48cb7d639
67
app/Business/RegisteredAddressCity.php
Normal file
67
app/Business/RegisteredAddressCity.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 从工商注册地址(rpt.fys_companyInfo.registeredAddress)解析地级市。
|
||||
*
|
||||
* 地址标准格式为「省/自治区/直辖市 + 地级行政区 + 区县 + 街道」,按顺序匹配:
|
||||
* 1. 直辖市(北京/上海/天津/重庆)本身就是市级,直接返回「XX市」;
|
||||
* 2. 剥掉省/自治区前缀后取第一个地级行政区(市/自治州/地区/盟),无省级前缀时直接匹配;
|
||||
* 3. 本地地址常省略省/市前缀(如「常州经济开发区…」「武进区…」),
|
||||
* 按本地区划前缀映射兜底;
|
||||
* 4. 地址缺行政区划或写法不规则时返回 null,由页面按统一缺失值展示。
|
||||
*/
|
||||
final class RegisteredAddressCity
|
||||
{
|
||||
private const MUNICIPALITIES = ['北京', '上海', '天津', '重庆'];
|
||||
|
||||
/** 地级行政区名最长 8 字(克孜勒苏柯尔克孜自治州),过长的匹配多为「自贸试验区」类园区名,宁缺勿错。 */
|
||||
private const PREFECTURE_PATTERN = '/^(.{2,8}?(?:市|自治州|地区|盟))/u';
|
||||
|
||||
private const PROVINCE_PATTERN = '/^(.{2,12}?(?:省|自治区))/u';
|
||||
|
||||
/** 公司库本地(常州)地址省略省市前缀时的区划前缀映射;按需扩展。 */
|
||||
private const DISTRICT_PREFIX_CITY_MAP = [
|
||||
'常州' => '常州市',
|
||||
'天宁' => '常州市',
|
||||
'钟楼' => '常州市',
|
||||
'新北' => '常州市',
|
||||
'武进' => '常州市',
|
||||
'金坛' => '常州市',
|
||||
'戚墅堰' => '常州市',
|
||||
'溧阳' => '常州市',
|
||||
];
|
||||
|
||||
public static function parse(mixed $address): ?string
|
||||
{
|
||||
$address = trim((string) ($address ?? ''));
|
||||
if ($address === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 部分地址带「中国」前缀,剥掉后再匹配行政区划。
|
||||
$address = (string) preg_replace('/^(?:中国|中华人民共和国)/u', '', $address);
|
||||
|
||||
foreach (self::MUNICIPALITIES as $municipality) {
|
||||
if (mb_strpos($address, $municipality) === 0) {
|
||||
return $municipality . '市';
|
||||
}
|
||||
}
|
||||
|
||||
// 剥掉省/自治区前缀后取第一个地级行政区;地址无省级前缀时直接匹配。
|
||||
$rest = (string) preg_replace(self::PROVINCE_PATTERN, '', $address);
|
||||
if (preg_match(self::PREFECTURE_PATTERN, $rest, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// 本地地址省略省/市前缀时按区划前缀识别。
|
||||
foreach (self::DISTRICT_PREFIX_CITY_MAP as $prefix => $city) {
|
||||
if (mb_strpos($rest, $prefix) === 0) {
|
||||
return $city;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -155,6 +155,7 @@ class TableRegistry
|
||||
'trademark-management/renewal' => Trademark::class,
|
||||
'trademark-management/information' => Trademark::class,
|
||||
'store-management/basic-information' => StoreInformation::class,
|
||||
'store-management/store-accounts' => StoreInformation::class,
|
||||
'store-management/store-report' => StoreInformation::class,
|
||||
'store-management/opening-process' => StoreOpening::class,
|
||||
'store-management/changes' => StoreChange::class,
|
||||
@ -255,6 +256,7 @@ class TableRegistry
|
||||
'company-management/basic-information',
|
||||
'company-management/subject-operating-report',
|
||||
'store-management/basic-information',
|
||||
'store-management/store-accounts',
|
||||
'store-management/store-report',
|
||||
'trademark-management/information',
|
||||
'financial-operations/installation-details',
|
||||
|
||||
@ -9,7 +9,9 @@ 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
|
||||
{
|
||||
@ -21,6 +23,7 @@ class UpstreamBusinessInformation
|
||||
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
|
||||
@ -32,6 +35,7 @@ class UpstreamBusinessInformation
|
||||
$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),
|
||||
@ -142,20 +146,101 @@ SQL);
|
||||
|
||||
private function storeReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$rows = $this->storeReportRows();
|
||||
$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 allStores(): array
|
||||
private function storeAccountPage(int $page, int $perPage): array
|
||||
{
|
||||
if (! $this->mirror->ready('store-information.read')) {
|
||||
throw new ServiceUnavailableHttpException(null, '店铺资料共享镜像尚未完成首次同步。');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $this->mirror->allStores();
|
||||
$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)];
|
||||
}
|
||||
@ -163,47 +248,177 @@ SQL);
|
||||
private function subjectOperatingReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$rows = $this->storeReportRows();
|
||||
$entityTotals = [];
|
||||
foreach ($rows as $row) {
|
||||
$entity = (string) ($row['entity'] ?? '');
|
||||
$entityTotals[$entity] = ($entityTotals[$entity] ?? 0.0) + (float) ($row['store_sales_12m'] ?? 0);
|
||||
}
|
||||
$rows = array_map(static function (array $row) use ($entityTotals): array {
|
||||
$entity = (string) ($row['entity'] ?? '');
|
||||
$entities = [];
|
||||
|
||||
return [
|
||||
foreach ($rows as $row) {
|
||||
$entity = trim((string) ($row['entity'] ?? ''));
|
||||
if ($entity === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entities[$entity] ??= [
|
||||
'city' => null,
|
||||
'entity' => $row['entity'] ?? null,
|
||||
'entity' => $entity,
|
||||
'company_type' => $row['company_type'] ?? null,
|
||||
'erp_store_name' => $row['erp_store_name'] ?? null,
|
||||
'profit_center' => $row['profit_center'] ?? null,
|
||||
'store_manager' => $row['store_manager'] ?? null,
|
||||
'closing_manager' => null,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'status' => $row['status'] ?? null,
|
||||
'recent_three_month_sales_1' => $row['recent_three_month_sales_1'] ?? 0,
|
||||
'recent_three_month_sales_2' => $row['recent_three_month_sales_2'] ?? 0,
|
||||
'recent_three_month_sales_3' => $row['recent_three_month_sales_3'] ?? 0,
|
||||
'store_sales_12m' => $row['store_sales_12m'] ?? 0,
|
||||
'entity_sales_12m' => $entityTotals[$entity] ?? 0,
|
||||
'rolling_12m_income' => null,
|
||||
'can_change_entity' => null,
|
||||
'plan' => null,
|
||||
'new_entity' => null,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
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;
|
||||
|
||||
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
|
||||
$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)];
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function storeReportRows(): array
|
||||
private function sumNullable(array &$row, string $key, mixed $value): void
|
||||
{
|
||||
$stores = $this->allStores();
|
||||
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 {
|
||||
return ($this->storeReports ?? app(StoreReportRepository::class))->rows($stores['data']);
|
||||
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) {
|
||||
throw new ServiceUnavailableHttpException(null, $exception->getMessage(), $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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,22 +11,15 @@ return [
|
||||
'title' => '主体维度经营报表',
|
||||
'directory' => [
|
||||
'icon' => 'query_stats',
|
||||
'status' => '共享镜像',
|
||||
'description' => '店铺主体和销量统一读取团队共享业务镜像;销量按主体生效区间归属并汇总。',
|
||||
'empty' => '经营报表共享镜像尚未完成同步。',
|
||||
'status' => '收入汇总',
|
||||
'description' => '按主体生效、失效日期归属账单收入;当前页面一行一个主体,收入来自 SQLite bills.income。',
|
||||
'empty' => '经营报表尚未同步。完成店铺主体和销量镜像接入后,这里会显示可追溯的主体数据。',
|
||||
'fields' => [
|
||||
['name' => 'city', 'label' => '市'],
|
||||
['name' => 'entity', 'label' => '主体'],
|
||||
['name' => 'company_type', 'label' => '公司类型'],
|
||||
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
|
||||
['name' => 'profit_center', 'label' => '店铺所属利润中心'],
|
||||
['name' => 'store_manager', 'label' => '店铺负责人'],
|
||||
['name' => 'closing_manager', 'label' => '闭门负责人'],
|
||||
['name' => 'opened_at', 'label' => '开店时间', 'kind' => 'date'],
|
||||
['name' => 'status', 'label' => '状态'],
|
||||
['name' => 'recent_three_month_sales', 'label' => '展示近3个月月度销量', 'kind' => 'number'],
|
||||
['name' => 'store_sales_12m', 'label' => '连续12个月店铺累计销量', 'kind' => 'number'],
|
||||
['name' => 'entity_sales_12m', 'label' => '连续12个月主体累计销量', 'kind' => 'number'],
|
||||
['name' => 'rolling_12m_income', 'label' => '最近12个月累计收入', 'kind' => 'number'],
|
||||
...array_map(static fn (int $month): array => ['name' => sprintf('income_%02d', $month), 'label' => $month . '月收入', 'kind' => 'number'], range(1, 12)),
|
||||
['name' => 'can_change_entity', 'label' => '能否变更主体'],
|
||||
['name' => 'plan', 'label' => '规划'],
|
||||
['name' => 'new_entity', 'label' => '新主体(变更流程同步新主体)'],
|
||||
@ -147,6 +140,41 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'rules-information',
|
||||
'title' => '规则信息',
|
||||
'directory' => [
|
||||
'icon' => 'rule',
|
||||
'status' => '规则待定义',
|
||||
'description' => '规则信息分组,包含主体商标规则和店铺变更规则。',
|
||||
'empty' => '请选择下方具体规则页面。',
|
||||
'fields' => [],
|
||||
],
|
||||
'children' => [
|
||||
[
|
||||
'slug' => 'subject-trademark-rules',
|
||||
'title' => '主体商标规则',
|
||||
'directory' => [
|
||||
'icon' => 'rule',
|
||||
'status' => '规则待定义',
|
||||
'description' => '主体商标规则页面位置已建立,待补充正式规则字段和业务数据。',
|
||||
'empty' => '主体商标规则尚未定义或同步。',
|
||||
'fields' => [],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'store-change-rules',
|
||||
'title' => '店铺变更规则',
|
||||
'directory' => [
|
||||
'icon' => 'rule',
|
||||
'status' => '规则待定义',
|
||||
'description' => '店铺变更规则页面位置已建立,待补充正式规则字段和业务数据。',
|
||||
'empty' => '店铺变更规则尚未定义或同步。',
|
||||
'fields' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -214,9 +242,9 @@ return [
|
||||
'title' => '店铺账号密码',
|
||||
'directory' => [
|
||||
'icon' => 'key',
|
||||
'status' => '账号系统待接入',
|
||||
'description' => '按店铺账号管理系统的 StoreAccount 字段展示账号资料;密码不返回明文,暂未接入的字段保留为空。',
|
||||
'empty' => '店铺账号数据尚未接入。数据源为账号系统 StoreAccount 主表,不使用店铺基础信息表代替。',
|
||||
'status' => '共享镜像',
|
||||
'description' => '读取店铺账号系统的共享业务镜像;密码不返回明文,仅展示已允许读取的账号资料。',
|
||||
'empty' => '共享镜像中暂未读取到店铺账号数据,请检查账号数据同步状态。',
|
||||
'fields' => [
|
||||
['name' => 'store_name', 'label' => '店铺名称'],
|
||||
['name' => 'operation_owner', 'label' => '店铺运营负责人'],
|
||||
@ -225,7 +253,7 @@ return [
|
||||
['name' => 'profit_center', 'label' => '店铺所属利润中心'],
|
||||
['name' => 'account_type', 'label' => '账号类型'],
|
||||
['name' => 'account', 'label' => '账号'],
|
||||
['name' => 'password_configured', 'label' => '密码', 'kind' => 'secret'],
|
||||
['name' => 'password_configured', 'label' => '密码', 'kind' => 'secret_status'],
|
||||
['name' => 'bound_phone', 'label' => '绑定手机号', 'kind' => 'secret'],
|
||||
['name' => 'verification_contact', 'label' => '手机号验证码对接人'],
|
||||
['name' => 'account_group_name', 'label' => '账号分组名称'],
|
||||
|
||||
@ -71,6 +71,16 @@ return [
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
],
|
||||
|
||||
'bills_sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => env('BILLS_SQLITE_PATH', base_path('runtime/bills.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => null,
|
||||
'options' => [
|
||||
PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY,
|
||||
],
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
|
||||
@ -24,6 +24,7 @@ services:
|
||||
- "127.0.0.1:18088:18088"
|
||||
volumes:
|
||||
- ./runtime/.env:/var/www/html/.env
|
||||
- ./runtime/bills.sqlite:/var/www/html/runtime/bills.sqlite:ro
|
||||
- jiangling_storage:/var/www/html/storage
|
||||
- ./app:/var/www/html/app
|
||||
- ./config:/var/www/html/config
|
||||
|
||||
304
docs/主体维度经营报表需求说明.md
Normal file
304
docs/主体维度经营报表需求说明.md
Normal file
@ -0,0 +1,304 @@
|
||||
# 主体维度经营报表需求说明
|
||||
|
||||
## 1. 文档状态
|
||||
|
||||
- 状态:需求口径已确认,等待字段细节和实现排期。
|
||||
- 页面:公司管理 → 主体维度经营报表。
|
||||
- 路由标识:`company-management/subject-operating-report`。
|
||||
- 本文是开发和验收依据;在本口径之外不得自行扩展业务规则。
|
||||
- 实现时不要重新设计,沿用现有项目框架、只读上游访问、页面布局和表格组件。
|
||||
|
||||
## 2. 目标和展示粒度
|
||||
|
||||
报表以“主体名称”为唯一展示粒度:
|
||||
|
||||
> 一行一个主体。
|
||||
|
||||
主体下的店铺只参与底层收入归集,不在本页面展示。一个主体关联多个店铺、多个历史生命周期或多个平台时,最终都合并为同一主体行。
|
||||
|
||||
历史主体和当前主体都必须显示。历史主体即使在 `rpt.fys_companyInfo` 中已经查不到,也不能因为缺少主体基础资料而删除其收入行。
|
||||
|
||||
## 3. 最终字段和顺序
|
||||
|
||||
页面表格必须严格按以下顺序展示,字段名和顺序是验收契约:
|
||||
|
||||
| 顺序 | 字段 | 说明 |
|
||||
| ---: | --- | --- |
|
||||
| 1 | 市 | 由主体基础资料 `rpt.fys_companyInfo.registeredAddress` 解析地级市(`App\Business\RegisteredAddressCity::parse`):直辖市直接返回「XX市」;其余取省/自治区前缀后第一个地级行政区;本地地址省略省市前缀时按常州区划前缀兜底;识别不出按统一缺失值展示,不伪造数据 |
|
||||
| 2 | 主体 | 主体名称;作为主体合并键 |
|
||||
| 3 | 公司类型 | 主体公司类型,来源于 `rpt` |
|
||||
| 4 | 最近12个月累计收入 | 最近12个完整自然月收入合计,排除当前月 |
|
||||
| 5 | 1月收入 | 当前年份 1 月收入,当前月或无账单时为空 |
|
||||
| 6 | 2月收入 | 当前年份 2 月收入,当前月或无账单时为空 |
|
||||
| 7 | 3月收入 | 当前年份 3 月收入,当前月或无账单时为空 |
|
||||
| 8 | 4月收入 | 当前年份 4 月收入,当前月或无账单时为空 |
|
||||
| 9 | 5月收入 | 当前年份 5 月收入,当前月或无账单时为空 |
|
||||
| 10 | 6月收入 | 当前年份 6 月收入,当前月或无账单时为空 |
|
||||
| 11 | 7月收入 | 当前年份 7 月收入,当前月或无账单时为空 |
|
||||
| 12 | 8月收入 | 当前年份 8 月收入,当前月或无账单时为空 |
|
||||
| 13 | 9月收入 | 当前年份 9 月收入,当前月或无账单时为空 |
|
||||
| 14 | 10月收入 | 当前年份 10 月收入,当前月或无账单时为空 |
|
||||
| 15 | 11月收入 | 当前年份 11 月收入,当前月或无账单时为空 |
|
||||
| 16 | 12月收入 | 当前年份 12 月收入,当前月或无账单时为空 |
|
||||
| 17 | 能否变更主体 | 逻辑暂未提供,先留空/按统一缺失值规则展示 |
|
||||
| 18 | 规划 | 逻辑暂未提供,先留空/按统一缺失值规则展示 |
|
||||
| 19 | 新主体(变更流程同步新主体) | 逻辑暂未提供,先留空/按统一缺失值规则展示 |
|
||||
|
||||
以下字段明确删除,不得出现在主体维度经营报表中:
|
||||
|
||||
- ERP 系统店铺名称
|
||||
- 店铺所属利润中心
|
||||
- 店铺负责人
|
||||
- 部门负责人/闭门负责人
|
||||
- 开店时间
|
||||
- 店铺状态
|
||||
- 连续12个月店铺累计收入(原“连续12个月店铺累计销量”)
|
||||
- 其他仅描述单个店铺的字段
|
||||
|
||||
“销量”在本报表中统一改为“收入”。所有收入数值均来自 SQLite `bills.income`,不以件数、利润或其他金额字段替代。
|
||||
|
||||
## 4. 数据来源和职责
|
||||
|
||||
### 4.1 `rpt` 主表
|
||||
|
||||
`rpt` 是主体报表的主表入口,先由它确定参与报表的店铺集合和主体基础维度。
|
||||
|
||||
主要对象:
|
||||
|
||||
- `rpt.fys_shopInfo`:店铺主表,至少使用 `erpShopName` 作为名称关联入口,并读取当前店铺对应的主体关联信息。
|
||||
- `rpt.fys_companyInfo`:主体基础资料表,至少提供 `businessLicenseName`(主体名称)和 `businessLicenseType`(公司类型)。
|
||||
|
||||
逻辑原则:
|
||||
|
||||
- 以 `rpt.fys_shopInfo` 中的店铺作为报表入口;
|
||||
- SQLite 有对应历史主体记录时,优先使用 SQLite 的主体及生命周期;
|
||||
- SQLite 没有对应历史主体记录时,使用 `rpt` 当前主体;
|
||||
- 不因为 SQLite 缺少生命周期记录而丢弃 `rpt` 中的店铺或主体。
|
||||
|
||||
### 4.2 SQLite 店铺报表数据
|
||||
|
||||
SQLite 文件逻辑上为项目已有店铺报表数据集(当前本地文件为 `runtime/bills.sqlite`),只读使用以下表:
|
||||
|
||||
`bills`:账单明细,收入字段为 `income`,账单时间字段为已标准化的 `occurred_at`。
|
||||
|
||||
`store_lifecycles`:店铺主体历史,字段包括:
|
||||
|
||||
- `erp_store_name`
|
||||
- `company_name`
|
||||
- `effective_at`
|
||||
- `expires_at`
|
||||
|
||||
### 4.3 名称关联
|
||||
|
||||
主体报表使用名称关联,不以店铺 ID 作为主关联键:
|
||||
|
||||
```text
|
||||
rpt.fys_shopInfo.erpShopName
|
||||
↔ store_lifecycles.erp_store_name
|
||||
↔ bills.store_name
|
||||
```
|
||||
|
||||
主体详细资料使用主体名称关联:
|
||||
|
||||
```text
|
||||
当前主体行.entity
|
||||
↔ rpt.fys_companyInfo.businessLicenseName
|
||||
```
|
||||
|
||||
名称匹配必须使用参数化查询和现有只读数据访问链路,禁止把用户输入直接拼接为 SQL。
|
||||
|
||||
## 5. 主体收入计算规则
|
||||
|
||||
### 5.1 生命周期区间
|
||||
|
||||
账单归属主体时使用左闭右开区间:
|
||||
|
||||
```text
|
||||
[effective_at, expires_at)
|
||||
```
|
||||
|
||||
等价筛选条件:
|
||||
|
||||
```sql
|
||||
bills.occurred_at >= effective_at
|
||||
AND bills.occurred_at < expires_at
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `effective_at` 当天计入;
|
||||
- `expires_at` 当天不计入;
|
||||
- `expires_at` 为空表示持续有效,右边界使用报表时间窗口的结束时间;
|
||||
- 主体切换日只能归属一个主体,不能重复计入;
|
||||
- 生效/失效时间只用于筛选账单,不在主体报表主表中展示。
|
||||
|
||||
### 5.2 收入口径
|
||||
|
||||
所有收入指标严格使用:
|
||||
|
||||
```sql
|
||||
SUM(bills.income)
|
||||
```
|
||||
|
||||
计算前必须同时满足:
|
||||
|
||||
1. 账单店铺名称与 `rpt` 店铺入口或其生命周期名称匹配;
|
||||
2. 账单时间落在主体生命周期区间内;
|
||||
3. 账单时间落在当前指标的月份或滚动窗口内。
|
||||
|
||||
不使用 `expense`,不计算利润、净收入或件数,不从 `rpt` 读取收入金额。
|
||||
|
||||
### 5.3 主体合并
|
||||
|
||||
先按店铺名称和生命周期切分账单,再按主体名称合并:
|
||||
|
||||
```text
|
||||
rpt 店铺入口
|
||||
↓ 名称匹配 SQLite 生命周期
|
||||
主体生命周期内的 bills.income
|
||||
↓ 按月 SUM
|
||||
店铺/生命周期月度收入
|
||||
↓ 按主体名称 SUM
|
||||
主体月度收入和主体滚动12个月收入
|
||||
```
|
||||
|
||||
同一个主体名称在多个店铺、多个主体生命周期或多个平台出现时,全部合并为一行。
|
||||
|
||||
## 6. 月度字段和滚动12个月
|
||||
|
||||
### 6.1 当前年份 1–12月
|
||||
|
||||
1–12月字段始终对应运行时当前年份,不固定写死某个年份:
|
||||
|
||||
```text
|
||||
当前年份 1月收入 ... 当前年份 12月收入
|
||||
```
|
||||
|
||||
当前月排除:
|
||||
|
||||
- 当前月份即使已有账单,也不计入月度字段;
|
||||
- 当前月字段显示为空;
|
||||
- 尚未到达的未来月份字段显示为空;
|
||||
- 过去月份没有账单时显示为空,不显示 `0`。
|
||||
|
||||
对已完成月份,主体某月收入为该月内、且属于主体有效生命周期的全部 `bills.income` 之和。
|
||||
|
||||
### 6.2 最近12个月累计收入
|
||||
|
||||
“最近12个月累计收入”放在 1月收入之前,取最近12个完整自然月,并排除当前月。
|
||||
|
||||
以当前月月初为右边界:
|
||||
|
||||
```text
|
||||
[当前月月初 - 12个月, 当前月月初)
|
||||
```
|
||||
|
||||
例如当前时间在 2026 年 9 月,则窗口为:
|
||||
|
||||
```text
|
||||
[2025-09-01 00:00:00, 2026-09-01 00:00:00)
|
||||
```
|
||||
|
||||
在该滚动窗口内,再叠加主体生命周期区间,最终执行 `SUM(bills.income)`。
|
||||
|
||||
最近12个月可能跨自然年,因此不等同于当前年份 1–12月字段之和。
|
||||
|
||||
如果主体在该滚动窗口内没有任何账单,最近12个月累计收入显示为空。
|
||||
|
||||
## 7. 缺失值规则
|
||||
|
||||
| 场景 | 展示规则 |
|
||||
| --- | --- |
|
||||
| 某个已完成月份没有账单 | 收入单元格为空 |
|
||||
| 当前月或未来月份 | 收入单元格为空 |
|
||||
| 最近12个月没有账单 | 累计收入单元格为空 |
|
||||
| 暂无来源的主体属性字段(市、能否变更主体、规划、新主体) | 按页面统一缺失值规则显示 `-`,不伪造数据 |
|
||||
| SQLite 没有生命周期,但 `rpt` 有店铺主体 | 使用 `rpt` 当前主体,收入按后续确认的兜底时间规则处理 |
|
||||
| 历史主体在 `rpt.fys_companyInfo` 中查不到 | 保留主体收入行;底部基础资料 Tab 显示无匹配资料状态 |
|
||||
|
||||
SQLite 无生命周期时使用 `rpt` 当前主体已经确定;但该兜底记录的收入起始时间(例如是否使用 `rpt.openTime`)尚未确认,开发前不得自行选择。
|
||||
|
||||
## 8. 底部“主体详细基础资料”Tab
|
||||
|
||||
### 8.1 交互
|
||||
|
||||
在主体经营报表底部增加局部 Tab:
|
||||
|
||||
```text
|
||||
主体经营汇总表
|
||||
↓ 选中当前主体行
|
||||
底部“主体详细基础资料”Tab显示当前行主体资料
|
||||
```
|
||||
|
||||
切换主体时,Tab 内容同步切换到当前主体;不重新设计为独立店铺页面,也不显示店铺资料。
|
||||
|
||||
首次进入页面的默认选中行、主体行点击方式和多选行为沿用现有页面交互规范;不得在没有确认的情况下增加复杂筛选或弹窗流程。
|
||||
|
||||
### 8.2 查询关系
|
||||
|
||||
当前主体名称取主体报表行的 `entity` 字段,使用以下查询关系:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM rpt.fys_companyInfo
|
||||
WHERE businessLicenseName = :entity_name
|
||||
```
|
||||
|
||||
其中 `:entity_name` 为当前选中行的主体名称。
|
||||
|
||||
`SELECT *` 是本需求确认的来源查询关系。页面展示必须继续遵守项目现有的敏感字段保护规则,不得将账号、密码、账户等敏感数据明文暴露。
|
||||
|
||||
### 8.3 资料缺失和多记录策略
|
||||
|
||||
- 查不到基础资料时,不删除主体经营行;Tab 显示“暂无匹配资料”或等价的空状态。
|
||||
- `rpt.fys_companyInfo` 同一主体名称返回多条记录时,展示全部记录还是选择一条,待后续确认;实现前不得擅自按最新记录或任意排序覆盖其他记录。
|
||||
- 基础资料字段顺序优先沿用现有公司基础信息页面,若需不同顺序另行确认。
|
||||
|
||||
## 9. 实现边界
|
||||
|
||||
### 本次要实现
|
||||
|
||||
- 将主体经营报表改为一主体一行;
|
||||
- 删除所有店铺级列及店铺级累计指标;
|
||||
- 增加并固定显示当前年份 1–12月收入列;
|
||||
- 将最近12个月累计收入放到 1月收入之前;
|
||||
- 按 SQLite `bills.income` 汇总收入;
|
||||
- 按主体生命周期左闭右开区间筛选账单;
|
||||
- 合并历史主体和当前主体的主体行;
|
||||
- 按主体名称合并多个店铺和多个生命周期结果;
|
||||
- 增加底部主体详细基础资料 Tab,并按主体名称查询 `rpt.fys_companyInfo`。
|
||||
|
||||
### 本次不要实现
|
||||
|
||||
- 不新增店铺维度字段到主体页面;
|
||||
- 不把收入改成销量件数、利润或净收入;
|
||||
- 不猜测“市”“能否变更主体”“规划”“新主体”的业务来源和计算逻辑;
|
||||
- 不擅自决定 SQLite 无生命周期时的收入起始日期;
|
||||
- 不擅自决定主体基础资料多记录的取数策略;
|
||||
- 不修改上游数据库,不向 `rpt` 或 SQLite 写回业务数据;
|
||||
- 不新增另一套报表数据源或绕过现有只读/镜像访问边界。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
1. 页面每个主体只出现一行,同名主体不会重复出现。
|
||||
2. 表头严格按第 3 节的 19 列顺序显示,最近12个月累计收入紧邻公司类型之后、1月收入之前。
|
||||
3. 页面不出现 ERP 店铺名称、利润中心、店铺负责人、开店时间、店铺状态或店铺累计收入等店铺字段。
|
||||
4. 月度字段显示当前年份 1–12 月;当前月、未来月份和无账单月份均为空。
|
||||
5. 最近12个月累计收入使用最近12个完整自然月,排除当前月,并支持跨年滚动。
|
||||
6. 所有收入均可追溯到匹配的 `bills.income` 汇总;不得使用 `expense` 或其他金额字段。
|
||||
7. 主体切换边界遵循 `[effective_at, expires_at)`,生效日计入、失效日不计入,不能重复统计。
|
||||
8. SQLite 有生命周期时使用历史/当前主体;无生命周期时保留 `rpt` 当前主体。
|
||||
9. 底部 Tab 能随当前主体行切换,并按 `businessLicenseName = 当前主体名称` 查询主体基础资料。
|
||||
10. 历史主体即使没有匹配的 `rpt.fys_companyInfo` 资料,也保留收入行并显示资料缺失状态。
|
||||
11. 暂无来源的属性字段显示统一缺失值,不填充猜测数据。
|
||||
12. 验收必须覆盖:同主体多店铺、主体切换日、无生命周期店铺、无账单月份、跨年最近12个月、无匹配主体基础资料等场景。
|
||||
|
||||
## 11. 待后续确认事项
|
||||
|
||||
- SQLite 无生命周期时,收入起始时间是否使用 `rpt.openTime`;
|
||||
- “能否变更主体”“规划”“新主体”的数据来源及维护方式;
|
||||
- `rpt.fys_companyInfo` 同一主体名称多条记录时的展示策略;
|
||||
- 底部 Tab 首次进入页面的默认选中主体;
|
||||
- 历史主体在基础资料中查不到时的最终空状态文案;
|
||||
- 当前年份是否需要显式显示在表头,例如“2026年1月收入”;
|
||||
- `bills.occurred_at` 的时区和月份边界是否统一按系统时区解释。
|
||||
@ -48,15 +48,17 @@ class BusinessInformationMirrorRepository
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function allStores(): array
|
||||
/** @param array<string,string> $filters @return array<int,array<string,mixed>> */
|
||||
public function allStores(array $filters = []): array
|
||||
{
|
||||
if (! $this->ready('store-information.read')) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
return $this->storeQuery()
|
||||
->orderByDesc('s.Id')
|
||||
$query = $this->storeQuery()->orderByDesc('s.Id');
|
||||
$this->applyStoreFilters($query, $filters);
|
||||
|
||||
return $query
|
||||
->get()
|
||||
->map(static fn (object $row): array => (array) $row)
|
||||
->all();
|
||||
@ -103,6 +105,62 @@ SQL)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** 店铺维度报表的店铺字段查询条件;日期左闭右开 [from, to)。 */
|
||||
private function applyStoreFilters($query, array $filters): void
|
||||
{
|
||||
foreach ([
|
||||
'erp_store_name' => 's.erpShopName',
|
||||
// 页面“店铺ID(唯一值)”列展示的就是 ERP 店铺名称,筛选按同一列匹配。
|
||||
'store_id' => 's.erpShopName',
|
||||
'profit_center' => 's.responsibleDepartment',
|
||||
'store_manager' => 's.shopHead',
|
||||
'department_manager' => 's.customerDeptManager',
|
||||
] as $key => $column) {
|
||||
$value = trim((string) ($filters[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$query->where($column, '=', $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (trim((string) ($filters['company_type'] ?? '')) !== '') {
|
||||
$query->where('c.businessLicenseType', '=', trim((string) $filters['company_type']));
|
||||
}
|
||||
|
||||
if (trim((string) ($filters['status'] ?? '')) !== '') {
|
||||
$query->where('s.shopState', '=', (string) $filters['status']);
|
||||
}
|
||||
|
||||
if (trim((string) ($filters['opened_at_from'] ?? '')) !== '') {
|
||||
$query->where('s.openTime', '>=', $filters['opened_at_from']);
|
||||
}
|
||||
|
||||
if (trim((string) ($filters['opened_at_to'] ?? '')) !== '') {
|
||||
$query->where('s.openTime', '<', $filters['opened_at_to']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,array<string,mixed>>> */
|
||||
public function companiesByNames(array $names): array
|
||||
{
|
||||
if (! $this->ready('company-information.read')) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$names = array_values(array_unique(array_filter(array_map('trim', $names))));
|
||||
if ($names === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->connection->table('ops_fys_company_info')
|
||||
->whereIn('businessLicenseName', $names)
|
||||
->orderByDesc('Id')
|
||||
->get()
|
||||
->map(static fn (object $row): array => (array) $row)
|
||||
->groupBy(static fn (array $row): string => (string) $row['businessLicenseName'])
|
||||
->map(static fn ($rows): array => $rows->values()->all())
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array{data: array<int,array<string,mixed>>, total: int} */
|
||||
private function companies(int $page, int $perPage): array
|
||||
{
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
class StoreAccountMirrorRepository
|
||||
{
|
||||
public const DATASET = 'database_4.external_store_accounts.accounts_v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {}
|
||||
|
||||
/** @return array{data:array<int,array<string,mixed>>,total:int} */
|
||||
public function paginate(int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('store_account_mirror_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->mirror->connection()->table($dataset->targetTable());
|
||||
$total = (clone $query)->count();
|
||||
$rows = $query
|
||||
->orderByDesc('id')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => [
|
||||
'id' => $row->id,
|
||||
'store_name' => $row->storeName,
|
||||
'operation_owner' => $row->operationOwner,
|
||||
'operation_department_head' => $row->operationDepartmentHead,
|
||||
'operation_director' => $row->operationDirector,
|
||||
'profit_center' => $row->profitCenter,
|
||||
'account_type' => $this->accountType($row->accountType),
|
||||
'account' => $row->account,
|
||||
// 上游只读视图明确不返回密码密文,列表只能展示未知配置状态。
|
||||
'password_configured' => null,
|
||||
'bound_phone' => $row->boundPhone,
|
||||
'verification_contact' => $row->verificationContact,
|
||||
'account_group_name' => $row->accountGroupName,
|
||||
'created_at' => $row->createdAt,
|
||||
'updated_at' => $row->updatedAt,
|
||||
])
|
||||
->all();
|
||||
|
||||
return ['data' => $rows, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。
|
||||
*
|
||||
* @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}>
|
||||
*/
|
||||
public function ownershipByStoreName(): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('store_account_mirror_not_ready');
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($this->mirror->connection()->table($dataset->targetTable())
|
||||
->select('storeName', 'operationOwner', 'operationDirector', 'profitCenter')
|
||||
->get() as $row) {
|
||||
$name = trim((string) $row->storeName);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$map[$name] = [
|
||||
'operation_owner' => trim((string) $row->operationOwner),
|
||||
'operation_director' => trim((string) $row->operationDirector),
|
||||
'profit_center' => trim((string) $row->profitCenter),
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function accountType(mixed $value): ?string
|
||||
{
|
||||
$type = trim((string) $value);
|
||||
|
||||
return match (strtoupper($type)) {
|
||||
'MAIN' => '主账号',
|
||||
'SUB' => '子账号',
|
||||
'', '/' => null,
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,52 +2,81 @@
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
/** 店铺维度报表只读取团队共享业务镜像。 */
|
||||
/**
|
||||
* 将经营分析副本库中的店铺资料与本地账单 SQLite 组合为店铺维度报表。
|
||||
*
|
||||
* SQLite 只读保存店铺生命周期和平台账单;生命周期区间统一使用
|
||||
* [effective_at, expires_at),避免主体切换日的账单被重复统计。
|
||||
*/
|
||||
class StoreReportRepository
|
||||
{
|
||||
// 保留导入命令使用的数据集键;报表页面本身读取本地 SQLite。
|
||||
public const BILLS_DATASET = 'store-report.platform-bills';
|
||||
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
|
||||
/** @deprecated Kept for existing synchronizer/test contracts. */
|
||||
public const DAILY_DATASET = 'store-report.daily-sales';
|
||||
|
||||
private const DEFAULT_EFFECTIVE_AT = '2026-07-01';
|
||||
private const DEFAULT_EXPIRES_AT = '2999-01-01';
|
||||
/** 主表 rpt.fys_shopinfo 中这些平台不支持变更主体(能否变更=否),其余平台均为是。 */
|
||||
public const UNCHANGEABLE_PLATFORMS = ['唯品会MP', '淘工厂'];
|
||||
private ?PDO $connection = null;
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
public function __construct(private readonly DatabaseManager $database) {}
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
/**
|
||||
* 店铺维度报表的查询条件;日期统一左闭右开 [from, to)。
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function requestFilters(): array
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
$rules = [];
|
||||
foreach (['opened_at', 'effective_at', 'invalid_at'] as $prefix) {
|
||||
$rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d'];
|
||||
$rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d'];
|
||||
if (request()->filled($prefix . '_from')) {
|
||||
$rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from';
|
||||
}
|
||||
}
|
||||
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'entity', 'company_type'] as $key) {
|
||||
$rules[$key] = ['nullable', 'string', 'max:191'];
|
||||
}
|
||||
$rules['status'] = ['nullable', 'string', 'in:0,3,6'];
|
||||
$rules['can_change'] = ['nullable', 'string', 'in:是,否'];
|
||||
|
||||
return validator(request()->only(array_keys($rules)), $rules)->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $stores
|
||||
* @param array<string,string> $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、生命周期区间及归属覆盖字段。
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function rows(array $stores, ?int $reportYear = null): array
|
||||
public function rows(array $stores, ?int $reportYear = null, array $filters = []): array
|
||||
{
|
||||
if ($stores === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$dailyGeneration, $lifecycleGeneration] = $this->activeGenerations();
|
||||
$reportYear ??= (int) now()->format('Y');
|
||||
if ($reportYear < 2000 || $reportYear > 2999) {
|
||||
throw new RuntimeException('店铺维度报表年份无效。');
|
||||
}
|
||||
|
||||
$this->assertDatabaseFile();
|
||||
$this->connection ??= $this->readOnlyConnection();
|
||||
$names = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['erp_store_name'] ?? '')),
|
||||
$stores,
|
||||
))));
|
||||
$lifecycles = $this->lifecycles($names, $lifecycleGeneration);
|
||||
$lifecycleSales = $this->lifecycleSales($names, $dailyGeneration, $lifecycleGeneration);
|
||||
$fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles))), $dailyGeneration);
|
||||
$lifecycles = $this->lifecycles($names);
|
||||
$lifecycleSales = $this->lifecycleSales($names);
|
||||
$fallbackSales = $this->fallbackSales(array_values(array_diff($names, array_keys($lifecycles))));
|
||||
$reportYear = $reportYear ?? (int) now()->format('Y');
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$rollingMonths = collect(range(12, 1))->map(
|
||||
static fn (int $monthsAgo): string => $currentMonth->copy()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
|
||||
)->all();
|
||||
$rows = [];
|
||||
|
||||
foreach ($stores as $store) {
|
||||
@ -58,7 +87,7 @@ class StoreReportRepository
|
||||
|
||||
$periods = $lifecycles[$storeName] ?? [[
|
||||
'company_name' => $store['company_name'] ?? null,
|
||||
'effective_at' => $store['opened_at'] ?? sprintf('%d-01-01', $reportYear),
|
||||
'effective_at' => self::DEFAULT_EFFECTIVE_AT,
|
||||
'expires_at' => self::DEFAULT_EXPIRES_AT,
|
||||
'lifecycle_id' => null,
|
||||
]];
|
||||
@ -66,143 +95,113 @@ class StoreReportRepository
|
||||
foreach ($periods as $period) {
|
||||
$saleData = $period['lifecycle_id'] === null
|
||||
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
|
||||
: $this->combinedSales($lifecycleSales, (array) ($period['lifecycle_ids'] ?? [$period['lifecycle_id']]));
|
||||
: ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]);
|
||||
$monthly = [];
|
||||
$monthlyIncome = [];
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$monthly[sprintf('sales_%02d', $month)] = (float) (
|
||||
$saleData['months'][sprintf('%d-%02d', $reportYear, $month)] ?? 0
|
||||
);
|
||||
$monthKey = sprintf('%d-%02d', $reportYear, $month);
|
||||
$monthly[sprintf('sales_%02d', $month)] = (float) ($saleData['months'][$monthKey] ?? 0);
|
||||
$monthlyIncome[sprintf('income_%02d', $month)] = $monthKey >= $currentMonth->format('Y-m')
|
||||
|| ! array_key_exists($monthKey, $saleData['months'])
|
||||
? null
|
||||
: (float) $saleData['months'][$monthKey];
|
||||
}
|
||||
$rollingMonths = collect(range(12, 1))->map(
|
||||
static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
|
||||
)->all();
|
||||
$recentMonths = collect(range(3, 1))->map(
|
||||
static fn (int $monthsAgo): string => now()->startOfMonth()->subMonthsNoOverflow($monthsAgo)->format('Y-m'),
|
||||
)->values()->all();
|
||||
|
||||
$rows[] = [
|
||||
$rollingSales = array_sum(array_map(
|
||||
static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0),
|
||||
$rollingMonths,
|
||||
));
|
||||
|
||||
$effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT);
|
||||
$expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT);
|
||||
$row = [
|
||||
...$store,
|
||||
'store_id' => $store['store_id'] ?? $storeName,
|
||||
'store_id' => $storeName,
|
||||
'status' => $this->status($store['store_status'] ?? null),
|
||||
'can_change' => $this->canChange($store['platform'] ?? null),
|
||||
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
|
||||
'effective_at' => $this->date($period['effective_at'] ?? null, sprintf('%d-01-01', $reportYear)),
|
||||
'invalid_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT),
|
||||
'effective_at' => $effectiveAt,
|
||||
'invalid_at' => $expiresAt === self::DEFAULT_EXPIRES_AT ? '存续中' : $expiresAt,
|
||||
'period_sales' => (float) $saleData['period'],
|
||||
'store_sales_12m' => array_sum(array_map(
|
||||
static fn (string $month): float => (float) ($saleData['months'][$month] ?? 0),
|
||||
$rollingMonths,
|
||||
)),
|
||||
'recent_three_month_sales_1' => (float) ($saleData['months'][$recentMonths[0]] ?? 0),
|
||||
'recent_three_month_sales_2' => (float) ($saleData['months'][$recentMonths[1]] ?? 0),
|
||||
'recent_three_month_sales_3' => (float) ($saleData['months'][$recentMonths[2]] ?? 0),
|
||||
'rolling_12m_income' => array_intersect($rollingMonths, array_keys($saleData['months'])) === []
|
||||
? null
|
||||
: $rollingSales,
|
||||
...$monthly,
|
||||
...$monthlyIncome,
|
||||
];
|
||||
|
||||
if (! $this->matchesFilters($row, $expiresAt, $filters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function activeGenerations(): array
|
||||
private function assertDatabaseFile(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
foreach (['store_report_import_states', 'store_report_daily_rows', 'store_report_lifecycle_rows'] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('店铺维度报表共享镜像尚未完成迁移。');
|
||||
}
|
||||
$path = (string) config('database.connections.bills_sqlite.database');
|
||||
if ($path === '' || ! is_file($path)) {
|
||||
throw new RuntimeException('店铺账单 SQLite 尚未挂载:' . $path);
|
||||
}
|
||||
|
||||
$states = $this->connection->table('store_report_import_states')
|
||||
->whereIn('dataset_key', [self::DAILY_DATASET, self::LIFECYCLES_DATASET])
|
||||
->get(['dataset_key', 'status', 'active_generation', 'row_count'])
|
||||
->keyBy('dataset_key');
|
||||
|
||||
foreach ([self::DAILY_DATASET, self::LIFECYCLES_DATASET] as $dataset) {
|
||||
$state = $states->get($dataset);
|
||||
if ($state === null || empty($state->active_generation)) {
|
||||
$label = $dataset === self::DAILY_DATASET ? '账单日汇总' : '主体历史';
|
||||
$rows = number_format((int) ($state->row_count ?? 0));
|
||||
throw new RuntimeException("店铺维度报表中央同步中:{$label}已写入 {$rows} 行,完整数据就绪后自动显示。");
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
(string) $states->get(self::DAILY_DATASET)->active_generation,
|
||||
(string) $states->get(self::LIFECYCLES_DATASET)->active_generation,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,array<string,mixed>>> */
|
||||
private function lifecycles(array $names, string $generation): array
|
||||
private function lifecycles(array $names): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (array_chunk($names, 500) as $chunk) {
|
||||
$rows = $this->connection->table('store_report_lifecycle_rows')
|
||||
->where('generation', $generation)
|
||||
->whereIn('erp_store_name', $chunk)
|
||||
->orderBy('erp_store_name')->orderBy('effective_at')->orderBy('id')
|
||||
->get(['id', 'erp_store_name', 'company_name', 'effective_at', 'expires_at']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$result[(string) $row->erp_store_name][] = [
|
||||
'lifecycle_id' => (int) $row->id,
|
||||
'company_name' => $row->company_name,
|
||||
'effective_at' => $row->effective_at,
|
||||
'expires_at' => $row->expires_at,
|
||||
];
|
||||
}
|
||||
if (! $this->hasTable('store_lifecycles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($result as $storeName => $periods) {
|
||||
$normalized = [];
|
||||
foreach ($periods as $period) {
|
||||
$last = array_key_last($normalized);
|
||||
if ($last !== null
|
||||
&& (string) ($normalized[$last]['company_name'] ?? '') === (string) ($period['company_name'] ?? '')) {
|
||||
$normalized[$last]['lifecycle_ids'][] = $period['lifecycle_id'];
|
||||
continue;
|
||||
}
|
||||
$period['lifecycle_ids'] = [$period['lifecycle_id']];
|
||||
$normalized[] = $period;
|
||||
$result = [];
|
||||
foreach (array_chunk($names, 100) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $this->select(
|
||||
"SELECT id, erp_store_name, company_name, effective_at, expires_at
|
||||
FROM store_lifecycles
|
||||
WHERE erp_store_name IN ($placeholders)
|
||||
ORDER BY erp_store_name, effective_at, id",
|
||||
$chunk,
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$result[(string) $row['erp_store_name']][] = [
|
||||
'lifecycle_id' => (int) $row['id'],
|
||||
'company_name' => $row['company_name'],
|
||||
'effective_at' => $row['effective_at'],
|
||||
'expires_at' => $row['expires_at'],
|
||||
];
|
||||
}
|
||||
foreach ($normalized as $index => &$period) {
|
||||
$period['expires_at'] = $period['expires_at']
|
||||
?: ($normalized[$index + 1]['effective_at'] ?? self::DEFAULT_EXPIRES_AT);
|
||||
}
|
||||
unset($period);
|
||||
$result[$storeName] = $normalized;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,array{period:float,months:array<string,float>}> */
|
||||
private function lifecycleSales(array $names, string $dailyGeneration, string $lifecycleGeneration): array
|
||||
private function lifecycleSales(array $names): array
|
||||
{
|
||||
if ($names === [] || ! $this->hasTable('store_lifecycles') || ! $this->hasTable('bills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$month = $this->monthExpression('b.occurred_on');
|
||||
foreach (array_chunk($names, 500) as $chunk) {
|
||||
foreach (array_chunk($names, 100) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $this->connection->select(
|
||||
"WITH lifecycle_periods AS (
|
||||
SELECT id, erp_store_name, effective_at,
|
||||
COALESCE(expires_at,
|
||||
LEAD(effective_at) OVER (PARTITION BY erp_store_name ORDER BY effective_at, id),
|
||||
'2999-01-01 00:00:00') AS computed_expires_at
|
||||
FROM store_report_lifecycle_rows
|
||||
WHERE generation = ?
|
||||
)
|
||||
SELECT l.id AS lifecycle_id, {$month} AS bill_month,
|
||||
$rows = $this->select(
|
||||
"SELECT l.id AS lifecycle_id, substr(b.occurred_at, 1, 7) AS bill_month,
|
||||
SUM(COALESCE(b.income, 0)) AS income_total
|
||||
FROM lifecycle_periods AS l
|
||||
INNER JOIN store_report_daily_rows AS b
|
||||
ON b.generation = ? AND b.store_name = l.erp_store_name
|
||||
AND b.occurred_on >= DATE(COALESCE(l.effective_at, '1000-01-01 00:00:00'))
|
||||
AND b.occurred_on < DATE(l.computed_expires_at)
|
||||
FROM store_lifecycles AS l
|
||||
INNER JOIN bills AS b
|
||||
ON b.store_name = l.erp_store_name
|
||||
AND b.occurred_at IS NOT NULL AND b.occurred_at <> ''
|
||||
AND b.occurred_at >= COALESCE(NULLIF(l.effective_at, ''), ?)
|
||||
AND b.occurred_at < COALESCE(NULLIF(l.expires_at, ''), ?)
|
||||
WHERE l.erp_store_name IN ($placeholders)
|
||||
GROUP BY l.id, {$month}",
|
||||
[$lifecycleGeneration, $dailyGeneration, ...$chunk],
|
||||
GROUP BY l.id, substr(b.occurred_at, 1, 7)",
|
||||
[self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, ...$chunk],
|
||||
);
|
||||
$this->collectSales($result, $rows, 'lifecycle_id');
|
||||
}
|
||||
@ -211,19 +210,24 @@ class StoreReportRepository
|
||||
}
|
||||
|
||||
/** @return array<string,array{period:float,months:array<string,float>}> */
|
||||
private function fallbackSales(array $names, string $generation): array
|
||||
private function fallbackSales(array $names): array
|
||||
{
|
||||
if ($names === [] || ! $this->hasTable('bills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$month = $this->monthExpression('occurred_on');
|
||||
foreach (array_chunk($names, 500) as $chunk) {
|
||||
foreach (array_chunk($names, 100) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $this->connection->select(
|
||||
"SELECT store_name, {$month} AS bill_month,
|
||||
$rows = $this->select(
|
||||
"SELECT store_name, substr(occurred_at, 1, 7) AS bill_month,
|
||||
SUM(COALESCE(income, 0)) AS income_total
|
||||
FROM store_report_daily_rows
|
||||
WHERE generation = ? AND store_name IN ($placeholders)
|
||||
GROUP BY store_name, {$month}",
|
||||
[$generation, ...$chunk],
|
||||
FROM bills
|
||||
WHERE store_name IN ($placeholders)
|
||||
AND occurred_at IS NOT NULL AND occurred_at <> ''
|
||||
AND occurred_at >= ? AND occurred_at < ?
|
||||
GROUP BY store_name, substr(occurred_at, 1, 7)",
|
||||
[...$chunk, self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT],
|
||||
);
|
||||
$this->collectSales($result, $rows, 'store_name');
|
||||
}
|
||||
@ -235,31 +239,41 @@ class StoreReportRepository
|
||||
private function collectSales(array &$result, array $rows, string $keyColumn): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$key = (string) $row->{$keyColumn};
|
||||
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
|
||||
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
|
||||
$key = (string) $row[$keyColumn];
|
||||
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row['income_total'];
|
||||
$result[$key]['months'][(string) $row['bill_month']] = ($result[$key]['months'][(string) $row['bill_month']] ?? 0.0) + (float) $row['income_total'];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,array{period:float,months:array<string,float>}> $sales */
|
||||
private function combinedSales(array $sales, array $ids): array
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function select(string $query, array $bindings = []): array
|
||||
{
|
||||
$combined = ['period' => 0.0, 'months' => []];
|
||||
foreach ($ids as $id) {
|
||||
$row = $sales[(string) $id] ?? ['period' => 0.0, 'months' => []];
|
||||
$combined['period'] += (float) $row['period'];
|
||||
foreach ($row['months'] as $month => $amount) {
|
||||
$combined['months'][$month] = ($combined['months'][$month] ?? 0.0) + (float) $amount;
|
||||
}
|
||||
}
|
||||
return $combined;
|
||||
$statement = $this->connection->prepare($query);
|
||||
$statement->execute($bindings);
|
||||
|
||||
return $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function monthExpression(string $column): string
|
||||
private function readOnlyConnection(): PDO
|
||||
{
|
||||
return $this->connection->getDriverName() === 'sqlite'
|
||||
? "strftime('%Y-%m', {$column})"
|
||||
: "DATE_FORMAT({$column}, '%Y-%m')";
|
||||
$path = realpath((string) config('database.connections.bills_sqlite.database'));
|
||||
if ($path === false) {
|
||||
throw new RuntimeException('店铺账单 SQLite 尚未挂载。');
|
||||
}
|
||||
|
||||
return new PDO(
|
||||
'sqlite:file:' . $path . '?mode=ro&immutable=1',
|
||||
null,
|
||||
null,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
|
||||
);
|
||||
}
|
||||
|
||||
/** SQLite Schema Builder 在只读/WAL 挂载下可能尝试写入,因此使用普通查询。 */
|
||||
private function hasTable(string $table): bool
|
||||
{
|
||||
// 本地账单文件由导入脚本一次性创建,避免 Laravel 在只读挂载上执行元数据写检查。
|
||||
return in_array($table, ['bills', 'store_lifecycles'], true);
|
||||
}
|
||||
|
||||
private function date(mixed $value, string $fallback): string
|
||||
@ -268,6 +282,38 @@ class StoreReportRepository
|
||||
return $value === '' ? $fallback : substr($value, 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期字段的行级过滤:主体、能否变更、负责人、利润中心精确匹配;生效、失效时间左闭右开。
|
||||
*
|
||||
* 店铺负责人/部门负责人/利润中心展示店铺账号镜像(PostgreSQL)覆盖后的值,
|
||||
* 与页面筛选保持一致,因此在这里按行值匹配而不是上游镜像 SQL。
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @param array<string,string> $filters
|
||||
*/
|
||||
private function matchesFilters(array $row, string $expiresAt, array $filters): bool
|
||||
{
|
||||
foreach (['entity', 'can_change', 'store_manager', 'department_manager', 'profit_center'] as $key) {
|
||||
$expected = trim((string) ($filters[$key] ?? ''));
|
||||
if ($expected !== '' && trim((string) ($row[$key] ?? '')) !== $expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['effective_at' => (string) $row['effective_at'], 'invalid_at' => $expiresAt] as $key => $value) {
|
||||
$from = trim((string) ($filters[$key . '_from'] ?? ''));
|
||||
$to = trim((string) ($filters[$key . '_to'] ?? ''));
|
||||
if ($from !== '' && strcmp($value, $from) < 0) {
|
||||
return false;
|
||||
}
|
||||
if ($to !== '' && strcmp($value, $to) >= 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function status(mixed $value): ?string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
@ -277,4 +323,10 @@ class StoreReportRepository
|
||||
default => $value === null || $value === '' ? null : (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
/** 能否变更:平台为唯品会MP或淘工厂时为“否”,其余(含平台为空)为“是”。 */
|
||||
private function canChange(mixed $platform): string
|
||||
{
|
||||
return in_array(trim((string) ($platform ?? '')), self::UNCHANGEABLE_PLATFORMS, true) ? '否' : '是';
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,6 +361,11 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
|
||||
<span>{{ $upstreamError }}</span>
|
||||
</div>
|
||||
@elseif ($directoryTotal > 0)
|
||||
<div class="directory-table-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">cloud_done</span>
|
||||
<span>已从共享业务镜像读取 {{ $directoryTotal }} 条数据,当前页面为只读展示。</span>
|
||||
</div>
|
||||
@elseif (($directory['status'] ?? null) === '既有只读上游')
|
||||
<div class="directory-table-notice" role="status">
|
||||
<span class="material-icons-outlined" aria-hidden="true">visibility</span>
|
||||
@ -378,12 +383,63 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (empty($directoryFields))
|
||||
<div class="directory-table-empty">
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit_note</span>
|
||||
<span>{{ $directory['empty'] ?? '字段待定义。' }}</span>
|
||||
</div>
|
||||
@else
|
||||
@if (!empty($directoryFields))
|
||||
@if (($page['slug'] ?? '') === 'store-report')
|
||||
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="店铺维度报表筛选查询栏">
|
||||
@php
|
||||
$storeFilterTextFields = [
|
||||
['name' => 'erp_store_name', 'label' => '店铺名称:'],
|
||||
['name' => 'store_id', 'label' => '店铺ID:'],
|
||||
['name' => 'profit_center', 'label' => '利润中心:'],
|
||||
['name' => 'store_manager', 'label' => '店铺负责人:'],
|
||||
['name' => 'department_manager', 'label' => '部门负责人:'],
|
||||
['name' => 'entity', 'label' => '主体:'],
|
||||
['name' => 'company_type', 'label' => '公司类型:'],
|
||||
];
|
||||
$storeFilterDateFields = [
|
||||
['name' => 'opened_at', 'label' => '开店时间:'],
|
||||
['name' => 'effective_at', 'label' => '生效时间:'],
|
||||
['name' => 'invalid_at', 'label' => '失效时间:'],
|
||||
];
|
||||
@endphp
|
||||
@foreach ($storeFilterTextFields as $filter)
|
||||
<label class="installation-filter-field">
|
||||
<span>{{ $filter['label'] }}</span>
|
||||
<input type="text" name="{{ $filter['name'] }}" value="{{ request($filter['name']) }}" placeholder="按原值精确查询">
|
||||
</label>
|
||||
@endforeach
|
||||
<label class="installation-filter-field">
|
||||
<span>状态:</span>
|
||||
<select name="status">
|
||||
<option value="">全部</option>
|
||||
@foreach (['0' => '开店中', '3' => '关店中', '6' => '关店'] as $value => $label)
|
||||
<option value="{{ $value }}" @selected((string) request('status') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="installation-filter-field">
|
||||
<span>能否变更:</span>
|
||||
<select name="can_change">
|
||||
<option value="">全部</option>
|
||||
@foreach (['是', '否'] as $value)
|
||||
<option value="{{ $value }}" @selected((string) request('can_change') === $value)>{{ $value }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
@foreach ($storeFilterDateFields as $filter)
|
||||
<label class="installation-filter-field">
|
||||
<span>{{ $filter['label'] }}</span>
|
||||
<input type="date" name="{{ $filter['name'] }}_from" value="{{ request($filter['name'] . '_from') }}" aria-label="{{ $filter['label'] }}开始(含)">
|
||||
<span>至</span>
|
||||
<input type="date" name="{{ $filter['name'] }}_to" value="{{ request($filter['name'] . '_to') }}" aria-label="{{ $filter['label'] }}结束(不含)">
|
||||
</label>
|
||||
@endforeach
|
||||
<div class="installation-filter-actions">
|
||||
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
|
||||
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
<div class="directory-table-tools" data-directory-tools>
|
||||
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
|
||||
<input id="directory-search-{{ $page['slug'] ?? 'page' }}" type="search" placeholder="搜索当前页数据..." data-directory-search>
|
||||
@ -424,6 +480,10 @@
|
||||
$display = $value;
|
||||
if ($kind === 'secret') {
|
||||
$display = filled($value) ? '已配置' : '—';
|
||||
} elseif ($kind === 'secret_status') {
|
||||
$display = $value === null || $value === ''
|
||||
? '状态未同步'
|
||||
: ($value ? '已设置' : '未设置');
|
||||
} elseif ($kind === 'date' && filled($value)) {
|
||||
$display = substr((string) $value, 0, 10);
|
||||
} elseif ($kind === 'number' && is_numeric($value)) {
|
||||
|
||||
@ -158,6 +158,10 @@
|
||||
['company-management', 'basic-information', '公司基础信息', 'badge'],
|
||||
['trademark-management', 'information', '商标基础信息', 'workspace_premium'],
|
||||
]],
|
||||
['title' => '规则信息', 'items' => [
|
||||
['trademark-management', 'subject-trademark-rules', '主体商标规则', 'rule'],
|
||||
['trademark-management', 'store-change-rules', '店铺变更规则', 'rule'],
|
||||
]],
|
||||
['title' => '业务进度', 'items' => [
|
||||
['company-management', 'company-progress', '公司进度表', 'timeline'],
|
||||
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
|
||||
@ -174,6 +178,9 @@
|
||||
foreach (\App\Utilities\SidebarPermissions::navigationFor(user()) as $visibleSection) {
|
||||
foreach ($visibleSection['pages'] ?? [] as $visiblePage) {
|
||||
$visiblePages[$visibleSection['slug'] . '/' . $visiblePage['slug']] = $visiblePage['title'];
|
||||
foreach ($visiblePage['children'] ?? [] as $visibleChild) {
|
||||
$visiblePages[$visibleSection['slug'] . '/' . $visibleChild['slug']] = $visibleChild['title'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$prototypeGroups = array_values(array_filter(array_map(function ($group) use ($visiblePages) {
|
||||
|
||||
@ -236,7 +236,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
'entity' => '测试主体',
|
||||
'company_type' => '普通公司',
|
||||
'effective_at' => '2026-07-01',
|
||||
'invalid_at' => '2999-01-01',
|
||||
'invalid_at' => '存续中',
|
||||
'period_sales' => 25916.80,
|
||||
'sales_07' => 25916.80,
|
||||
]], 1, 50, 1, ['path' => '/'])
|
||||
@ -254,7 +254,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
->assertSeeText('测试店铺')
|
||||
->assertSeeText('测试主体')
|
||||
->assertSeeText('2026-07-01')
|
||||
->assertSeeText('2999-01-01')
|
||||
->assertSeeText('存续中')
|
||||
->assertSeeText('25,916.80')
|
||||
->assertSeeText('关店中')
|
||||
->assertSeeText('共享镜像');
|
||||
@ -299,9 +299,15 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
array_column($sections['company-management']['pages'], 'slug')
|
||||
);
|
||||
$this->assertSame(
|
||||
['information', 'trademark-progress'],
|
||||
['information', 'trademark-progress', 'rules-information'],
|
||||
array_column($sections['trademark-management']['pages'], 'slug')
|
||||
);
|
||||
$rulesPage = collect($sections['trademark-management']['pages'])->firstWhere('slug', 'rules-information');
|
||||
$this->assertNotNull($rulesPage);
|
||||
$this->assertSame(
|
||||
['subject-trademark-rules', 'store-change-rules'],
|
||||
array_column($rulesPage['children'], 'slug')
|
||||
);
|
||||
$this->assertSame(
|
||||
['store-report', 'basic-information', 'store-accounts', 'change-progress'],
|
||||
array_column($sections['store-management']['pages'], 'slug')
|
||||
@ -374,7 +380,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
->assertSeeText('保管部门');
|
||||
|
||||
$expectedHeaders = [
|
||||
['section' => 'company-management', 'page' => 'subject-operating-report', 'headers' => ['市', '主体', '连续12个月主体累计销量']],
|
||||
['section' => 'company-management', 'page' => 'subject-operating-report', 'headers' => ['市', '主体', '最近12个月累计收入', '1月收入', '12月收入']],
|
||||
['section' => 'company-management', 'page' => 'personnel-inventory', 'headers' => ['人员名称', '税务局密码', '可担任公司数量']],
|
||||
['section' => 'company-management', 'page' => 'company-progress', 'headers' => ['公司进度表字段尚未定义']],
|
||||
['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']],
|
||||
|
||||
@ -10,6 +10,45 @@ use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PrototypePageTest extends FeatureTestCase
|
||||
{
|
||||
public function testStoreAccountPageRendersMirroredRowsAndDoesNotShowEmptyNotice(): void
|
||||
{
|
||||
$rows = new LengthAwarePaginator([(object) [
|
||||
'id' => 'account-1',
|
||||
'store_name' => '测试店铺账号数据',
|
||||
'operation_owner' => '测试运营',
|
||||
'operation_department_head' => '测试部门负责人',
|
||||
'operation_director' => '测试运营总监',
|
||||
'profit_center' => '测试利润中心',
|
||||
'account_type' => '主账号',
|
||||
'account' => 'test-account',
|
||||
'password_configured' => null,
|
||||
'bound_phone' => '13800000000',
|
||||
'verification_contact' => '测试对接人',
|
||||
'account_group_name' => '测试分组',
|
||||
'created_at' => '2026-09-01 10:00:00',
|
||||
'updated_at' => '2026-09-02 10:00:00',
|
||||
]], 958, 50, 1, ['path' => '/']);
|
||||
|
||||
$this->mock(\App\Business\UpstreamBusinessInformation::class)
|
||||
->shouldReceive('paginate')
|
||||
->once()
|
||||
->with('store-management', 'store-accounts', 50)
|
||||
->andReturn($rows);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'store-management',
|
||||
'page' => 'store-accounts',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('测试店铺账号数据')
|
||||
->assertSeeText('共 958 条')
|
||||
->assertSeeText('已从共享业务镜像读取 958 条数据')
|
||||
->assertSeeText('主账号')
|
||||
->assertSeeText('状态未同步')
|
||||
->assertDontSeeText('MAIN')
|
||||
->assertDontSeeText('共享镜像中暂未读取到店铺账号数据');
|
||||
}
|
||||
|
||||
public function testNavigationUsesConfiguredPermissionsAndTitles(): void
|
||||
{
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
@ -43,12 +82,12 @@ class PrototypePageTest extends FeatureTestCase
|
||||
->andReturn(new LengthAwarePaginator([
|
||||
(object) [
|
||||
'entity' => '测试主体',
|
||||
'erp_store_name' => '测试店铺',
|
||||
'recent_three_month_sales_1' => 10,
|
||||
'recent_three_month_sales_2' => 20,
|
||||
'recent_three_month_sales_3' => 30,
|
||||
'store_sales_12m' => 60,
|
||||
'entity_sales_12m' => 60,
|
||||
'city' => '常州市',
|
||||
'company_type' => '有限公司',
|
||||
'rolling_12m_income' => 100,
|
||||
'can_change_entity' => null,
|
||||
'plan' => null,
|
||||
'new_entity' => null,
|
||||
],
|
||||
], 1, 50, 1, ['path' => '/']));
|
||||
$this->app->instance(UpstreamBusinessInformation::class, $service);
|
||||
@ -59,11 +98,10 @@ class PrototypePageTest extends FeatureTestCase
|
||||
'page' => 'subject-operating-report',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('共享镜像')
|
||||
->assertSeeText('收入汇总')
|
||||
->assertSeeText('测试主体')
|
||||
->assertSeeText('测试店铺')
|
||||
->assertSeeText('共 1 条')
|
||||
->assertSeeText('当前显示团队共享业务镜像数据')
|
||||
->assertSeeText('已从共享业务镜像读取')
|
||||
->assertDontSeeText('经营报表尚未同步。完成店铺主体和销量镜像接入后')
|
||||
->assertDontSeeText('经营报表共享镜像尚未完成同步。');
|
||||
}
|
||||
@ -75,7 +113,7 @@ class PrototypePageTest extends FeatureTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testEntityReportShowsPreviousThreeCompleteMonthsAsSeparateSalesHeaders(): void
|
||||
public function testEntityReportShowsCurrentYearIncomeHeadersAndRollingTotal(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-08-20 12:00:00');
|
||||
|
||||
@ -86,11 +124,11 @@ class PrototypePageTest extends FeatureTestCase
|
||||
|
||||
$augustResponse
|
||||
->assertOk()
|
||||
->assertSeeTextInOrder(['5月销量', '6月销量', '7月销量'])
|
||||
->assertDontSeeText('8月销量')
|
||||
->assertDontSeeText('展示近3个月月度销量');
|
||||
->assertSeeTextInOrder(['最近12个月累计收入', '1月收入', '12月收入'])
|
||||
->assertDontSeeText('ERP系统店铺名称')
|
||||
->assertDontSeeText('连续12个月店铺累计收入');
|
||||
|
||||
Carbon::setTestNow('2026-09-01 00:00:00');
|
||||
Carbon::setTestNow('2027-01-01 00:00:00');
|
||||
|
||||
$septemberResponse = $this->get(route('business.pages.show', [
|
||||
'section' => 'company-management',
|
||||
@ -99,12 +137,6 @@ class PrototypePageTest extends FeatureTestCase
|
||||
|
||||
$septemberResponse
|
||||
->assertOk()
|
||||
->assertSeeTextInOrder(['6月销量', '7月销量', '8月销量'])
|
||||
->assertDontSeeText('5月销量')
|
||||
->assertDontSeeText('9月销量');
|
||||
|
||||
Carbon::setTestNow('2027-01-01 00:00:00');
|
||||
$this->get(route('business.pages.show', ['section' => 'company-management', 'page' => 'subject-operating-report']))
|
||||
->assertOk()->assertSeeTextInOrder(['10月销量', '11月销量', '12月销量']);
|
||||
->assertSeeTextInOrder(['最近12个月累计收入', '1月收入', '12月收入']);
|
||||
}
|
||||
}
|
||||
|
||||
49
tests/Unit/Business/RegisteredAddressCityTest.php
Normal file
49
tests/Unit/Business/RegisteredAddressCityTest.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Business;
|
||||
|
||||
use App\Business\RegisteredAddressCity;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/** 注册地址解析地级市:直辖市直接返回,普通地址取省后第一个地级行政区。 */
|
||||
final class RegisteredAddressCityTest extends TestCase
|
||||
{
|
||||
public static function addressProvider(): array
|
||||
{
|
||||
return [
|
||||
'省份加地级市加区' => ['江苏省常州市天宁区XX路XX号', '常州市'],
|
||||
'无省前缀' => ['常州市新北区XX路XX号', '常州市'],
|
||||
'北京直辖市区' => ['北京市朝阳区XX路XX号', '北京市'],
|
||||
'上海直辖市新区' => ['上海市浦东新区XX路XX号', '上海市'],
|
||||
'天津直辖市' => ['天津市滨海新区XX路XX号', '天津市'],
|
||||
'重庆直辖市' => ['重庆市江北区XX路XX号', '重庆市'],
|
||||
'带中国前缀' => ['中国江苏省苏州市工业园区XX路XX号', '苏州市'],
|
||||
'两字地级市' => ['广东省深圳市南山区XX路XX号', '深圳市'],
|
||||
'三字地级市' => ['黑龙江省哈尔滨市南岗区XX路XX号', '哈尔滨市'],
|
||||
'无区县地级市' => ['广东省东莞市XX路XX号', '东莞市'],
|
||||
'自治区首府' => ['内蒙古自治区呼和浩特市新城区XX路XX号', '呼和浩特市'],
|
||||
'长自治区名' => ['新疆维吾尔自治区乌鲁木齐市天山区XX路XX号', '乌鲁木齐市'],
|
||||
'自治州' => ['云南省大理白族自治州大理市XX路XX号', '大理白族自治州'],
|
||||
'最长自治州名' => ['新疆维吾尔自治区克孜勒苏柯尔克孜自治州阿图什市XX路', '克孜勒苏柯尔克孜自治州'],
|
||||
'园区名不当地级市' => ['(浙江)自由贸易试验区舟山市定海区XX路', null],
|
||||
'省直辖县级市' => ['海南省琼海市XX路XX号', '琼海市'],
|
||||
'本地地址无省市前缀的开发区' => ['常州经济开发区横林镇双蓉村和顺路66号', '常州市'],
|
||||
'本地地址无省市前缀的区' => ['武进国家高新技术产业开发区天安数码城10幢101室', '常州市'],
|
||||
'本地地址区县镇' => ['武进区横林镇XX路XX号', '常州市'],
|
||||
'本地县级市' => ['溧阳市XX镇XX路XX号', '溧阳市'],
|
||||
'无行政区划' => ['XX路XX号3幢', null],
|
||||
'只有省份' => ['江苏省', null],
|
||||
'无市后缀城市名' => ['江苏常州XX路XX号', null],
|
||||
'空地址' => ['', null],
|
||||
'null地址' => [null, null],
|
||||
'港澳特区' => ['香港特别行政区中环XX号', null],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('addressProvider')]
|
||||
public function test_parse_extracts_prefecture_city(mixed $address, ?string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, RegisteredAddressCity::parse($address));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreAccountMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private StoreAccountMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [
|
||||
StoreAccountMirrorRepository::DATASET => [
|
||||
'source_key' => 'database_4',
|
||||
'schema' => 'external_store_accounts',
|
||||
'object' => 'accounts_v1',
|
||||
'target' => 'store_accounts',
|
||||
'keys' => ['id'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('store_accounts', function (Blueprint $table): void {
|
||||
$table->string('id')->primary();
|
||||
foreach (['storeName', 'operationOwner', 'operationDepartmentHead', 'operationDirector', 'profitCenter', 'accountType', 'account', 'boundPhone', 'verificationContact', 'accountGroupName', 'createdAt', 'updatedAt'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
});
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => StoreAccountMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-04 10:00:00',
|
||||
]);
|
||||
|
||||
$this->repository = new StoreAccountMirrorRepository(
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
new GenericBusinessMirrorRepository(app('db')),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_maps_account_type_codes_to_business_labels(): void
|
||||
{
|
||||
foreach (['MAIN' => '主账号', 'SUB' => '子账号', '/' => null] as $type => $expected) {
|
||||
DB::connection('business_mirror')->table('store_accounts')->insert([
|
||||
'id' => 'account-' . md5($type),
|
||||
'storeName' => '测试店铺',
|
||||
'accountType' => $type,
|
||||
'account' => 'test-account',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = collect($this->repository->paginate(1, 50)['data'])->keyBy('id');
|
||||
|
||||
$this->assertSame('主账号', $rows['account-' . md5('MAIN')]['account_type']);
|
||||
$this->assertSame('子账号', $rows['account-' . md5('SUB')]['account_type']);
|
||||
$this->assertNull($rows['account-' . md5('/')]['account_type']);
|
||||
$this->assertNull($rows['account-' . md5('MAIN')]['password_configured']);
|
||||
}
|
||||
}
|
||||
158
tests/Unit/EcommerceFinance/StoreReportRepositoryFilterTest.php
Normal file
158
tests/Unit/EcommerceFinance/StoreReportRepositoryFilterTest.php
Normal file
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
|
||||
use PDO;
|
||||
use Tests\TestCase;
|
||||
|
||||
/** 店铺维度报表查询条件:生命周期字段按左闭右开区间过滤。 */
|
||||
class StoreReportRepositoryFilterTest extends TestCase
|
||||
{
|
||||
private string $databasePath;
|
||||
private StoreReportRepository $repository;
|
||||
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private array $stores = [
|
||||
['erp_store_name' => '店铺A', 'company_name' => '旧公司', 'store_status' => '0', 'platform' => '唯品会MP'],
|
||||
['erp_store_name' => '店铺B', 'company_name' => '主体丙', 'store_status' => '0', 'platform' => '天猫'],
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->databasePath = sys_get_temp_dir() . '/store-report-filters-' . uniqid() . '.sqlite';
|
||||
$pdo = new PDO('sqlite:' . $this->databasePath);
|
||||
$pdo->exec('CREATE TABLE bills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
store_name TEXT NOT NULL,
|
||||
occurred_at TEXT,
|
||||
income NUMERIC NOT NULL DEFAULT 0,
|
||||
expense NUMERIC NOT NULL DEFAULT 0,
|
||||
bill_item TEXT,
|
||||
record_code TEXT NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(platform, record_code)
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE store_lifecycles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
erp_store_name TEXT NOT NULL,
|
||||
company_name TEXT,
|
||||
effective_at TEXT,
|
||||
expires_at TEXT,
|
||||
source_file TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(erp_store_name, effective_at, expires_at)
|
||||
)');
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO store_lifecycles (erp_store_name, company_name, effective_at, expires_at, source_file, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
$insert->execute(['店铺A', '主体甲', '2026-07-15 00:00:00', '2026-08-01 00:00:00', 'test', '{}']);
|
||||
$insert->execute(['店铺A', '主体乙', '2026-08-10 00:00:00', null, 'test', '{}']);
|
||||
$pdo = null;
|
||||
|
||||
config(['database.connections.bills_sqlite.database' => $this->databasePath]);
|
||||
$this->repository = new StoreReportRepository(app('db'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->databasePath);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_without_filters_all_periods_are_returned(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026);
|
||||
|
||||
$this->assertSame(
|
||||
['主体甲', '主体乙', '主体丙'],
|
||||
array_column($rows, 'entity'),
|
||||
);
|
||||
$this->assertSame('存续中', $rows[1]['invalid_at']);
|
||||
$this->assertSame('2026-08-01', $rows[0]['invalid_at']);
|
||||
}
|
||||
|
||||
public function test_effective_at_filter_is_left_closed_and_right_open(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026, [
|
||||
'effective_at_from' => '2026-07-15',
|
||||
'effective_at_to' => '2026-08-10',
|
||||
]);
|
||||
|
||||
// 左边界 07-15 命中主体甲;右边界 08-10 命中的主体乙被排除(右开);
|
||||
// 无生命周期兜底行(默认 2026-07-01)低于左边界被排除。
|
||||
$this->assertSame(['主体甲'], array_column($rows, 'entity'));
|
||||
|
||||
$rows = $this->repository->rows($this->stores, 2026, [
|
||||
'effective_at_from' => '2026-07-16',
|
||||
]);
|
||||
|
||||
$this->assertSame(['主体乙'], array_column($rows, 'entity'));
|
||||
}
|
||||
|
||||
public function test_invalid_at_filter_uses_resolved_expiry_and_excludes_ongoing(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026, [
|
||||
'invalid_at_from' => '2026-07-01',
|
||||
'invalid_at_to' => '2026-09-01',
|
||||
]);
|
||||
|
||||
// 主体乙与兜底行均为存续(2999-01-01),不在失效区间内。
|
||||
$this->assertSame(['主体甲'], array_column($rows, 'entity'));
|
||||
|
||||
$rows = $this->repository->rows($this->stores, 2026, [
|
||||
'invalid_at_from' => '2026-08-01',
|
||||
]);
|
||||
|
||||
// 只有下界时,存续中(2999-01-01)尚未失效,同样满足条件。
|
||||
$this->assertSame(['主体甲', '主体乙', '主体丙'], array_column($rows, 'entity'));
|
||||
}
|
||||
|
||||
public function test_entity_filter_matches_lifecycle_and_fallback_companies(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026, ['entity' => '主体甲']);
|
||||
|
||||
$this->assertSame(['主体甲'], array_column($rows, 'entity'));
|
||||
|
||||
$rows = $this->repository->rows($this->stores, 2026, ['entity' => '主体丙']);
|
||||
|
||||
$this->assertSame(['主体丙'], array_column($rows, 'entity'));
|
||||
$this->assertSame('存续中', $rows[0]['invalid_at']);
|
||||
}
|
||||
|
||||
public function test_can_change_depends_on_platform(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026);
|
||||
|
||||
// 唯品会MP → 否;天猫 → 是。
|
||||
$this->assertSame(['否', '否', '是'], array_column($rows, 'can_change'));
|
||||
|
||||
// 平台为空或“唯品会”(非唯品会MP)同样视为可变更。
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '店铺C', 'company_name' => null, 'store_status' => '0'],
|
||||
['erp_store_name' => '店铺D', 'company_name' => null, 'store_status' => '0', 'platform' => '唯品会'],
|
||||
['erp_store_name' => '店铺E', 'company_name' => null, 'store_status' => '0', 'platform' => '淘工厂'],
|
||||
], 2026);
|
||||
|
||||
$this->assertSame(['是', '是', '否'], array_column($rows, 'can_change'));
|
||||
}
|
||||
|
||||
public function test_can_change_filter_matches_row_value(): void
|
||||
{
|
||||
$rows = $this->repository->rows($this->stores, 2026, ['can_change' => '是']);
|
||||
|
||||
$this->assertSame(['主体丙'], array_column($rows, 'entity'));
|
||||
|
||||
$rows = $this->repository->rows($this->stores, 2026, ['can_change' => '否']);
|
||||
|
||||
$this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity'));
|
||||
}
|
||||
}
|
||||
@ -3,118 +3,139 @@
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Business\UpstreamBusinessInformation;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
||||
use Mockery;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 店铺维度报表数据组合:本地账单 SQLite 的生命周期拆行与收入归属。
|
||||
*/
|
||||
class StoreReportRepositoryTest extends TestCase
|
||||
{
|
||||
private string $databasePath;
|
||||
private StoreReportRepository $repository;
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
DB::connection('business_mirror')->table('store_report_import_states')->insert([
|
||||
['dataset_key' => StoreReportRepository::DAILY_DATASET, 'status' => 'succeeded', 'active_generation' => 'bills-v1'],
|
||||
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'status' => 'succeeded', 'active_generation' => 'lifecycles-v1'],
|
||||
]);
|
||||
|
||||
$this->databasePath = sys_get_temp_dir() . '/store-report-rows-' . uniqid() . '.sqlite';
|
||||
$pdo = new PDO('sqlite:' . $this->databasePath);
|
||||
$pdo->exec('CREATE TABLE bills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
store_name TEXT NOT NULL,
|
||||
occurred_at TEXT,
|
||||
income NUMERIC NOT NULL DEFAULT 0,
|
||||
expense NUMERIC NOT NULL DEFAULT 0,
|
||||
bill_item TEXT,
|
||||
record_code TEXT NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(platform, record_code)
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE store_lifecycles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
erp_store_name TEXT NOT NULL,
|
||||
company_name TEXT,
|
||||
effective_at TEXT,
|
||||
expires_at TEXT,
|
||||
source_file TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(erp_store_name, effective_at, expires_at)
|
||||
)');
|
||||
$this->pdo = $pdo;
|
||||
config(['database.connections.bills_sqlite.database' => $this->databasePath]);
|
||||
$this->repository = new StoreReportRepository(app('db'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
$this->pdo = null;
|
||||
@unlink($this->databasePath);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_lifecycle_boundaries_split_monthly_income_without_double_counting(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('store_report_lifecycle_rows')->insert([
|
||||
$this->lifecycle(1, '店铺A', '主体甲', '2026-05-01', '2026-07-01'),
|
||||
$this->lifecycle(2, '店铺A', '主体乙', '2026-07-01', null),
|
||||
]);
|
||||
$db->table('store_report_daily_rows')->insert([
|
||||
$this->daily(1, '店铺A', '2026-04-30', 9),
|
||||
$this->daily(2, '店铺A', '2026-05-01', 100),
|
||||
$this->daily(3, '店铺A', '2026-06-30', 60),
|
||||
$this->daily(4, '店铺A', '2026-07-01', 70),
|
||||
$this->daily(5, '店铺A', '2026-08-01', 80),
|
||||
$this->daily(6, '店铺A', '2027-01-01', 5),
|
||||
]);
|
||||
$this->insertLifecycle('店铺A', '主体甲', '2026-05-01 00:00:00', '2026-07-01 00:00:00');
|
||||
$this->insertLifecycle('店铺A', '主体乙', '2026-07-01 00:00:00', null);
|
||||
$this->insertBill('店铺A', '2026-04-30 10:00:00', 9);
|
||||
$this->insertBill('店铺A', '2026-05-01 00:00:00', 100);
|
||||
$this->insertBill('店铺A', '2026-06-30 23:59:59', 60);
|
||||
$this->insertBill('店铺A', '2026-07-01 00:00:00', 70);
|
||||
$this->insertBill('店铺A', '2026-08-01 10:00:00', 80);
|
||||
$this->insertBill('店铺A', '2027-01-01 10:00:00', 5);
|
||||
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '店铺A', 'store_id' => 'STORE-001', 'company_name' => '旧主体', 'store_status' => '3'],
|
||||
['erp_store_name' => '店铺A', 'company_name' => '旧主体', 'store_status' => '3'],
|
||||
], 2026);
|
||||
|
||||
$this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity'));
|
||||
$this->assertSame('STORE-001', $rows[0]['store_id']);
|
||||
// 主体切换日 07-01 的账单只归主体乙(左闭右开),切换前的 04-30 不计入。
|
||||
$this->assertSame([160.0, 155.0], array_column($rows, 'period_sales'));
|
||||
$this->assertSame([100.0, 0.0], array_column($rows, 'sales_05'));
|
||||
$this->assertSame([0.0, 70.0], array_column($rows, 'sales_07'));
|
||||
$this->assertSame(['关店中', '关店中'], array_column($rows, 'status'));
|
||||
$this->assertSame(['2026-07-01', '存续中'], array_column($rows, 'invalid_at'));
|
||||
}
|
||||
|
||||
public function test_failed_refresh_keeps_the_previous_active_generation_readable(): void
|
||||
public function test_fallback_row_uses_company_table_subject_and_default_window(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::DAILY_DATASET)
|
||||
->update(['status' => 'failed', 'pending_generation' => null]);
|
||||
DB::connection('business_mirror')->table('store_report_daily_rows')->insert(
|
||||
$this->daily(1, '店铺A', '2026-05-03', 25.5),
|
||||
);
|
||||
// 未匹配生命周期时按默认窗口 [2026-07-01, 2999-01-01) 归属账单。
|
||||
$this->insertBill('店铺A', '2026-05-03 10:00:00', 25.5);
|
||||
$this->insertBill('店铺A', '2026-07-05 10:00:00', 30);
|
||||
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'opened_at' => '2026-05-01'],
|
||||
], 2026);
|
||||
|
||||
$this->assertSame(25.5, $rows[0]['period_sales']);
|
||||
$this->assertSame(25.5, $rows[0]['sales_05']);
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame(30.0, $rows[0]['period_sales']);
|
||||
$this->assertSame(30.0, $rows[0]['sales_07']);
|
||||
$this->assertSame('公司表主体', $rows[0]['entity']);
|
||||
$this->assertSame('2026-07-01', $rows[0]['effective_at']);
|
||||
$this->assertSame('存续中', $rows[0]['invalid_at']);
|
||||
}
|
||||
|
||||
public function test_missing_first_import_is_reported_instead_of_returning_misleading_zeroes(): void
|
||||
public function test_missing_sqlite_file_is_reported_instead_of_returning_misleading_zeroes(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::DAILY_DATASET)->delete();
|
||||
config(['database.connections.bills_sqlite.database' => sys_get_temp_dir() . '/store-report-missing-' . uniqid() . '.sqlite']);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('中央同步中');
|
||||
$this->expectExceptionMessage('店铺账单 SQLite 尚未挂载');
|
||||
$this->repository->rows([['erp_store_name' => '店铺A']], 2026);
|
||||
}
|
||||
|
||||
public function test_upstream_business_information_exposes_shared_store_report_rows(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([
|
||||
$this->lifecycle(1, '店铺A', '主体甲', '2026-05-01', null),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
|
||||
$this->daily(1, '店铺A', '2026-06-30', 60),
|
||||
]);
|
||||
$this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null);
|
||||
$this->insertBill('店铺A', '2026-06-30 10:00:00', 60);
|
||||
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
|
||||
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
|
||||
$mirror->shouldReceive('allStores')->once()->andReturn([[
|
||||
'erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'store_status' => '3',
|
||||
]]);
|
||||
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
|
||||
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
|
||||
$service = new UpstreamBusinessInformation(
|
||||
Mockery::mock(UpstreamQueryCacheFactory::class),
|
||||
$mirror,
|
||||
Mockery::mock(InstallationDetailMirrorRepository::class),
|
||||
$this->repository,
|
||||
$accounts,
|
||||
);
|
||||
|
||||
$paginator = $service->paginate('store-management', 'store-report', 50);
|
||||
@ -124,96 +145,58 @@ class StoreReportRepositoryTest extends TestCase
|
||||
$this->assertSame(60.0, $paginator->items()[0]->period_sales);
|
||||
}
|
||||
|
||||
public function test_subject_operating_report_uses_real_recent_and_entity_sales(): void
|
||||
public function test_subject_operating_report_aggregates_income_by_entity(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-09-01 10:00:00');
|
||||
DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([
|
||||
$this->lifecycle(1, '店铺A', '主体甲', '2026-01-01', null),
|
||||
$this->lifecycle(2, '店铺B', '主体甲', '2026-01-01', null),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
|
||||
$this->daily(1, '店铺A', '2026-06-10', 10),
|
||||
$this->daily(2, '店铺A', '2026-07-10', 20),
|
||||
$this->daily(3, '店铺A', '2026-08-10', 30),
|
||||
$this->daily(4, '店铺B', '2026-08-11', 40),
|
||||
]);
|
||||
$this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null);
|
||||
$this->insertLifecycle('店铺B', '主体甲', '2026-01-01 00:00:00', null);
|
||||
$this->insertBill('店铺A', '2026-06-10 10:00:00', 10);
|
||||
$this->insertBill('店铺A', '2026-07-10 10:00:00', 20);
|
||||
$this->insertBill('店铺A', '2026-08-10 10:00:00', 30);
|
||||
$this->insertBill('店铺B', '2026-08-11 10:00:00', 40);
|
||||
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
|
||||
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
|
||||
$mirror->shouldReceive('allStores')->once()->andReturn([
|
||||
['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0'],
|
||||
['erp_store_name' => '店铺B', 'company_name' => '主体甲', 'store_status' => '0'],
|
||||
]);
|
||||
$mirror->shouldReceive('ready')->with('company-information.read')->andReturnTrue();
|
||||
$mirror->shouldReceive('companiesByNames')->andReturn([]);
|
||||
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
|
||||
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
|
||||
$service = new UpstreamBusinessInformation(
|
||||
Mockery::mock(UpstreamQueryCacheFactory::class),
|
||||
$mirror,
|
||||
Mockery::mock(InstallationDetailMirrorRepository::class),
|
||||
$this->repository,
|
||||
$accounts,
|
||||
);
|
||||
|
||||
$rows = $service->paginate('company-management', 'subject-operating-report', 50)->items();
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertSame(10.0, $rows[0]->recent_three_month_sales_1);
|
||||
$this->assertSame(20.0, $rows[0]->recent_three_month_sales_2);
|
||||
$this->assertSame(30.0, $rows[0]->recent_three_month_sales_3);
|
||||
$this->assertSame(60.0, $rows[0]->store_sales_12m);
|
||||
$this->assertSame(100.0, $rows[0]->entity_sales_12m);
|
||||
$this->assertSame(100.0, $rows[1]->entity_sales_12m);
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('主体甲', $rows[0]->entity);
|
||||
$this->assertSame(100.0, $rows[0]->rolling_12m_income);
|
||||
$this->assertSame(10.0, $rows[0]->income_06);
|
||||
$this->assertSame(20.0, $rows[0]->income_07);
|
||||
$this->assertSame(70.0, $rows[0]->income_08);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
private function insertBill(string $store, string $occurredAt, float $income): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('store_report_import_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->string('status');
|
||||
$table->string('active_generation')->nullable();
|
||||
$table->string('pending_generation')->nullable();
|
||||
});
|
||||
$schema->create('store_report_bill_rows', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('generation');
|
||||
$table->string('store_name');
|
||||
$table->dateTime('occurred_at')->nullable();
|
||||
$table->decimal('income', 20, 4)->default(0);
|
||||
});
|
||||
$schema->create('store_report_daily_rows', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('generation');
|
||||
$table->string('store_name');
|
||||
$table->date('occurred_on');
|
||||
$table->decimal('income', 20, 4)->default(0);
|
||||
$table->decimal('expense', 20, 4)->default(0);
|
||||
$table->dateTime('updated_at')->nullable();
|
||||
});
|
||||
$schema->create('store_report_lifecycle_rows', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('generation');
|
||||
$table->string('erp_store_name');
|
||||
$table->string('company_name')->nullable();
|
||||
$table->dateTime('effective_at')->nullable();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO bills (platform, type, store_name, occurred_at, income, expense, record_code, source_file, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)',
|
||||
);
|
||||
$statement->execute(['test', 'income', $store, $occurredAt, $income, uniqid(), 'test', '{}']);
|
||||
}
|
||||
|
||||
private function bill(int $id, string $store, string $time, float $income): array
|
||||
private function insertLifecycle(string $store, string $company, ?string $from, ?string $to): void
|
||||
{
|
||||
return ['id' => $id, 'generation' => 'bills-v1', 'store_name' => $store, 'occurred_at' => $time, 'income' => $income];
|
||||
}
|
||||
|
||||
private function daily(int $id, string $store, string $date, float $income): array
|
||||
{
|
||||
return [
|
||||
'id' => $id, 'generation' => 'bills-v1', 'store_name' => $store,
|
||||
'occurred_on' => $date, 'income' => $income, 'expense' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function lifecycle(int $id, string $store, string $company, ?string $from, ?string $to): array
|
||||
{
|
||||
return [
|
||||
'id' => $id, 'generation' => 'lifecycles-v1', 'erp_store_name' => $store,
|
||||
'company_name' => $company, 'effective_at' => $from, 'expires_at' => $to,
|
||||
];
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO store_lifecycles (erp_store_name, company_name, effective_at, expires_at, source_file, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
$statement->execute([$store, $company, $from, $to, 'test', '{}']);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user