feat: 接入店铺维度报表真实账单
This commit is contained in:
parent
91920450c2
commit
6c4dfe5983
@ -154,6 +154,7 @@ class TableRegistry
|
||||
'trademark-management/renewal' => Trademark::class,
|
||||
'trademark-management/information' => Trademark::class,
|
||||
'store-management/basic-information' => StoreInformation::class,
|
||||
'store-management/store-report' => StoreInformation::class,
|
||||
'store-management/opening-process' => StoreOpening::class,
|
||||
'store-management/changes' => StoreChange::class,
|
||||
'store-management/closing-process' => StoreClosing::class,
|
||||
@ -241,6 +242,7 @@ class TableRegistry
|
||||
return in_array("{$section}/{$page}", [
|
||||
'company-management/basic-information',
|
||||
'store-management/basic-information',
|
||||
'store-management/store-report',
|
||||
'trademark-management/information',
|
||||
'financial-operations/installation-details',
|
||||
], true);
|
||||
|
||||
@ -8,6 +8,7 @@ use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
|
||||
class UpstreamBusinessInformation
|
||||
@ -18,6 +19,8 @@ class UpstreamBusinessInformation
|
||||
private readonly UpstreamQueryCacheFactory $caches,
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly InstallationDetailMirrorRepository $installationMirror,
|
||||
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
|
||||
private readonly ?StoreReportRepository $storeReports = null,
|
||||
) {}
|
||||
|
||||
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
|
||||
@ -29,6 +32,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-report' => $this->storeReportPage($currentPage, $perPage),
|
||||
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
|
||||
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
|
||||
default => ['data' => [], 'total' => 0],
|
||||
@ -71,7 +75,9 @@ class UpstreamBusinessInformation
|
||||
|
||||
return match ($scope) {
|
||||
'company-information.read' => $this->companySnapshot($query, $page, $perPage),
|
||||
'store-information.read' => $this->storeSnapshot($query, $page, $perPage),
|
||||
'store-information.read' => ! empty($parameters['all'])
|
||||
? $this->storeSnapshotAll($query)
|
||||
: $this->storeSnapshot($query, $page, $perPage),
|
||||
'trademark-information.read' => $this->trademarkSnapshot($query, $page, $perPage),
|
||||
default => throw new InvalidArgumentException('未知的经营分析副本库快照类型:' . $scope),
|
||||
};
|
||||
@ -135,6 +141,79 @@ SQL);
|
||||
);
|
||||
}
|
||||
|
||||
private function storeReportPage(int $page, int $perPage): array
|
||||
{
|
||||
$stores = $this->allStores();
|
||||
try {
|
||||
$repository = $this->storeReports ?? app(StoreReportRepository::class);
|
||||
$rows = $repository->rows($stores['data']);
|
||||
} catch (\RuntimeException $exception) {
|
||||
throw new ServiceUnavailableHttpException(null, $exception->getMessage(), $exception);
|
||||
}
|
||||
$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
|
||||
{
|
||||
if ($this->mirror->ready('store-information.read')) {
|
||||
$rows = $this->mirror->allStores();
|
||||
return [
|
||||
'data' => array_map(fn (array $row): array => $this->mapStore($row), $rows),
|
||||
'total' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
|
||||
&& ! config('upstreams.local_configuration.enabled', false)) {
|
||||
throw new ServiceUnavailableHttpException(null, '当前仍使用本机临时引导库。请在“系统设置 → 数据库管理 → 系统数据库”连接团队共享数据库,然后重新登录;无需另配上游 SQL Server。');
|
||||
}
|
||||
|
||||
$cache = $this->caches->forSource(self::SOURCE);
|
||||
return $cache->remember(
|
||||
'store-information.read',
|
||||
['all' => true],
|
||||
fn () => $this->storeSnapshotAll(new UpstreamReadOnlyQuery(self::SOURCE)),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
NULL AS trademark_name,
|
||||
s.platform AS platform,
|
||||
s.responsibleDepartment AS profit_center,
|
||||
s.shopHead AS store_manager,
|
||||
s.customerDeptManager AS department_manager,
|
||||
s.shopState AS store_status,
|
||||
s.shopBankAccount AS withdrawal_account,
|
||||
s.shopType AS store_type,
|
||||
s.channelType AS channel_mode
|
||||
FROM rpt.fys_shopInfo AS s
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 company.businessLicenseName, company.businessLicenseType
|
||||
FROM rpt.fys_companyInfo AS company
|
||||
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
|
||||
ORDER BY company.Id DESC
|
||||
) AS c
|
||||
ORDER BY s.Id DESC
|
||||
SQL);
|
||||
$rows = array_map(fn (array $row): array => $this->mapStore($row), $rows);
|
||||
return ['data' => $rows, 'total' => count($rows)];
|
||||
}
|
||||
|
||||
private function storeSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
@ -147,19 +226,20 @@ SELECT
|
||||
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,
|
||||
NULL AS trademark_name,
|
||||
s.platform AS platform,
|
||||
s.responsibleDepartment AS profit_center,
|
||||
s.shopHead AS store_manager,
|
||||
NULL AS department_manager,
|
||||
s.customerDeptManager AS department_manager,
|
||||
s.shopState AS store_status,
|
||||
s.shopBankAccount AS withdrawal_account,
|
||||
s.shopType AS store_type,
|
||||
s.channelType AS channel_mode
|
||||
FROM rpt.fys_shopInfo AS s
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 company.businessLicenseName
|
||||
SELECT TOP 1 company.businessLicenseName, company.businessLicenseType
|
||||
FROM rpt.fys_companyInfo AS company
|
||||
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
|
||||
ORDER BY company.Id DESC
|
||||
|
||||
@ -67,6 +67,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
|
||||
- caiwu_storage:/var/www/html/storage
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
@ -159,9 +159,9 @@ return [
|
||||
'title' => '店铺维度报表',
|
||||
'directory' => [
|
||||
'icon' => 'query_stats',
|
||||
'status' => '尚未同步',
|
||||
'description' => '以店铺 ID 和主体生效、失效时间为基础,固定期间销量及 2026 年月度销量字段。',
|
||||
'empty' => '店铺维度报表尚未同步。完成销量镜像接入后,这里会显示可核对的期间数据。',
|
||||
'status' => '已接入真实账单与生命周期',
|
||||
'description' => '店铺资料来自经营分析副本库;主体生效区间和月度销量来自本地 bills.sqlite。',
|
||||
'empty' => '暂无可匹配的店铺生命周期或账单数据。',
|
||||
'fields' => [
|
||||
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
|
||||
['name' => 'store_id', 'label' => '店铺ID(唯一值)'],
|
||||
|
||||
@ -71,6 +71,21 @@ return [
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
],
|
||||
|
||||
// 平台账单与店铺生命周期的本地只读数据源;不作为系统主库使用。
|
||||
'bills_sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => env('BILLS_SQLITE_PATH', base_path('runtime/bills.sqlite')),
|
||||
'prefix' => '',
|
||||
// null prevents Laravel from issuing PRAGMA writes while opening
|
||||
// the read-only mounted database.
|
||||
'foreign_key_constraints' => null,
|
||||
// PDO must open the file itself read-only; a filesystem read-only
|
||||
// bind mount alone still lets SQLite attempt WAL recovery writes.
|
||||
'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
|
||||
@ -55,6 +56,7 @@ services:
|
||||
- while true; do php artisan upstream:snapshots:refresh-all --quiet || true; sleep 60; done
|
||||
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
|
||||
|
||||
@ -20,6 +20,15 @@
|
||||
|
||||
## 经营分析副本库(operations_replica)
|
||||
|
||||
店铺维度报表的销售区间来自本机只读 SQLite 文件
|
||||
`runtime/bills.sqlite`,不是 `jst_Orders` 的件数统计。店铺资料仍以
|
||||
`rpt.fys_shopInfo` 为主表;SQLite 的 `store_lifecycles.erp_store_name` 和
|
||||
`bills.store_name` 均按店铺名称与 `fys_shopInfo.erpShopName` 关联。
|
||||
每个生命周期行使用 `[effective_at, expires_at)`,销量固定为
|
||||
`SUM(bills.income)`;空失效时间按 `2999-01-01`,无生命周期行按
|
||||
`2026-05-01` 至 `2999-01-01`,主体回退到公司表的
|
||||
`businessLicenseName`。
|
||||
|
||||
| 业务范围 | 上游对象 | 镜像目标 |
|
||||
| --- | --- | --- |
|
||||
| 公司资料 | `rpt.fys_companyInfo` | `ops_fys_company_info` |
|
||||
|
||||
@ -50,5 +50,5 @@ PY=/Users/dodo/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/b
|
||||
SELECT * FROM store_lifecycles
|
||||
WHERE erp_store_name = ?
|
||||
AND effective_at <= ?
|
||||
AND (expires_at IS NULL OR expires_at >= ?);
|
||||
AND (expires_at IS NULL OR expires_at > ?);
|
||||
```
|
||||
|
||||
@ -48,6 +48,20 @@ class BusinessInformationMirrorRepository
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function allStores(): array
|
||||
{
|
||||
if (! $this->ready('store-information.read')) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
return $this->storeQuery()
|
||||
->orderByDesc('s.Id')
|
||||
->get()
|
||||
->map(static fn (object $row): array => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return array{data: array<int,array<string,mixed>>, total: int} */
|
||||
private function companies(int $page, int $perPage): array
|
||||
{
|
||||
@ -76,6 +90,11 @@ SQL);
|
||||
|
||||
/** @return array{data: array<int,array<string,mixed>>, total: int} */
|
||||
private function stores(int $page, int $perPage): array
|
||||
{
|
||||
return $this->page($this->storeQuery(), $page, $perPage, 's.Id');
|
||||
}
|
||||
|
||||
private function storeQuery()
|
||||
{
|
||||
$latestCompanies = $this->connection->table('ops_fys_company_info')
|
||||
->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id')
|
||||
@ -96,19 +115,20 @@ 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,
|
||||
NULL AS trademark_name,
|
||||
s.platform AS platform,
|
||||
s.responsibleDepartment AS profit_center,
|
||||
s.shopHead AS store_manager,
|
||||
NULL AS department_manager,
|
||||
s.customerDeptManager AS department_manager,
|
||||
s.shopState AS store_status,
|
||||
s.shopBankAccount AS withdrawal_account,
|
||||
s.shopType AS store_type,
|
||||
s.channelType AS channel_mode
|
||||
SQL);
|
||||
|
||||
return $this->page($query, $page, $perPage, 's.Id');
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @return array{data: array<int,array<string,mixed>>, total: int} */
|
||||
|
||||
216
modules/EcommerceFinance/Repositories/StoreReportRepository.php
Normal file
216
modules/EcommerceFinance/Repositories/StoreReportRepository.php
Normal file
@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 将经营分析副本库中的店铺资料与本地账单 SQLite 组合为店铺维度报表。
|
||||
*
|
||||
* SQLite 只读保存“店铺存续时间”和平台账单导入结果;本类不写入任何
|
||||
* 业务数据,且严格使用 [effective_at, expires_at) 区间,避免主体切换日
|
||||
* 的账单被重复统计。
|
||||
*/
|
||||
class StoreReportRepository
|
||||
{
|
||||
private const DEFAULT_EFFECTIVE_AT = '2026-05-01';
|
||||
private const DEFAULT_EXPIRES_AT = '2999-01-01';
|
||||
private const REPORT_YEAR = '2026';
|
||||
|
||||
private ?ConnectionInterface $connection = null;
|
||||
private readonly DatabaseManager $database;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $stores
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function rows(array $stores): array
|
||||
{
|
||||
if ($stores === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->assertDatabaseFile();
|
||||
$this->connection ??= $this->database->connection('bills_sqlite');
|
||||
$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);
|
||||
$lifecycleSales = $this->lifecycleSales($names);
|
||||
$lifecycleNames = array_fill_keys(array_keys($lifecycles), true);
|
||||
$fallbackNames = array_values(array_diff($names, array_keys($lifecycleNames)));
|
||||
$fallbackSales = $this->fallbackSales($fallbackNames);
|
||||
$rows = [];
|
||||
|
||||
foreach ($stores as $store) {
|
||||
$storeName = trim((string) ($store['erp_store_name'] ?? ''));
|
||||
$periods = $lifecycles[$storeName] ?? [[
|
||||
'company_name' => $store['company_name'] ?? null,
|
||||
'effective_at' => self::DEFAULT_EFFECTIVE_AT,
|
||||
'expires_at' => self::DEFAULT_EXPIRES_AT,
|
||||
'lifecycle_id' => null,
|
||||
]];
|
||||
|
||||
foreach ($periods as $period) {
|
||||
$effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT);
|
||||
$expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT);
|
||||
$saleData = $period['lifecycle_id'] === null
|
||||
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
|
||||
: ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]);
|
||||
$monthly = $saleData['months'];
|
||||
$sales = [];
|
||||
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$key = sprintf('%s-%02d', self::REPORT_YEAR, $month);
|
||||
$sales[$month] = $monthly[$key] ?? 0.0;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
...$store,
|
||||
'store_id' => $storeName,
|
||||
'status' => $this->status($store['store_status'] ?? null),
|
||||
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
|
||||
'effective_at' => $effectiveAt,
|
||||
'invalid_at' => $expiresAt,
|
||||
'period_sales' => $saleData['period'],
|
||||
...array_combine(
|
||||
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
|
||||
array_values($sales),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function assertDatabaseFile(): void
|
||||
{
|
||||
$path = (string) config('database.connections.bills_sqlite.database');
|
||||
if ($path === '' || ! is_file($path)) {
|
||||
throw new RuntimeException('店铺账单 SQLite 尚未挂载:' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,array<string,mixed>>> */
|
||||
private function lifecycles(array $names): array
|
||||
{
|
||||
if (! $this->connection->getSchemaBuilder()->hasTable('store_lifecycles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (array_chunk($names, 900) as $chunk) {
|
||||
$rows = $this->connection->table('store_lifecycles')
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,array{period:float,months:array<string,float>}> */
|
||||
private function lifecycleSales(array $names): array
|
||||
{
|
||||
if ($names === []
|
||||
|| ! $this->connection->getSchemaBuilder()->hasTable('store_lifecycles')
|
||||
|| ! $this->connection->getSchemaBuilder()->hasTable('bills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (array_chunk($names, 900) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $this->connection->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 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, substr(b.occurred_at, 1, 7)",
|
||||
[self::DEFAULT_EFFECTIVE_AT, self::DEFAULT_EXPIRES_AT, ...$chunk],
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$key = (string) $row->lifecycle_id;
|
||||
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
|
||||
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,array{period:float,months:array<string,float>}> */
|
||||
private function fallbackSales(array $names): array
|
||||
{
|
||||
if ($names === [] || ! $this->connection->getSchemaBuilder()->hasTable('bills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (array_chunk($names, 900) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$rows = $this->connection->select(
|
||||
"SELECT store_name, substr(occurred_at, 1, 7) AS bill_month, SUM(COALESCE(income, 0)) AS income_total
|
||||
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],
|
||||
);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string) $row->store_name;
|
||||
$result[$key]['period'] = ($result[$key]['period'] ?? 0.0) + (float) $row->income_total;
|
||||
$result[$key]['months'][(string) $row->bill_month] = (float) $row->income_total;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function date(mixed $value, string $fallback): string
|
||||
{
|
||||
$value = trim((string) ($value ?? ''));
|
||||
return $value === '' ? $fallback : substr($value, 0, 10);
|
||||
}
|
||||
|
||||
private function status(mixed $value): ?string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'0' => '开店中',
|
||||
'3' => '关店中',
|
||||
'6' => '关店',
|
||||
'正常' => '开店中',
|
||||
'关闭中' => '关店中',
|
||||
'已关闭' => '关店',
|
||||
default => $value === null || $value === '' ? null : (string) $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -148,6 +148,48 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
$this->assertCount(14, $headers[0]);
|
||||
}
|
||||
|
||||
public function testStoreReportRendersExpandedLifecycleRowsAndClosedInProgressStatus(): void
|
||||
{
|
||||
$service = Mockery::mock(UpstreamBusinessInformation::class);
|
||||
$service->shouldReceive('paginate')->once()->with('store-management', 'store-report', 50)->andReturn(
|
||||
new LengthAwarePaginator([[
|
||||
'erp_store_name' => '测试店铺',
|
||||
'store_id' => '测试店铺',
|
||||
'profit_center' => '运营中心A',
|
||||
'store_manager' => '店铺负责人',
|
||||
'department_manager' => '部门负责人',
|
||||
'opened_at' => '2026-05-01',
|
||||
'status' => '关店中',
|
||||
'entity' => '测试主体',
|
||||
'company_type' => '普通公司',
|
||||
'effective_at' => '2026-07-01',
|
||||
'invalid_at' => '2999-01-01',
|
||||
'period_sales' => 25916.80,
|
||||
'sales_07' => 25916.80,
|
||||
]], 1, 50, 1, ['path' => '/'])
|
||||
);
|
||||
$this->app->instance(UpstreamBusinessInformation::class, $service);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'store-management',
|
||||
'page' => 'store-report',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeText('店铺维度报表')
|
||||
->assertSeeText('测试店铺')
|
||||
->assertSeeText('测试主体')
|
||||
->assertSeeText('2026-07-01')
|
||||
->assertSeeText('2999-01-01')
|
||||
->assertSeeText('25,916.80')
|
||||
->assertSeeText('关店中')
|
||||
->assertSeeText('已接入真实账单与生命周期');
|
||||
|
||||
preg_match_all('/<th(?:\s|>)/', $response->getContent(), $headers);
|
||||
$this->assertCount(26, $headers[0]);
|
||||
}
|
||||
|
||||
public function testTrademarkInformationShowsReadOnlyUpstreamRows(): void
|
||||
{
|
||||
$this->mockUpstreamPage((object) [
|
||||
@ -287,7 +329,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
{
|
||||
$paginator = new LengthAwarePaginator([$row], 1, 50, 1, ['path' => '/']);
|
||||
$service = Mockery::mock(UpstreamBusinessInformation::class);
|
||||
$service->shouldReceive('paginate')->once()->andReturn($paginator);
|
||||
$service->shouldReceive('paginate')->atLeast()->once()->andReturn($paginator);
|
||||
$this->app->instance(UpstreamBusinessInformation::class, $service);
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,7 +105,7 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
|
||||
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
|
||||
$table->integer('Id'); $table->dateTime('openTime')->nullable(); $table->string('erpShopName')->nullable(); $table->string('unifiedSocialCreditCode')->nullable();
|
||||
$table->string('trademarkNumber')->nullable(); $table->string('platform')->nullable(); $table->string('responsibleDepartment')->nullable();
|
||||
$table->string('shopHead')->nullable(); $table->string('shopState')->nullable(); $table->string('shopType')->nullable(); $table->string('channelType')->nullable();
|
||||
$table->string('shopHead')->nullable(); $table->string('customerDeptManager')->nullable(); $table->string('shopState')->nullable(); $table->string('shopType')->nullable(); $table->string('channelType')->nullable();
|
||||
$table->string('sotreId')->nullable(); $table->dateTime('renewalTime')->nullable(); $table->string('shopBankAccount')->nullable();
|
||||
});
|
||||
$schema->create('ops_fys_trademark_info', function (Blueprint $table): void {
|
||||
|
||||
151
tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php
Normal file
151
tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Business\UpstreamBusinessInformation;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
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\StoreReportRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreReportRepositoryTest extends TestCase
|
||||
{
|
||||
private string $databasePath;
|
||||
private StoreReportRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->databasePath = tempnam(sys_get_temp_dir(), 'store-report-');
|
||||
config(['database.connections.bills_sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => $this->databasePath,
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => false,
|
||||
]]);
|
||||
DB::purge('bills_sqlite');
|
||||
$schema = Schema::connection('bills_sqlite');
|
||||
$schema->create('store_lifecycles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('erp_store_name');
|
||||
$table->string('company_name')->nullable();
|
||||
$table->string('effective_at')->nullable();
|
||||
$table->string('expires_at')->nullable();
|
||||
});
|
||||
$schema->create('bills', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('store_name');
|
||||
$table->string('occurred_at')->nullable();
|
||||
$table->decimal('income', 18, 2)->default(0);
|
||||
});
|
||||
$this->repository = new StoreReportRepository(app('db'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DB::purge('bills_sqlite');
|
||||
if (is_file($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_lifecycle_boundaries_split_monthly_and_period_income_without_double_counting(): void
|
||||
{
|
||||
$db = DB::connection('bills_sqlite');
|
||||
$db->table('store_lifecycles')->insert([
|
||||
['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'effective_at' => '2026-05-01', 'expires_at' => '2026-07-01'],
|
||||
['erp_store_name' => '店铺A', 'company_name' => '主体乙', 'effective_at' => '2026-07-01', 'expires_at' => null],
|
||||
]);
|
||||
$db->table('bills')->insert([
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-04-30 23:59:59', 'income' => 9],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-05-01 00:00:00', 'income' => 100],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-06-30 23:59:59', 'income' => 60],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-07-01 00:00:00', 'income' => 70],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-08-01 00:00:00', 'income' => 80],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2027-01-01 00:00:00', 'income' => 5],
|
||||
]);
|
||||
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '店铺A', 'company_name' => '旧主体', 'store_status' => '3'],
|
||||
]);
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity'));
|
||||
$this->assertSame(['2026-05-01', '2026-07-01'], array_column($rows, 'effective_at'));
|
||||
$this->assertSame(['2026-07-01', '2999-01-01'], array_column($rows, 'invalid_at'));
|
||||
$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'));
|
||||
}
|
||||
|
||||
public function test_missing_lifecycle_uses_upstream_company_and_default_period(): void
|
||||
{
|
||||
$db = DB::connection('bills_sqlite');
|
||||
$db->table('bills')->insert([
|
||||
['store_name' => '无生命周期店铺', 'occurred_at' => '2026-05-03 12:00:00', 'income' => 25.5],
|
||||
]);
|
||||
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '无生命周期店铺', 'company_name' => '公司表主体', 'store_status' => '0'],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('公司表主体', $rows[0]['entity']);
|
||||
$this->assertSame('2026-05-01', $rows[0]['effective_at']);
|
||||
$this->assertSame('2999-01-01', $rows[0]['invalid_at']);
|
||||
$this->assertSame(25.5, $rows[0]['period_sales']);
|
||||
$this->assertSame(25.5, $rows[0]['sales_05']);
|
||||
$this->assertSame('开店中', $rows[0]['status']);
|
||||
}
|
||||
|
||||
public function test_shop_state_mapping_keeps_opening_closing_and_closed_labels(): void
|
||||
{
|
||||
$rows = $this->repository->rows([
|
||||
['erp_store_name' => '开店中店铺', 'store_status' => '0'],
|
||||
['erp_store_name' => '关店中店铺', 'store_status' => '3'],
|
||||
['erp_store_name' => '已关店店铺', 'store_status' => '6'],
|
||||
]);
|
||||
|
||||
$this->assertSame(['开店中', '关店中', '关店'], array_column($rows, 'status'));
|
||||
}
|
||||
|
||||
public function test_upstream_business_information_exposes_expanded_rows_for_store_report(): void
|
||||
{
|
||||
DB::connection('bills_sqlite')->table('store_lifecycles')->insert([
|
||||
['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'effective_at' => '2026-05-01', 'expires_at' => '2026-07-01'],
|
||||
['erp_store_name' => '店铺A', 'company_name' => '主体乙', 'effective_at' => '2026-07-01', 'expires_at' => null],
|
||||
]);
|
||||
DB::connection('bills_sqlite')->table('bills')->insert([
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-06-30 23:59:59', 'income' => 60],
|
||||
['store_name' => '店铺A', 'occurred_at' => '2026-07-01 00:00:00', 'income' => 70],
|
||||
]);
|
||||
|
||||
$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',
|
||||
]]);
|
||||
|
||||
$service = new UpstreamBusinessInformation(
|
||||
Mockery::mock(UpstreamQueryCacheFactory::class),
|
||||
$mirror,
|
||||
Mockery::mock(InstallationDetailMirrorRepository::class),
|
||||
$this->repository,
|
||||
);
|
||||
$paginator = $service->paginate('store-management', 'store-report', 50);
|
||||
|
||||
$this->assertSame(2, $paginator->total());
|
||||
$this->assertSame(['主体甲', '主体乙'], array_map(static fn (object $row): string => $row->entity, $paginator->items()));
|
||||
$this->assertSame([60.0, 70.0], array_map(static fn (object $row): float => $row->period_sales, $paginator->items()));
|
||||
$this->assertSame(['关店中', '关店中'], array_map(static fn (object $row): string => $row->status, $paginator->items()));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user