From 65a7da66f00a0f21abb64ccaa962b75e3b424dca Mon Sep 17 00:00:00 2001 From: DoDo <1060330844@qq.com> Date: Wed, 11 Nov 2026 17:39:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E5=85=AC=E5=8F=B8=E5=BA=97=E9=93=BA=E6=98=A0=E5=B0=84=E4=B8=8E?= =?UTF-8?q?=E4=B8=BB=E4=BD=93=E6=8A=A5=E8=A1=A8=E6=94=B6=E5=85=A5=20(#1095?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入客服公司店铺映射,主体报表收入字段改名并默认隐藏。 --- ...CustomerServiceMappingWorkbookImporter.php | 83 ++++++ ...ServiceCompanyReconciliationRepository.php | 238 ++++++++++++++++++ app/Business/UpstreamBusinessInformation.php | 7 +- .../ImportServiceCompanyCustomerMapping.php | 73 ++++++ config/business_navigation.php | 2 +- ...dd_source_to_service_company_relations.php | 52 ++++ docs/主体维度经营报表需求说明.md | 8 +- docs/服务公司对账配置方案.md | 5 +- .../Services/StoreReportLogic.php | 2 +- .../pages/partials/directory-table.blade.php | 4 +- tests/Feature/Business/PrototypePageTest.php | 23 +- .../ServiceCompanyReconciliationTest.php | 50 ++++ .../UpstreamBusinessInformationTest.php | 2 +- 13 files changed, 529 insertions(+), 20 deletions(-) create mode 100644 app/Business/ServiceCompanyReconciliation/CustomerServiceMappingWorkbookImporter.php create mode 100644 app/Console/Commands/ImportServiceCompanyCustomerMapping.php create mode 100644 database/migrations/2026_11_11_190000_add_source_to_service_company_relations.php diff --git a/app/Business/ServiceCompanyReconciliation/CustomerServiceMappingWorkbookImporter.php b/app/Business/ServiceCompanyReconciliation/CustomerServiceMappingWorkbookImporter.php new file mode 100644 index 00000000..c466a0fe --- /dev/null +++ b/app/Business/ServiceCompanyReconciliation/CustomerServiceMappingWorkbookImporter.php @@ -0,0 +1,83 @@ +, + * total_rows:int, + * assigned_rows:int, + * unassigned_rows:int, + * invalid_rows:list + * } + */ + public function rows(string $path): array + { + if (! is_file($path)) { + throw new RuntimeException('客服公司映射文件不存在:' . $path); + } + + $result = [ + 'rows' => [], + 'total_rows' => 0, + 'assigned_rows' => 0, + 'unassigned_rows' => 0, + 'invalid_rows' => [], + ]; + foreach ($this->reader->rows( + $path, + ['公司名称', '结算比例', '对应客服公司'], + sheetName: '公司盘点', + ) as $row) { + $result['total_rows']++; + $sourceRow = $result['total_rows'] + 1; + $storeName = trim((string) ($row['公司名称'] ?? '')); + $serviceName = trim((string) ($row['对应客服公司'] ?? '')); + if ($storeName === '') { + continue; + } + if ($serviceName === '') { + $result['unassigned_rows']++; + continue; + } + + $feeRate = $this->feeRate((string) ($row['结算比例'] ?? '')); + if ($feeRate === null) { + $result['invalid_rows'][] = "第 {$sourceRow} 行结算比例无效。"; + continue; + } + + $result['rows'][] = [ + 'service_company_name' => $serviceName, + 'store_company_name' => $storeName, + 'fee_rate' => $feeRate, + 'source_row' => $sourceRow, + ]; + $result['assigned_rows']++; + } + + return $result; + } + + private function feeRate(string $value): ?float + { + $normalized = trim(str_replace(['%', ',', ','], ['', '', ''], $value)); + if ($normalized === '' || ! is_numeric($normalized)) { + return null; + } + + $rate = (float) $normalized; + if ($rate > 1) { + $rate /= 100; + } + + return $rate > 0 && $rate <= 1 ? round($rate, 6) : null; + } +} diff --git a/app/Business/ServiceCompanyReconciliation/ServiceCompanyReconciliationRepository.php b/app/Business/ServiceCompanyReconciliation/ServiceCompanyReconciliationRepository.php index 0ca25cde..ac854673 100644 --- a/app/Business/ServiceCompanyReconciliation/ServiceCompanyReconciliationRepository.php +++ b/app/Business/ServiceCompanyReconciliation/ServiceCompanyReconciliationRepository.php @@ -576,6 +576,8 @@ class ServiceCompanyReconciliationRepository 'status' => in_array($data['status'] ?? 'active', ['active', 'inactive'], true) ? $data['status'] : 'active', + 'source' => 'manual', + 'source_synced_at' => null, 'notes' => $this->nullableText($data['notes'] ?? null, 500), 'updated_by' => $actor, ]; @@ -631,6 +633,227 @@ class ServiceCompanyReconciliationRepository }); } + /** + * 将客服公司 Excel 中的店铺公司映射同步为结算关系。 + * + * @param list $rows + * @return array{created:int,updated:int,unchanged:int,inactive:int,errors:list} + */ + public function syncCustomerServiceMappings( + int $tenantId, + array $rows, + string $effectiveFrom, + string $actor, + ): array { + $result = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'inactive' => 0, 'errors' => []]; + if (preg_match('/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/', $effectiveFrom) !== 1) { + $result['errors'][] = '生效日期格式必须是 YYYY-MM-DD。'; + + return $result; + } + + $serviceCompanies = $this->connection->table('svc_service_companies') + ->where('company_id', $tenantId) + ->where('service_type_code', 'customer_service') + ->where('status', 'active') + ->get(); + $servicesByName = []; + foreach ($serviceCompanies as $serviceCompany) { + $nameKey = $this->mappingCompanyNameKey((string) $serviceCompany->company_name); + if (isset($servicesByName[$nameKey])) { + $result['errors'][] = '客服公司名称重复:' . $serviceCompany->company_name; + continue; + } + $servicesByName[$nameKey] = $serviceCompany; + } + + $directoryByName = []; + foreach ($this->companyDirectory() as $directoryCompany) { + $directoryByName[$this->mappingCompanyNameKey((string) $directoryCompany['company_name'])] = $directoryCompany; + } + $storesByName = []; + foreach ($this->storeCompanies($tenantId) as $storeCompany) { + $storesByName[$this->mappingCompanyNameKey((string) $storeCompany['name'])] = $storeCompany; + } + + $prepared = []; + $seen = []; + foreach ($rows as $index => $row) { + $sourceRow = (int) ($row['source_row'] ?? ($index + 2)); + $serviceName = trim((string) ($row['service_company_name'] ?? '')); + $storeName = trim((string) ($row['store_company_name'] ?? '')); + $feeRate = round((float) ($row['fee_rate'] ?? 0), 6); + if ($serviceName === '' && $storeName === '') { + continue; + } + if ($serviceName === '') { + continue; + } + if ($storeName === '') { + $result['errors'][] = "第 {$sourceRow} 行缺少店铺公司名称。"; + continue; + } + if ($feeRate <= 0 || $feeRate > 1) { + $result['errors'][] = "第 {$sourceRow} 行结算比例无效。"; + continue; + } + + $serviceCompany = $servicesByName[$this->mappingCompanyNameKey($serviceName)] ?? null; + if (! $serviceCompany) { + $result['errors'][] = "第 {$sourceRow} 行客服公司不存在或未启用:{$serviceName}"; + continue; + } + $nameKey = $this->mappingCompanyNameKey($storeName); + $storeCompany = $storesByName[$nameKey] ?? null; + $directoryCompany = $directoryByName[$nameKey] ?? null; + if (! $storeCompany && ! $directoryCompany) { + $result['errors'][] = "第 {$sourceRow} 行店铺公司不在公司基础信息中:{$storeName}"; + continue; + } + + $canonicalStoreName = trim((string) ( + $storeCompany['name'] + ?? $directoryCompany['company_name'] + )); + $creditCode = strtoupper(trim((string) ( + $storeCompany['unified_social_credit_code'] + ?? $directoryCompany['unified_social_credit_code'] + ?? '' + ))); + $storeCompanyKey = self::companyKey($canonicalStoreName, $creditCode); + $uniqueKey = (int) $serviceCompany->id . '|' . $storeCompanyKey . '|' . $effectiveFrom; + if (isset($seen[$uniqueKey])) { + $result['errors'][] = "第 {$sourceRow} 行店铺公司重复:{$canonicalStoreName}"; + continue; + } + $seen[$uniqueKey] = true; + $prepared[] = [ + 'service_company_id' => (int) $serviceCompany->id, + 'service_company_key' => (string) $serviceCompany->company_key, + 'service_company_name' => (string) $serviceCompany->company_name, + 'store_company_key' => $storeCompanyKey, + 'store_company_name' => $canonicalStoreName, + 'store_company_credit_code' => $creditCode !== '' ? $creditCode : null, + 'fee_rate' => $feeRate, + 'effective_from' => $effectiveFrom, + ]; + } + + if ($result['errors'] !== []) { + return $result; + } + + $syncedAt = now(); + $this->connection->transaction(function () use ( + $tenantId, + $prepared, + $effectiveFrom, + $actor, + $syncedAt, + $seen, + &$result, + ): void { + foreach ($prepared as $row) { + $payload = [ + 'service_company_key' => $row['service_company_key'], + 'service_company_name' => $row['service_company_name'], + 'store_company_key' => $row['store_company_key'], + 'store_company_name' => $row['store_company_name'], + 'store_company_credit_code' => $row['store_company_credit_code'], + 'service_type_code' => 'customer_service', + 'base_metric' => 'settlement_income', + 'fee_rate' => $row['fee_rate'], + 'effective_from' => $row['effective_from'], + 'effective_to' => null, + 'contract_no' => null, + 'contract_status' => 'pending', + 'status' => 'active', + 'source' => 'customer_service_mapping', + 'source_synced_at' => $syncedAt, + 'notes' => '来源:客服公司 Excel 店铺映射', + 'updated_by' => $actor, + 'updated_at' => $syncedAt, + ]; + $existing = $this->connection->table('svc_service_relations') + ->where('company_id', $tenantId) + ->where('service_company_id', $row['service_company_id']) + ->where('store_company_key', $row['store_company_key']) + ->where('service_type_code', 'customer_service') + ->where('effective_from', $effectiveFrom) + ->first(); + + if (! $existing) { + $this->connection->table('svc_service_relations')->insert([ + ...$payload, + 'company_id' => $tenantId, + 'service_company_id' => $row['service_company_id'], + 'rule_version' => 1, + 'created_by' => $actor, + 'created_at' => $syncedAt, + ]); + $result['created']++; + continue; + } + + $old = (array) $existing; + $changed = (string) $existing->service_company_key !== $row['service_company_key'] + || (string) $existing->service_company_name !== $row['service_company_name'] + || (string) $existing->store_company_name !== $row['store_company_name'] + || (string) ($existing->store_company_credit_code ?? '') !== (string) ($row['store_company_credit_code'] ?? '') + || abs((float) $existing->fee_rate - $row['fee_rate']) > 0.000001 + || (string) $existing->status !== 'active'; + if (! $changed) { + $this->connection->table('svc_service_relations') + ->where('id', $existing->id) + ->update([ + 'source' => 'customer_service_mapping', + 'source_synced_at' => $syncedAt, + 'updated_by' => $actor, + 'updated_at' => $syncedAt, + ]); + $result['unchanged']++; + continue; + } + + $payload['rule_version'] = (int) $existing->rule_version + 1; + $this->recordRelationVersion($tenantId, (int) $existing->id, $old, $actor); + $this->connection->table('svc_service_relations') + ->where('id', $existing->id) + ->update($payload); + $updated = (array) $this->connection->table('svc_service_relations')->find($existing->id); + $this->recordRelationVersion($tenantId, (int) $existing->id, $updated, $actor); + $result['updated']++; + } + + $imported = $this->connection->table('svc_service_relations') + ->where('company_id', $tenantId) + ->where('source', 'customer_service_mapping') + ->where('status', 'active') + ->get(); + foreach ($imported as $relation) { + $key = (int) $relation->service_company_id + . '|' + . (string) $relation->store_company_key + . '|' + . (string) $relation->effective_from; + if (isset($seen[$key])) { + continue; + } + $this->connection->table('svc_service_relations') + ->where('id', $relation->id) + ->update([ + 'status' => 'inactive', + 'source_synced_at' => $syncedAt, + 'updated_by' => $actor, + 'updated_at' => $syncedAt, + ]); + $result['inactive']++; + } + }); + + return $result; + } + public function assignPerson( int $tenantId, int $serviceCompanyId, @@ -743,6 +966,9 @@ class ServiceCompanyReconciliationRepository $periodStart, $periodEnd, ); + $availableStoreCompanyKeys = collect($this->storeCompanies($tenantId)) + ->pluck('key') + ->flip(); $sourceGeneration = mb_substr( 'paid:' . $paidGeneration . '|life:' . $lifecycleGeneration, 0, @@ -757,6 +983,7 @@ class ServiceCompanyReconciliationRepository $periodEnd, $relations, $baseRows, + $availableStoreCompanyKeys, $sourceGeneration, $actor, ): array { @@ -774,6 +1001,10 @@ class ServiceCompanyReconciliationRepository $errors[] = $relation->store_company_name . ':当前尚不支持该收入口径。'; continue; } + if (! isset($availableStoreCompanyKeys[$relation->store_company_key])) { + $errors[] = $relation->store_company_name . ':未找到当前店铺主体期间,暂不生成结算。'; + continue; + } $unique = [ 'company_id' => $tenantId, @@ -1081,6 +1312,13 @@ class ServiceCompanyReconciliationRepository return rtrim(rtrim(number_format($percent, 4, '.', ''), '0'), '.') . '%'; } + private function mappingCompanyNameKey(string $name): string + { + $normalized = str_replace(['(', ')', ' '], ['(', ')', ''], trim($name)); + + return mb_strtolower(preg_replace('/\s+/u', '', $normalized) ?? $normalized); + } + private function serviceTypeCodeFromLabel(string $label): string { if (mb_stripos($label, '客服') !== false) { diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 19c217e9..779efd62 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -907,10 +907,13 @@ SQL); } unset($row); - // 整列为空的月份默认隐藏;金额 0 仍视为有账单,不隐藏。 + // 服务公司结算收入列按业务要求默认隐藏;整列为空的月份也默认隐藏,金额 0 仍视为有账单。 request()->attributes->set( 'subject-operating-report.default_hidden_columns', - $this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true), + array_values(array_unique([ + 'previous_month_settlement_income', + ...$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true), + ])), ); $rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters))); diff --git a/app/Console/Commands/ImportServiceCompanyCustomerMapping.php b/app/Console/Commands/ImportServiceCompanyCustomerMapping.php new file mode 100644 index 00000000..099fa5f8 --- /dev/null +++ b/app/Console/Commands/ImportServiceCompanyCustomerMapping.php @@ -0,0 +1,73 @@ +option('company-id'); + $effectiveFrom = trim((string) $this->option('effective-from')); + try { + $parsed = $importer->rows((string) $this->argument('file')); + } catch (\Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->table(['指标', '数量'], [ + ['店铺公司行数', $parsed['total_rows']], + ['已分配客服公司', $parsed['assigned_rows']], + ['暂不分配', $parsed['unassigned_rows']], + ['无效行', count($parsed['invalid_rows'])], + ]); + if ($parsed['invalid_rows'] !== []) { + $this->error(implode(PHP_EOL, $parsed['invalid_rows'])); + + return self::FAILURE; + } + if ((bool) $this->option('dry-run')) { + $this->info('校验通过,未写入数据库。'); + + return self::SUCCESS; + } + + $result = $repository->syncCustomerServiceMappings( + $companyId, + $parsed['rows'], + $effectiveFrom, + trim((string) $this->option('actor')) ?: '客服公司映射导入', + ); + if ($result['errors'] !== []) { + $this->error(implode(PHP_EOL, $result['errors'])); + + return self::FAILURE; + } + + $this->table(['结果', '数量'], [ + ['新增', $result['created']], + ['更新', $result['updated']], + ['无变化', $result['unchanged']], + ['停用', $result['inactive']], + ]); + $this->info("客服公司映射已同步,生效日期:{$effectiveFrom}"); + + return self::SUCCESS; + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index a82bc84c..4958ac30 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -28,7 +28,7 @@ $navigation = [ ]], ['name' => 'business_registration_type', 'label' => '工商类型', 'kind' => 'badge'], ['name' => 'rolling_12m_income', 'label' => '最近12个月累计收入', 'kind' => 'number'], - ['name' => 'previous_month_settlement_income', 'label' => '店铺结算收入-客服公司', 'kind' => 'number'], + ['name' => 'previous_month_settlement_income', 'label' => '店铺结算收入-服务公司', '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' => '规划'], diff --git a/database/migrations/2026_11_11_190000_add_source_to_service_company_relations.php b/database/migrations/2026_11_11_190000_add_source_to_service_company_relations.php new file mode 100644 index 00000000..b1eb5cf5 --- /dev/null +++ b/database/migrations/2026_11_11_190000_add_source_to_service_company_relations.php @@ -0,0 +1,52 @@ +connection()); + if (! $schema->hasTable('svc_service_relations')) { + return; + } + + $schema->table('svc_service_relations', function (Blueprint $table) use ($schema): void { + if (! $schema->hasColumn('svc_service_relations', 'source')) { + $table->string('source', 64)->nullable()->after('rule_version'); + } + if (! $schema->hasColumn('svc_service_relations', 'source_synced_at')) { + $table->timestamp('source_synced_at')->nullable()->after('source'); + } + if (! $schema->hasIndex('svc_service_relations', 'svc_service_relations_source_index')) { + $table->index(['company_id', 'source'], 'svc_service_relations_source_index'); + } + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('svc_service_relations')) { + return; + } + + $schema->table('svc_service_relations', function (Blueprint $table) use ($schema): void { + if ($schema->hasIndex('svc_service_relations', 'svc_service_relations_source_index')) { + $table->dropIndex('svc_service_relations_source_index'); + } + foreach (['source_synced_at', 'source'] as $column) { + if ($schema->hasColumn('svc_service_relations', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/docs/主体维度经营报表需求说明.md b/docs/主体维度经营报表需求说明.md index 9874fa3a..c1b6bb99 100644 --- a/docs/主体维度经营报表需求说明.md +++ b/docs/主体维度经营报表需求说明.md @@ -30,7 +30,7 @@ | 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`,按店铺主体期间拆分后汇总到主体 | +| 7 | 上月店铺结算收入-服务公司 | 按运行时上一个完整自然月动态命名,例如当前为 11 月时显示「10月店铺结算收入-服务公司」;该列默认隐藏,可在字段筛选中恢复;金额取业绩明细 `store_receipt_order_match_rows.income_amount`,按店铺主体期间拆分后汇总到主体 | | 8 | 1月收入 | 当前年份 1 月收入,当前月或无账单时为空 | | 9 | 2月收入 | 当前年份 2 月收入,当前月或无账单时为空 | | 10 | 3月收入 | 当前年份 3 月收入,当前月或无账单时为空 | @@ -58,7 +58,7 @@ - 连续12个月店铺累计收入(原“连续12个月店铺累计销量”) - 其他仅描述单个店铺的字段 -“销量”在本报表中统一改为“收入”。原有最近 12 个月和 1–12 月收入均来自 SQLite `bills.income`,不以件数、利润或其他金额字段替代;新增「上月店铺结算收入-客服公司」单独使用业绩明细收入金额口径。 +“销量”在本报表中统一改为“收入”。原有最近 12 个月和 1–12 月收入均来自 SQLite `bills.income`,不以件数、利润或其他金额字段替代;新增「上月店铺结算收入-服务公司」单独使用业绩明细收入金额口径。 ## 4. 数据来源和职责 @@ -151,7 +151,7 @@ SUM(bills.income) 不使用 `expense`,不计算利润、净收入或件数,不从 `rpt` 读取收入金额。 -「上月店铺结算收入-客服公司」不使用上述 SQLite `bills.income`,而是取业绩明细: +「上月店铺结算收入-服务公司」不使用上述 SQLite `bills.income`,而是取业绩明细: ```text store_receipt_order_match_rows.income_amount @@ -163,7 +163,7 @@ store_report_paid_sales_rows.settlement_income 主体上月店铺结算收入 ``` -该列按运行时当前月的上一个完整自然月展示;例如当前为 2026 年 11 月,列名为「10月店铺结算收入-客服公司」。金额只取业绩明细「收入金额」,不叠加优惠、冲减、违约赔付或退款金额。 +该列按运行时当前月的上一个完整自然月展示;例如当前为 2026 年 11 月,列名为「10月店铺结算收入-服务公司」。列默认隐藏,可在字段筛选中恢复。金额只取业绩明细「收入金额」,不叠加优惠、冲减、违约赔付或退款金额。 ### 5.3 主体合并 diff --git a/docs/服务公司对账配置方案.md b/docs/服务公司对账配置方案.md index 9b2b9d36..93e1d4f0 100644 --- a/docs/服务公司对账配置方案.md +++ b/docs/服务公司对账配置方案.md @@ -271,7 +271,7 @@ 现有系统已有两种候选基数: - `store_report_daily_rows.income`:主体维度经营报表的账单收入。 -- `store_report_paid_sales_rows.settlement_income`:业绩明细“收入金额”,对应现有“上月店铺结算收入-客服公司”口径。 +- `store_report_paid_sales_rows.settlement_income`:业绩明细“收入金额”,对应现有“上月店铺结算收入-服务公司”口径。 客服服务公司当前默认: @@ -334,7 +334,7 @@ 3. 上线服务公司总览和只读详情页,先验证 A/B 客服公司及店铺公司映射。 4. 接入人员归属和成本汇总,先核对 80 人、34/33 人与工资社保数据。 5. 接入店铺公司关系配置,完成同类型唯一、重叠、主体和合同校验。 -6. 上线 1.5% 月度预览,用一个已结账月份与现有“店铺结算收入-客服公司”字段对账。 +6. 上线 1.5% 月度预览,用一个已结账月份与现有“店铺结算收入-服务公司”字段对账。 7. 接入票据系统和财务台账/银行到账状态。 8. 对账通过后再开放确认、导出和待办,不直接写上游账务。 @@ -361,6 +361,7 @@ - 公司盘点的名称、类型、税务类型、工商类型、信用代码和状态自动同步;规划量、备注和启停状态不被覆盖。 - 盘点类型改成非服务公司后,自动来源记录转为停用,历史关系和结算保留。 - 店铺公司来自店铺主体生命周期,关系按生效期间保存。 +- 可使用 `php artisan service-company:import-customer-mapping <文件> --effective-from=YYYY-MM-DD` 导入客服公司映射;文件读取 `公司盘点` 工作表,按“公司名称、结算比例、对应客服公司”建立关系,空客服公司视为暂不分配。 - 同一店铺公司 + 同一服务类型禁止重叠服务公司。 - 关系修改递增版本,并将历史快照保存在 `svc_service_relation_versions`。 - 人员从现有工资快照选择并归属服务公司,人员同一天只能有一个有效主归属。 diff --git a/modules/EcommerceFinance/Services/StoreReportLogic.php b/modules/EcommerceFinance/Services/StoreReportLogic.php index b783baa9..39eeffe0 100644 --- a/modules/EcommerceFinance/Services/StoreReportLogic.php +++ b/modules/EcommerceFinance/Services/StoreReportLogic.php @@ -87,7 +87,7 @@ class StoreReportLogic [ 'code' => 'R10', 'object' => '上月店铺结算收入', - 'logic' => '主体维度经营报表按运行时上一个完整自然月展示「N月店铺结算收入-客服公司」;金额只取业绩明细「收入金额」,按店铺和发生日期汇总后,再按现有主体生效/失效区间拆分并汇总到主体。', + 'logic' => '主体维度经营报表按运行时上一个完整自然月展示「N月店铺结算收入-服务公司」并默认隐藏;金额只取业绩明细「收入金额」,按店铺和发生日期汇总后,再按现有主体生效/失效区间拆分并汇总到主体。', 'source' => 'store_receipt_order_match_rows.income_amount → store_report_paid_sales_rows.settlement_income', 'priority' => '与销量分开统计', ], diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index cba2289f..bf576c75 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -72,7 +72,7 @@ ->format('n'); $directoryFields = collect($directoryFields)->map(function (array $field) use ($previousSettlementMonthLabel): array { if (($field['name'] ?? null) === 'previous_month_settlement_income') { - $field['label'] = $previousSettlementMonthLabel . '月店铺结算收入-客服公司'; + $field['label'] = $previousSettlementMonthLabel . '月店铺结算收入-服务公司'; } return $field; @@ -1736,7 +1736,7 @@ data-directory-user="{{ auth()->id() ?? 'guest' }}" data-directory-section="{{ $section['slug'] ?? '' }}" data-directory-tab="{{ $activeTab['slug'] ?? '' }}" - data-directory-column-preference-version="{{ $directoryPageKey === 'company-management/subject-operating-report' ? 'subject-settlement-income-v1' : ($directoryPageKey === 'store-management/store-report' ? 'empty-month-columns-v1' : '') }}" + data-directory-column-preference-version="{{ $directoryPageKey === 'company-management/subject-operating-report' ? 'subject-service-company-income-v2' : ($directoryPageKey === 'store-management/store-report' ? 'empty-month-columns-v1' : '') }}" aria-label="{{ $page['title'] ?? $section['title'] }}" > @if ($directoryUsesFieldQuery) diff --git a/tests/Feature/Business/PrototypePageTest.php b/tests/Feature/Business/PrototypePageTest.php index 2b9a278b..482df698 100644 --- a/tests/Feature/Business/PrototypePageTest.php +++ b/tests/Feature/Business/PrototypePageTest.php @@ -162,6 +162,10 @@ class PrototypePageTest extends FeatureTestCase $service = Mockery::mock(UpstreamBusinessInformation::class); $service->shouldReceive('paginate')->once()->with('company-management', 'subject-operating-report', 50) ->andReturnUsing(function (): LengthAwarePaginator { + request()->attributes->set( + 'subject-operating-report.default_hidden_columns', + ['previous_month_settlement_income'], + ); request()->attributes->set('directory.status-counts', [ 'total' => 1, 'fields' => [ @@ -214,7 +218,8 @@ class PrototypePageTest extends FeatureTestCase ->assertOk() ->assertSeeText('收入汇总') ->assertSeeTextInOrder(['公司类型', '税务类型', '工商类型', '最近12个月累计收入']) - ->assertSeeText('10月店铺结算收入-客服公司') + ->assertSeeText('10月店铺结算收入-服务公司') + ->assertSee('data-column-key="previous_month_settlement_income"', false) ->assertSeeText('测试主体') ->assertSeeText('小规模纳税人') ->assertSeeText('个人独资企业') @@ -249,7 +254,7 @@ class PrototypePageTest extends FeatureTestCase $augustResponse ->assertOk() - ->assertSeeTextInOrder(['最近12个月累计收入', '7月店铺结算收入-客服公司', '1月收入', '12月收入']) + ->assertSeeTextInOrder(['最近12个月累计收入', '7月店铺结算收入-服务公司', '1月收入', '12月收入']) ->assertDontSeeText('ERP系统店铺名称') ->assertDontSeeText('连续12个月店铺累计收入'); @@ -262,7 +267,7 @@ class PrototypePageTest extends FeatureTestCase $septemberResponse ->assertOk() - ->assertSeeTextInOrder(['最近12个月累计收入', '12月店铺结算收入-客服公司', '1月收入', '12月收入']); + ->assertSeeTextInOrder(['最近12个月累计收入', '12月店铺结算收入-服务公司', '1月收入', '12月收入']); } public function testSubjectOperatingReportHidesMonthsWithoutDataAndKeepsThemSelectable(): void @@ -272,7 +277,7 @@ class PrototypePageTest extends FeatureTestCase ->andReturnUsing(function (): LengthAwarePaginator { request()->attributes->set( 'subject-operating-report.default_hidden_columns', - ['income_01', 'income_02'], + ['previous_month_settlement_income', 'income_01', 'income_02'], ); return new LengthAwarePaginator([(object) [ @@ -301,22 +306,26 @@ class PrototypePageTest extends FeatureTestCase // 没有数据的月份默认隐藏,但表头和「筛选字段」勾选项仍在页面中。 $this->assertMatchesRegularExpression('/]*data-column-key="income_01"[^>]*is-column-hidden/', $html); $this->assertMatchesRegularExpression('/]*data-column-key="income_02"[^>]*is-column-hidden/', $html); + $this->assertMatchesRegularExpression('/]*data-column-key="previous_month_settlement_income"[^>]*is-column-hidden/', $html); $this->assertDoesNotMatchRegularExpression('/]*data-column-key="income_03"[^>]*is-column-hidden/', $html); $this->assertStringContainsString('value="income_01" data-directory-query-column', $html); $this->assertStringNotContainsString('value="income_01" data-directory-query-column checked', $html); + $this->assertStringContainsString('value="previous_month_settlement_income" data-directory-query-column', $html); + $this->assertStringNotContainsString('value="previous_month_settlement_income" data-directory-query-column checked', $html); $this->assertStringContainsString('value="income_03" data-directory-query-column checked', $html); - $this->assertStringContainsString('筛选字段 20/22', $html); - $this->assertStringContainsString('data-directory-column-preference-version="subject-settlement-income-v1"', $html); + $this->assertStringContainsString('筛选字段 19/22', $html); + $this->assertStringContainsString('data-directory-column-preference-version="subject-service-company-income-v2"', $html); // 手动勾选(columns 参数)后,对应月份列恢复显示。 $restored = $this->get(route('business.pages.show', [ 'section' => 'company-management', 'page' => 'subject-operating-report', - 'columns' => 'city,entity,company_type,rolling_12m_income,income_01,income_03', + 'columns' => 'city,entity,company_type,rolling_12m_income,previous_month_settlement_income,income_01,income_03', ])); $restored->assertOk(); $restoredHtml = $restored->getContent(); + $this->assertDoesNotMatchRegularExpression('/]*data-column-key="previous_month_settlement_income"[^>]*is-column-hidden/', $restoredHtml); $this->assertDoesNotMatchRegularExpression('/]*data-column-key="income_01"[^>]*is-column-hidden/', $restoredHtml); $this->assertDoesNotMatchRegularExpression('/]*data-column-key="income_03"[^>]*is-column-hidden/', $restoredHtml); $this->assertMatchesRegularExpression('/]*data-column-key="income_04"[^>]*is-column-hidden/', $restoredHtml); diff --git a/tests/Feature/Business/ServiceCompanyReconciliationTest.php b/tests/Feature/Business/ServiceCompanyReconciliationTest.php index 9968ff47..90e178dc 100644 --- a/tests/Feature/Business/ServiceCompanyReconciliationTest.php +++ b/tests/Feature/Business/ServiceCompanyReconciliationTest.php @@ -269,6 +269,56 @@ SQL); ->assertDontSeeText('结算基数'); } + public function test_customer_service_excel_mapping_is_synced_from_store_company_to_relation(): void + { + $this->seedStoreMirror(); + $this->loginAs(); + $serviceCompanyId = $this->createServiceCompany( + '常州测试客服服务有限公司', + '91320400TESTCUSTOMER05', + ); + $repository = app(ServiceCompanyReconciliationRepository::class); + $rows = [[ + 'service_company_name' => '常州测试客服服务有限公司', + 'store_company_name' => '上海测试店铺公司有限公司', + 'fee_rate' => 0.015, + 'source_row' => 2, + ]]; + + $first = $repository->syncCustomerServiceMappings( + $this->company->id, + $rows, + '2026-10-01', + '测试导入', + ); + $this->assertSame(1, $first['created']); + $this->assertSame([], $first['errors']); + $relation = DB::connection('business_mirror') + ->table('svc_service_relations') + ->where('service_company_id', $serviceCompanyId) + ->first(); + $this->assertNotNull($relation); + $this->assertSame('customer_service_mapping', $relation->source); + $this->assertSame('2026-10-01', $relation->effective_from); + $this->assertSame(0.015, (float) $relation->fee_rate); + $this->assertSame( + ServiceCompanyReconciliationRepository::companyKey( + '上海测试店铺公司有限公司', + '91310000TESTSTORE01', + ), + $relation->store_company_key, + ); + + $second = $repository->syncCustomerServiceMappings( + $this->company->id, + $rows, + '2026-10-01', + '测试导入', + ); + $this->assertSame(1, $second['unchanged']); + $this->assertSame(1, DB::connection('business_mirror')->table('svc_service_relations')->count()); + } + public function test_same_store_company_and_service_type_cannot_have_overlapping_service_companies(): void { $this->seedStoreMirror(); diff --git a/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php b/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php index b523c4b5..bb05bf3b 100644 --- a/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php +++ b/tests/Unit/EcommerceFinance/UpstreamBusinessInformationTest.php @@ -532,7 +532,7 @@ SQL); $paginator = $information->paginate('company-management', 'subject-operating-report', 50); $this->assertSame( - ['income_01', 'income_04', 'income_05', 'income_06', 'income_07', 'income_08', 'income_09', 'income_10', 'income_11', 'income_12'], + ['previous_month_settlement_income', 'income_01', 'income_04', 'income_05', 'income_06', 'income_07', 'income_08', 'income_09', 'income_10', 'income_11', 'income_12'], $this->app['request']->attributes->get('subject-operating-report.default_hidden_columns'), ); $this->assertSame(1, $paginator->total());