diff --git a/app/Business/RegisteredAddressCity.php b/app/Business/RegisteredAddressCity.php new file mode 100644 index 00000000..24343f0d --- /dev/null +++ b/app/Business/RegisteredAddressCity.php @@ -0,0 +1,67 @@ + '常州市', + '天宁' => '常州市', + '钟楼' => '常州市', + '新北' => '常州市', + '武进' => '常州市', + '金坛' => '常州市', + '戚墅堰' => '常州市', + '溧阳' => '常州市', + ]; + + 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; + } +} diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index 612a084b..c5af6e96 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -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', diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index d83f6a33..8d6b1884 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -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>,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 $filters @return array{data:array>,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 $row + * @param array $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(<< $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> */ - 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> $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>> */ + 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 $filters @return array> */ + 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 */ + private function storeOwnership(): array + { + try { + return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName(); + } catch (\RuntimeException | \InvalidArgumentException $exception) { + // 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。 + return []; } } diff --git a/config/business_navigation.php b/config/business_navigation.php index d0272aa0..6282e03d 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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' => '账号分组名称'], diff --git a/config/database.php b/config/database.php index f667adad..723b4175 100644 --- a/config/database.php +++ b/config/database.php @@ -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'), diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1f9168d4..3cf5eb60 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 diff --git a/docs/主体维度经营报表需求说明.md b/docs/主体维度经营报表需求说明.md new file mode 100644 index 00000000..d1ba2f85 --- /dev/null +++ b/docs/主体维度经营报表需求说明.md @@ -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` 的时区和月份边界是否统一按系统时区解释。 diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php index 0735b502..43b197ed 100644 --- a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -48,15 +48,17 @@ class BusinessInformationMirrorRepository }; } - /** @return array> */ - public function allStores(): array + /** @param array $filters @return array> */ + 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>> */ + 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>, total: int} */ private function companies(int $page, int $perPage): array { diff --git a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php new file mode 100644 index 00000000..1c4ba15c --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php @@ -0,0 +1,96 @@ +>,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 + */ + 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, + }; + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreReportRepository.php b/modules/EcommerceFinance/Repositories/StoreReportRepository.php index ca559524..26954bc9 100644 --- a/modules/EcommerceFinance/Repositories/StoreReportRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReportRepository.php @@ -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 + */ + 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> $stores + * @param array $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、生命周期区间及归属覆盖字段。 * @return array> */ - 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>> */ - 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}> */ - 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}> */ - 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}> $sales */ - private function combinedSales(array $sales, array $ids): array + /** @return array> */ + 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 $row + * @param array $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) ? '否' : '是'; + } } diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 8209e734..2b2c70ea 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -361,6 +361,11 @@ {{ $upstreamError }} + @elseif ($directoryTotal > 0) +
+ + 已从共享业务镜像读取 {{ $directoryTotal }} 条数据,当前页面为只读展示。 +
@elseif (($directory['status'] ?? null) === '既有只读上游')
@@ -378,12 +383,63 @@
@endif - @if (empty($directoryFields)) -
- - {{ $directory['empty'] ?? '字段待定义。' }} -
- @else + @if (!empty($directoryFields)) + @if (($page['slug'] ?? '') === 'store-report') +
+ @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) + + @endforeach + + + @foreach ($storeFilterDateFields as $filter) + + @endforeach +
+ + 清除 +
+
+ @endif
@@ -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)) { diff --git a/resources/views/components/layouts/admin/menu.blade.php b/resources/views/components/layouts/admin/menu.blade.php index 34580137..17f1df6d 100644 --- a/resources/views/components/layouts/admin/menu.blade.php +++ b/resources/views/components/layouts/admin/menu.blade.php @@ -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) { diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index d72a7b8d..5567d51b 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -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' => ['商标号', '是否完成', '办理人']], diff --git a/tests/Feature/Business/PrototypePageTest.php b/tests/Feature/Business/PrototypePageTest.php index 7b62d695..8acfd614 100644 --- a/tests/Feature/Business/PrototypePageTest.php +++ b/tests/Feature/Business/PrototypePageTest.php @@ -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月收入']); } } diff --git a/tests/Unit/Business/RegisteredAddressCityTest.php b/tests/Unit/Business/RegisteredAddressCityTest.php new file mode 100644 index 00000000..a96eab30 --- /dev/null +++ b/tests/Unit/Business/RegisteredAddressCityTest.php @@ -0,0 +1,49 @@ + ['江苏省常州市天宁区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)); + } +} diff --git a/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php new file mode 100644 index 00000000..7a4b0eab --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php @@ -0,0 +1,79 @@ + [ + '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']); + } +} diff --git a/tests/Unit/EcommerceFinance/StoreReportRepositoryFilterTest.php b/tests/Unit/EcommerceFinance/StoreReportRepositoryFilterTest.php new file mode 100644 index 00000000..90302952 --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreReportRepositoryFilterTest.php @@ -0,0 +1,158 @@ +> */ + 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')); + } +} diff --git a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php index 15176f91..899591d6 100644 --- a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php @@ -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', '{}']); } }