diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index fb74206c..19c217e9 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -275,25 +275,14 @@ class UpstreamBusinessInformation } $references ??= $this->companyPlanClassificationReferences($rows); - $byCreditCode = []; - $byCompanyName = []; - - foreach ($references as $reference) { - $creditCode = $this->normalizeCreditCode($reference['unified_social_credit_code'] ?? null); - if ($creditCode !== '' && ! isset($byCreditCode[$creditCode])) { - $byCreditCode[$creditCode] = $reference; - } - - $companyName = $this->normalizeCompanyName($reference['company_name'] ?? null); - if ($companyName !== '' && ! isset($byCompanyName[$companyName])) { - $byCompanyName[$companyName] = $reference; - } - } + $index = $this->companyPlanClassificationIndex($references); foreach ($rows as &$row) { - $creditCode = $this->normalizeCreditCode($row['unified_social_credit_code'] ?? null); - $companyName = $this->normalizeCompanyName($row['company_name'] ?? null); - $reference = $byCreditCode[$creditCode] ?? $byCompanyName[$companyName] ?? null; + $reference = $this->matchedCompanyPlanClassification( + $index, + $row['company_name'] ?? null, + [$row], + ); $planTaxType = trim((string) ($reference['taxpayer_type'] ?? '')); $row['taxpayer_category'] = $this->normalizeTaxType( @@ -319,6 +308,10 @@ class UpstreamBusinessInformation */ private function companyPlanClassificationReferences(array $rows = [], bool $all = false): array { + if ($this->companyPlans === null) { + return []; + } + try { $companyId = (int) (company_id() ?: 1); if ($all) { @@ -337,6 +330,61 @@ class UpstreamBusinessInformation } } + /** + * @param list> $references + * @return array{ + * by_credit_code:array>, + * by_company_name:array> + * } + */ + private function companyPlanClassificationIndex(array $references): array + { + $byCreditCode = []; + $byCompanyName = []; + + foreach ($references as $reference) { + $creditCode = $this->normalizeCreditCode($reference['unified_social_credit_code'] ?? null); + if ($creditCode !== '' && ! isset($byCreditCode[$creditCode])) { + $byCreditCode[$creditCode] = $reference; + } + + $companyName = $this->normalizeCompanyName($reference['company_name'] ?? null); + if ($companyName !== '' && ! isset($byCompanyName[$companyName])) { + $byCompanyName[$companyName] = $reference; + } + } + + return [ + 'by_credit_code' => $byCreditCode, + 'by_company_name' => $byCompanyName, + ]; + } + + /** + * @param array{ + * by_credit_code:array>, + * by_company_name:array> + * } $index + * @param list> $companyRows + * @return array|null + */ + private function matchedCompanyPlanClassification( + array $index, + mixed $companyName, + array $companyRows = [], + ): ?array { + foreach ($companyRows as $companyRow) { + $creditCode = $this->normalizeCreditCode($companyRow['unified_social_credit_code'] ?? null); + if ($creditCode !== '' && isset($index['by_credit_code'][$creditCode])) { + return $index['by_credit_code'][$creditCode]; + } + } + + $normalizedCompanyName = $this->normalizeCompanyName($companyName); + + return $index['by_company_name'][$normalizedCompanyName] ?? null; + } + /** * @param array> $filters */ @@ -819,15 +867,29 @@ SQL); } catch (Throwable) { $classifications = []; } + $planClassifications = $this->companyPlanClassificationIndex( + $this->companyPlanClassificationReferences(all: true), + ); foreach ($rows as &$row) { $row['company_details'] = $details[$row['entity']] ?? []; $row['city'] = $this->cityFromDetails($row['company_details']); $row['company_status'] = $row['company_details'][0]['company_status'] ?? null; $classification = $classifications[$row['entity']] ?? []; - $taxType = $classification['tax_type'] + $planClassification = $this->matchedCompanyPlanClassification( + $planClassifications, + $row['entity'], + $row['company_details'], + ); + $planTaxType = trim((string) ($planClassification['taxpayer_type'] ?? '')); + $planBusinessType = trim((string) ($planClassification['business_registration_type'] ?? '')); + $taxType = $planTaxType !== '' + ? $planTaxType + : $classification['tax_type'] ?? $row['company_details'][0]['taxpayer_category'] ?? null; - $businessType = $classification['business_registration_type'] + $businessType = $planBusinessType !== '' + ? $planBusinessType + : $classification['business_registration_type'] ?? $row['company_details'][0]['business_registration_type'] ?? null; $classificationStatus = trim((string) ($classification['status'] ?? '')); @@ -839,7 +901,7 @@ SQL); $row['tax_type'] = $this->normalizeTaxType($taxType) ?? '/'; $businessType = trim((string) $businessType); $row['business_registration_type'] = $retained - && in_array($businessType, ['核定', '个人独资企业'], true) + && ($planBusinessType !== '' || in_array($businessType, ['核定', '个人独资企业'], true)) ? $businessType : '/'; } diff --git a/config/business_navigation.php b/config/business_navigation.php index 9fab9561..a82bc84c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -12,7 +12,7 @@ $navigation = [ 'directory' => [ 'icon' => 'query_stats', 'status' => '收入汇总', - 'description' => '按主体生效、失效日期归属账单收入;上月店铺结算收入取业绩明细收入金额,按店铺、主体期间汇总。', + 'description' => '按主体生效、失效日期归属账单收入;税务类型和工商类型优先引用公司盘点,上月店铺结算收入取业绩明细收入金额并按主体期间汇总。', 'empty' => '经营报表尚未同步。完成店铺主体和销量镜像接入后,这里会显示可追溯的主体数据。', 'fields' => [ ['name' => 'city', 'label' => '市'], diff --git a/docs/主体维度经营报表需求说明.md b/docs/主体维度经营报表需求说明.md index 39ea8232..9874fa3a 100644 --- a/docs/主体维度经营报表需求说明.md +++ b/docs/主体维度经营报表需求说明.md @@ -27,8 +27,8 @@ | 1 | 市 | 由主体基础资料 `rpt.fys_companyInfo.registeredAddress` 解析地级市(`App\Business\RegisteredAddressCity::parse`):直辖市直接返回「XX市」;其余取省/自治区前缀后第一个地级行政区;本地地址省略省市前缀时按常州区划前缀兜底;识别不出按统一缺失值展示,不伪造数据 | | 2 | 主体 | 主体名称;作为主体合并键 | | 3 | 公司类型 | 主体公司类型,来源于 `rpt` | -| 4 | 税务类型 | 主体税务类型,优先读取 `fin_companies.tax_type`,缺失时使用 `rpt.fys_companyInfo.taxCategoryAndType` | -| 5 | 工商类型 | 主体工商类型,读取 `fin_companies.business_type`;仅“核定”和“个人独资企业”为有效值,其他统一显示 `/` | +| 4 | 税务类型 | 主体税务类型,优先读取当前公司盘点的 `taxpayer_type`;盘点未填写时回退 `fin_companies.tax_type`,再回退 `rpt.fys_companyInfo.taxCategoryAndType` | +| 5 | 工商类型 | 主体工商类型,优先读取当前公司盘点的 `business_registration_type`;盘点未填写时回退 `fin_companies.business_type` 或 `rpt.fys_companyInfo` | | 6 | 最近12个月累计收入 | 最近12个完整自然月收入合计,排除当前月 | | 7 | 上月店铺结算收入-客服公司 | 按运行时上一个完整自然月动态命名,例如当前为 11 月时显示「10月店铺结算收入-客服公司」;金额取业绩明细 `store_receipt_order_match_rows.income_amount`,按店铺主体期间拆分后汇总到主体 | | 8 | 1月收入 | 当前年份 1 月收入,当前月或无账单时为空 | diff --git a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php index 3a0b9b82..5432101f 100644 --- a/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReportRepositoryTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\EcommerceFinance; +use App\Business\CompanyPlan\CompanyPlanRepository; use App\Business\UpstreamBusinessInformation; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Carbon; @@ -455,6 +456,81 @@ class StoreReportRepositoryTest extends TestCase $this->assertSame(0, $service->paginate('company-management', 'subject-operating-report', 50)->total()); } + public function test_subject_operating_report_uses_company_plan_tax_and_business_types(): void + { + Carbon::setTestNow('2026-09-15 10:00:00'); + $mirror = Mockery::mock(BusinessInformationMirrorRepository::class); + $mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue(); + $mirror->shouldReceive('allStores')->once()->andReturn([ + ['erp_store_name' => '店铺盘点类型', 'company_name' => '主体甲', 'store_status' => '0'], + ]); + $mirror->shouldReceive('ready')->with('company-information.read')->andReturnTrue(); + $mirror->shouldReceive('companiesByNames')->once()->with(['主体甲'])->andReturn([ + '主体甲' => [[ + 'Id' => 1, + 'businessLicenseName' => '主体甲', + 'unifiedSocialCreditCode' => 'CODE-SUBJECT', + 'companyStatus' => '0', + 'taxCategoryAndType' => '一般纳税人', + 'business_registration_type' => '核定', + ]], + ]); + $mirror->shouldReceive('companyClassificationsByNames')->once()->with(['主体甲'])->andReturn([ + '主体甲' => [ + 'status' => '保留', + 'tax_type' => '一般纳税人', + 'business_registration_type' => '核定', + ], + ]); + + $companyPlans = Mockery::mock(CompanyPlanRepository::class); + $companyPlans->shouldReceive('allClassificationReferences') + ->once() + ->with((int) (company_id() ?: 1)) + ->andReturn([[ + 'company_name' => '盘点中的主体名称', + 'unified_social_credit_code' => 'CODE-SUBJECT', + 'taxpayer_type' => '小规模', + 'business_registration_type' => '有限责任公司', + ]]); + + $storeReports = Mockery::mock(StoreReportRepository::class); + $storeReports->shouldReceive('rows')->once()->andReturn([[ + 'entity' => '主体甲', + 'company_type' => '店铺-普通公司', + 'rolling_12m_income' => 88, + ]]); + $storeAccounts = Mockery::mock(StoreAccountMirrorRepository::class); + $storeAccounts->shouldReceive('ownershipByStoreName')->once()->andReturn([]); + + $service = new UpstreamBusinessInformation( + Mockery::mock(UpstreamQueryCacheFactory::class), + $mirror, + $storeReports, + $storeAccounts, + null, + $companyPlans, + ); + + $rows = $service->paginate('company-management', 'subject-operating-report', 50)->items(); + $statusFields = collect( + $this->app['request']->attributes->get('directory.status-counts')['fields'] + )->keyBy('name'); + $summaryCards = collect( + $this->app['request']->attributes->get('business-page-summary-cards') + )->keyBy('label'); + + $this->assertCount(1, $rows); + $this->assertSame('小规模纳税人', $rows[0]->tax_type); + $this->assertSame('有限责任公司', $rows[0]->business_registration_type); + $this->assertSame(1, $statusFields['tax_type']['values'][0]['count']); + $this->assertSame('小规模纳税人', $statusFields['tax_type']['values'][0]['value']); + $this->assertSame(1, $statusFields['business_registration_type']['values'][0]['count']); + $this->assertSame('有限责任公司', $statusFields['business_registration_type']['values'][0]['value']); + $this->assertSame(1, $summaryCards['小规模纳税人']['value']); + $this->assertSame(0, $summaryCards['一般纳税人']['value']); + } + public function test_subject_cards_exclude_deregistered_entities_and_show_slash_for_their_types(): void { Carbon::setTestNow('2026-09-15 10:00:00');