feat: 接入客服公司店铺映射与主体报表收入 (#1095)

导入客服公司店铺映射,主体报表收入字段改名并默认隐藏。
This commit is contained in:
DoDo 2026-11-11 17:39:54 +08:00
parent 9a162b3f0a
commit 65a7da66f0
13 changed files with 529 additions and 20 deletions

View File

@ -0,0 +1,83 @@
<?php
namespace App\Business\ServiceCompanyReconciliation;
use App\Business\XlsxSheetReader;
use RuntimeException;
class CustomerServiceMappingWorkbookImporter
{
public function __construct(private readonly XlsxSheetReader $reader) {}
/**
* @return array{
* rows:list<array{service_company_name:string,store_company_name:string,fee_rate:float,source_row:int}>,
* total_rows:int,
* assigned_rows:int,
* unassigned_rows:int,
* invalid_rows:list<string>
* }
*/
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;
}
}

View File

@ -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<array{service_company_name:string,store_company_name:string,fee_rate:float,source_row?:int}> $rows
* @return array{created:int,updated:int,unchanged:int,inactive:int,errors:list<string>}
*/
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) {

View File

@ -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)));

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Business\ServiceCompanyReconciliation\CustomerServiceMappingWorkbookImporter;
use App\Business\ServiceCompanyReconciliation\ServiceCompanyReconciliationRepository;
use Illuminate\Console\Command;
class ImportServiceCompanyCustomerMapping extends Command
{
protected $signature = 'service-company:import-customer-mapping
{file : 客服公司映射 Excel 路径}
{--company-id=1 : 租户公司 ID}
{--effective-from=2026-10-01 : 结算关系生效日期}
{--actor=客服公司映射导入 : 导入操作人}
{--dry-run : 只解析和校验,不写入数据库}';
protected $description = '导入客服公司与店铺公司的配置映射';
public function handle(
CustomerServiceMappingWorkbookImporter $importer,
ServiceCompanyReconciliationRepository $repository,
): int {
$companyId = (int) $this->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;
}
}

View File

@ -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' => '规划'],

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): 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->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);
}
}
});
}
};

View File

@ -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 主体合并

View File

@ -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`。
- 人员从现有工资快照选择并归属服务公司,人员同一天只能有一个有效主归属。

View File

@ -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' => '与销量分开统计',
],

View File

@ -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)

View File

@ -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('/<th[^>]*data-column-key="income_01"[^>]*is-column-hidden/', $html);
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="income_02"[^>]*is-column-hidden/', $html);
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="previous_month_settlement_income"[^>]*is-column-hidden/', $html);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*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('/<th[^>]*data-column-key="previous_month_settlement_income"[^>]*is-column-hidden/', $restoredHtml);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="income_01"[^>]*is-column-hidden/', $restoredHtml);
$this->assertDoesNotMatchRegularExpression('/<th[^>]*data-column-key="income_03"[^>]*is-column-hidden/', $restoredHtml);
$this->assertMatchesRegularExpression('/<th[^>]*data-column-key="income_04"[^>]*is-column-hidden/', $restoredHtml);

View File

@ -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();

View File

@ -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());