From 6c4dfe5983c0bbffd88d2cd0bed22565544e3435 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 31 Aug 2026 22:02:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=E6=8A=A5=E8=A1=A8=E7=9C=9F=E5=AE=9E=E8=B4=A6?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Business/TableRegistry.php | 2 + app/Business/UpstreamBusinessInformation.php | 86 ++++++- compose.jiangling.yml | 1 + config/business_navigation.php | 6 +- config/database.php | 15 ++ docker-compose.local.yml | 2 + docs/上游业务镜像清单.md | 9 + docs/账单SQLite导入说明.md | 2 +- .../BusinessInformationMirrorRepository.php | 24 +- .../Repositories/StoreReportRepository.php | 216 ++++++++++++++++++ tests/Feature/Business/ManagementCrudTest.php | 44 +++- ...usinessInformationMirrorRepositoryTest.php | 2 +- .../StoreReportRepositoryTest.php | 151 ++++++++++++ 13 files changed, 549 insertions(+), 11 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/StoreReportRepository.php create mode 100644 tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index ede657b1..2e2d2d84 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -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); diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 8062d660..d35cd4c7 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -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>,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(<< $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 diff --git a/compose.jiangling.yml b/compose.jiangling.yml index 883a5160..b89fe705 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -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: diff --git a/config/business_navigation.php b/config/business_navigation.php index 72b9750f..297ce0e1 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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(唯一值)'], diff --git a/config/database.php b/config/database.php index f667adad..17ccdd82 100644 --- a/config/database.php +++ b/config/database.php @@ -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'), diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0466626e..eb46a93a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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 diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 1ca9dd80..773548ad 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` | diff --git a/docs/账单SQLite导入说明.md b/docs/账单SQLite导入说明.md index eb2217bc..19f9654f 100644 --- a/docs/账单SQLite导入说明.md +++ b/docs/账单SQLite导入说明.md @@ -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 > ?); ``` diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php index 6931455c..78a840be 100644 --- a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -48,6 +48,20 @@ class BusinessInformationMirrorRepository }; } + /** @return array> */ + 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>, total: int} */ private function companies(int $page, int $perPage): array { @@ -76,6 +90,11 @@ SQL); /** @return array{data: array>, 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>, total: int} */ diff --git a/modules/EcommerceFinance/Repositories/StoreReportRepository.php b/modules/EcommerceFinance/Repositories/StoreReportRepository.php new file mode 100644 index 00000000..aeaaf6ab --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreReportRepository.php @@ -0,0 +1,216 @@ +database = $database; + } + + /** + * @param array> $stores + * @return array> + */ + 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>> */ + 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}> */ + 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}> */ + 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, + }; + } +} diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index e1ecfc54..439f97e4 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -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('/)/', $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); } } diff --git a/tests/Unit/EcommerceFinance/BusinessInformationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/BusinessInformationMirrorRepositoryTest.php index 38111b69..37506f17 100644 --- a/tests/Unit/EcommerceFinance/BusinessInformationMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/BusinessInformationMirrorRepositoryTest.php @@ -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 { diff --git a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php new file mode 100644 index 00000000..45f97658 --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php @@ -0,0 +1,151 @@ +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())); + } +}