'客服服务公司', 'operations' => '运营服务公司', 'warehouse' => '仓储服务公司', 'other' => '其他服务公司', ]; public const BASE_METRICS = [ 'settlement_income' => '业绩明细收入(不减退款)', ]; public const FIXED_FEE_RATES_PERCENT = [ 'customer_service' => 1.5, ]; private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $database) { $this->connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** @return list> */ public function serviceCompanies(int $tenantId, string $period): array { $companies = $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('status', 'active') ->orderBy('service_type_label') ->orderBy('company_name') ->get(); if ($companies->isEmpty()) { return []; } $serviceCompanyIds = $companies->pluck('id')->map(static fn ($id): int => (int) $id)->all(); $today = CarbonImmutable::today()->toDateString(); $periodStart = CarbonImmutable::createFromFormat('!Y-m', $period)->startOfMonth(); $periodEnd = $periodStart->endOfMonth(); $assignments = $this->connection->table('svc_service_people') ->where('company_id', $tenantId) ->whereIn('service_company_id', $serviceCompanyIds) ->where('status', 'active') ->where('effective_from', '<=', $today) ->where(static fn ($query) => $query ->whereNull('effective_to') ->orWhere('effective_to', '>=', $today)) ->get() ->groupBy(static fn ($row): int => (int) $row->service_company_id); $relations = $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->whereIn('service_company_id', $serviceCompanyIds) ->where('status', 'active') ->where('effective_from', '<=', $periodEnd->toDateString()) ->where(static fn ($query) => $query ->whereNull('effective_to') ->orWhere('effective_to', '>=', $periodStart->toDateString())) ->get() ->groupBy(static fn ($row): int => (int) $row->service_company_id); $settlements = $this->connection->table('svc_service_settlements') ->where('company_id', $tenantId) ->where('period', $period) ->whereIn('service_company_id', $serviceCompanyIds) ->get() ->groupBy(static fn ($row): int => (int) $row->service_company_id); $salaryByPerson = collect($this->availablePeople($tenantId))->keyBy('person_key'); return $companies->map(function ($company) use ($assignments, $relations, $settlements, $salaryByPerson): array { $serviceCompanyId = (int) $company->id; $companyAssignments = $assignments->get($serviceCompanyId, collect()); $companyRelations = $relations->get($serviceCompanyId, collect()); $companySettlements = $settlements->get($serviceCompanyId, collect()); $laborCost = $companyAssignments->sum( static fn ($assignment): float => (float) ($salaryByPerson->get($assignment->person_key)['salary'] ?? 0), ); $settlementAmount = (float) $companySettlements->sum('service_fee'); $confirmedAmount = (float) $companySettlements ->where('status', 'confirmed') ->sum('service_fee'); $invoiceAmount = (float) $companySettlements->sum('invoice_amount'); $paidAmount = (float) $companySettlements->sum('paid_amount'); $fixedFeeRatePercent = self::fixedFeeRatePercent((string) $company->service_type_code); $relationFeeRates = $companyRelations ->pluck('fee_rate') ->map(static fn ($rate): float => round((float) $rate * 100, 4)) ->unique() ->sort() ->values(); $settlementRateLabel = $fixedFeeRatePercent !== null ? self::feeRatePercentLabel($fixedFeeRatePercent) : ($relationFeeRates->isEmpty() ? '—' : ($relationFeeRates->count() === 1 ? self::feeRatePercentLabel((float) $relationFeeRates->first()) : self::feeRatePercentLabel((float) $relationFeeRates->first()) . '–' . self::feeRatePercentLabel((float) $relationFeeRates->last()))); $hasOpenInvoice = $companySettlements->contains( static fn ($row): bool => ! in_array((string) $row->invoice_status, ['invoiced', 'waived'], true), ); $hasOpenPayment = $companySettlements->contains( static fn ($row): bool => (string) $row->payment_status !== 'paid', ); $hasUnconfirmed = $companySettlements->contains( static fn ($row): bool => (string) $row->status !== 'confirmed', ); return [ 'id' => $serviceCompanyId, 'company_key' => (string) $company->company_key, 'company_name' => (string) $company->company_name, 'unified_social_credit_code' => $company->unified_social_credit_code, 'service_type_code' => (string) $company->service_type_code, 'service_type_label' => (string) $company->service_type_label, 'taxpayer_type' => $company->taxpayer_type, 'business_registration_type' => $company->business_registration_type, 'company_status' => (string) ($company->company_status ?: '正常'), 'planning_target_amount' => $company->planning_target_amount !== null ? (float) $company->planning_target_amount : null, 'status' => (string) $company->status, 'people_count' => $companyAssignments->count(), 'labor_cost' => $laborCost, 'relation_count' => $companyRelations->count(), 'base_amount' => (float) $companySettlements->sum('base_amount'), 'settlement_rate_label' => $settlementRateLabel, 'settlement_amount' => $settlementAmount, 'confirmed_amount' => $confirmedAmount, 'invoice_amount' => $invoiceAmount, 'paid_amount' => $paidAmount, 'settlement_count' => $companySettlements->count(), 'invoice_label' => $companySettlements->isEmpty() ? '尚未生成' : ($hasOpenInvoice ? '开票待处理' : '已开票'), 'payment_label' => $companySettlements->isEmpty() ? '尚未生成' : ($hasOpenPayment ? '到账待确认' : '已到账'), 'reconciliation_status' => $companyRelations->isEmpty() ? '未配置' : ($companySettlements->isEmpty() ? '待生成' : ($hasUnconfirmed ? '待确认' : '已确认')), ]; })->all(); } /** @return array|null */ public function serviceCompany(int $tenantId, int $serviceCompanyId): ?array { $row = $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('id', $serviceCompanyId) ->first(); return $row ? (array) $row : null; } /** * 将公司盘点中“类型包含服务公司”的主体同步为服务公司档案。 * * 同步只更新公司名称、类型、税务类型、信用代码和公司状态;手工维护的 * 规划量、备注和启停状态保持不变。 * * @param list> $rows */ public function syncCompanyPlanServiceCompanies(int $tenantId, array $rows): int { return $this->connection->transaction(function () use ($tenantId, $rows): int { $seen = []; $synced = 0; $syncedAt = now(); foreach ($rows as $row) { $typeLabel = trim((string) ($row['company_type'] ?? '')); $companyName = trim((string) ($row['company_name'] ?? '')); if ($companyName === '' || mb_stripos($typeLabel, '服务公司') === false) { continue; } $creditCode = strtoupper(trim((string) ($row['unified_social_credit_code'] ?? ''))); $companyKey = $this->companyKey($companyName, $creditCode); $serviceTypeCode = $this->serviceTypeCodeFromLabel($typeLabel); $seen[$companyKey . '|' . $serviceTypeCode] = true; $payload = [ 'company_key' => $companyKey, 'company_name' => mb_substr($companyName, 0, 200), 'unified_social_credit_code' => $creditCode !== '' ? mb_substr($creditCode, 0, 64) : null, 'service_type_code' => $serviceTypeCode, 'service_type_label' => mb_substr($typeLabel, 0, 64), 'taxpayer_type' => $this->nullableText($row['taxpayer_type'] ?? null, 64), 'business_registration_type' => $this->nullableText( $row['business_registration_type'] ?? null, 64, ), 'company_status' => $this->nullableText($row['company_status'] ?? null, 32) ?: '正常', 'source' => 'company_plan', 'source_row_id' => isset($row['id']) ? (int) $row['id'] : null, 'source_synced_at' => $syncedAt, 'updated_at' => $syncedAt, ]; $existing = $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('company_key', $companyKey) ->where('service_type_code', $serviceTypeCode) ->first(); if ($existing) { $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('id', $existing->id) ->update($payload); } else { $this->connection->table('svc_service_companies')->insert([ ...$payload, 'company_id' => $tenantId, 'status' => 'active', 'created_by' => '公司盘点同步', 'created_at' => $syncedAt, ]); } $synced++; } $imported = $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('source', 'company_plan') ->get(); foreach ($imported as $company) { $key = (string) $company->company_key . '|' . (string) $company->service_type_code; if (! isset($seen[$key]) && (string) $company->status !== 'inactive') { $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('id', $company->id) ->update([ 'status' => 'inactive', 'source_synced_at' => $syncedAt, 'updated_at' => $syncedAt, ]); } } return $synced; }); } /** @return list> */ public function relations(int $tenantId, int $serviceCompanyId): array { return $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->orderByDesc('effective_from') ->orderByDesc('id') ->get() ->map(static fn ($row): array => (array) $row) ->all(); } /** @return list> */ public function settlements(int $tenantId, int $serviceCompanyId, string $period): array { $settlements = $this->connection->table('svc_service_settlements') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('period', $period) ->orderBy('store_company_name') ->get(); if ($settlements->isEmpty()) { return []; } $details = $this->connection->table('svc_service_settlement_details') ->whereIn('settlement_id', $settlements->pluck('id')->all()) ->orderBy('entity_period_from') ->orderBy('erp_store_name') ->get() ->groupBy(static fn ($row): int => (int) $row->settlement_id); return $settlements->map(static function ($row) use ($details): array { $data = (array) $row; $data['details'] = $details->get((int) $row->id, collect()) ->map(static fn ($detail): array => (array) $detail) ->values() ->all(); return $data; })->all(); } /** @return list> */ public function assignedPeople(int $tenantId, int $serviceCompanyId): array { $salaryByPerson = collect($this->availablePeople($tenantId))->keyBy('person_key'); $today = CarbonImmutable::today()->toDateString(); return $this->connection->table('svc_service_people') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->orderByDesc('effective_from') ->orderByDesc('id') ->get() ->map(static function ($row) use ($salaryByPerson, $today): array { $data = (array) $row; $data['salary'] = (float) ($salaryByPerson->get($row->person_key)['salary'] ?? 0); $data['is_current'] = $row->status === 'active' && $row->effective_from <= $today && ($row->effective_to === null || $row->effective_to >= $today); return $data; })->all(); } /** @return array> */ public function availablePeople(int $tenantId): array { $snapshot = DB::table('business_labor_expense_snapshots') ->where('company_id', $tenantId) ->first(); if (! $snapshot) { return []; } $snapshotRows = is_array($snapshot->rows) ? $snapshot->rows : json_decode((string) $snapshot->rows, true); if (! is_array($snapshotRows)) { return []; } $people = []; foreach ($snapshotRows as $row) { if (! is_array($row)) { continue; } $name = trim((string) ($row['C'] ?? '')); if ($name === '') { continue; } $alias = trim((string) ($row['B'] ?? '')); $department = trim((string) ($row['D'] ?? '')); $position = trim((string) ($row['E'] ?? '')); $personKey = $this->personKey($name, $alias, $department); $salaryText = str_replace([',', ',', '¥', '¥'], '', trim((string) ($row['F'] ?? '0'))); $people[$personKey] = [ 'person_key' => $personKey, 'employee_name' => $name, 'alias' => $alias, 'department' => $department, 'position' => $position, 'salary' => is_numeric($salaryText) ? (float) $salaryText : 0.0, ]; } return $people; } /** @return list> */ public function storeCompanies(int $tenantId): array { $lifecycleGeneration = $this->activeGeneration('store-report.store-lifecycles'); if ($lifecycleGeneration === null) { return []; } $rows = $this->connection->table('store_report_lifecycle_rows') ->where('generation', $lifecycleGeneration) ->whereNotNull('company_name') ->where('company_name', '<>', '') ->select(['company_name', 'unified_social_credit_code']) ->distinct() ->get(); return $rows ->map(function ($row): array { $name = trim((string) $row->company_name); $creditCode = trim((string) ($row->unified_social_credit_code ?? '')); return [ 'key' => $this->companyKey($name, $creditCode), 'name' => $name, 'unified_social_credit_code' => $creditCode !== '' ? $creditCode : null, ]; }) ->filter(static fn (array $row): bool => $row['name'] !== '') ->unique('key') ->sortBy('name') ->values() ->all(); } /** @return list> */ public function companyDirectory(): array { try { return $this->connection->table('ops_fys_company_info') ->whereNotNull('businessLicenseName') ->where('businessLicenseName', '<>', '') ->select([ 'businessLicenseName AS company_name', 'unifiedSocialCreditCode AS unified_social_credit_code', 'businessLicenseType AS company_type', 'taxCategoryAndType AS taxpayer_type', 'companyStatus AS company_status', ]) ->orderBy('businessLicenseName') ->get() ->map(static fn ($row): array => (array) $row) ->unique(static fn (array $row): string => self::companyKey( (string) $row['company_name'], (string) ($row['unified_social_credit_code'] ?? ''), )) ->values() ->all(); } catch (\Throwable) { return []; } } /** * @param array $data * @return array */ public function saveServiceCompany(int $tenantId, array $data, string $actor, ?int $id = null): array { $serviceTypeCode = (string) ($data['service_type_code'] ?? ''); if (! isset(self::SERVICE_TYPES[$serviceTypeCode])) { throw ValidationException::withMessages(['service_type_code' => '服务类型无效。']); } $companyName = trim((string) ($data['company_name'] ?? '')); $creditCode = strtoupper(trim((string) ($data['unified_social_credit_code'] ?? ''))); if ($companyName === '') { throw ValidationException::withMessages(['company_name' => '请填写服务公司名称。']); } $companyKey = $this->companyKey($companyName, $creditCode); $payload = [ 'company_key' => $companyKey, 'company_name' => mb_substr($companyName, 0, 200), 'unified_social_credit_code' => $creditCode !== '' ? mb_substr($creditCode, 0, 64) : null, 'service_type_code' => $serviceTypeCode, 'service_type_label' => self::SERVICE_TYPES[$serviceTypeCode], 'taxpayer_type' => $this->nullableText($data['taxpayer_type'] ?? null, 64), 'business_registration_type' => $this->nullableText( $data['business_registration_type'] ?? null, 64, ), 'company_status' => $this->nullableText($data['company_status'] ?? null, 32) ?: '正常', 'planning_target_amount' => isset($data['planning_target_amount']) && $data['planning_target_amount'] !== '' ? (float) $data['planning_target_amount'] : null, 'status' => in_array($data['status'] ?? 'active', ['active', 'inactive'], true) ? $data['status'] : 'active', 'notes' => $this->nullableText($data['notes'] ?? null, 500), 'updated_by' => $actor, ]; return $this->connection->transaction(function () use ($tenantId, $id, $payload, $actor): array { if ($id !== null) { $existing = $this->serviceCompany($tenantId, $id); if ($existing === null) { throw ValidationException::withMessages(['company_name' => '服务公司不存在。']); } $duplicate = $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('company_key', $payload['company_key']) ->where('service_type_code', $payload['service_type_code']) ->where('id', '<>', $id) ->exists(); if ($duplicate) { throw ValidationException::withMessages(['company_name' => '该公司的服务类型已经登记。']); } $payload['updated_at'] = now(); $this->connection->table('svc_service_companies') ->where('company_id', $tenantId) ->where('id', $id) ->update($payload); return $this->serviceCompany($tenantId, $id) ?? []; } $payload['company_id'] = $tenantId; $payload['created_by'] = $actor; $payload['created_at'] = now(); $payload['updated_at'] = now(); $newId = (int) $this->connection->table('svc_service_companies')->insertGetId($payload); return $this->serviceCompany($tenantId, $newId) ?? []; }); } /** * @param array $data * @return array */ public function saveRelation( int $tenantId, int $serviceCompanyId, array $data, string $actor, ?int $relationId = null, ): array { $company = $this->serviceCompany($tenantId, $serviceCompanyId); if ($company === null) { throw ValidationException::withMessages(['store_company_key' => '服务公司不存在。']); } $storeCompany = collect($this->storeCompanies($tenantId)) ->firstWhere('key', (string) ($data['store_company_key'] ?? '')); if (! $storeCompany) { throw ValidationException::withMessages(['store_company_key' => '请选择有效的店铺公司。']); } $effectiveFrom = trim((string) ($data['effective_from'] ?? '')); $effectiveTo = trim((string) ($data['effective_to'] ?? '')); if ($effectiveFrom === '') { throw ValidationException::withMessages(['effective_from' => '请选择生效日期。']); } $feeRatePercent = (float) ($data['fee_rate_percent'] ?? 1.5); if ($feeRatePercent < 0 || $feeRatePercent > 100) { throw ValidationException::withMessages(['fee_rate_percent' => '结算比例必须在 0% 到 100% 之间。']); } $fixedFeeRatePercent = self::fixedFeeRatePercent((string) $company['service_type_code']); if ($fixedFeeRatePercent !== null && abs($feeRatePercent - $fixedFeeRatePercent) > 0.000001) { throw ValidationException::withMessages([ 'fee_rate_percent' => sprintf( '%s的结算比例固定为 %s%%。', self::SERVICE_TYPES[(string) $company['service_type_code']], rtrim(rtrim(number_format($fixedFeeRatePercent, 4, '.', ''), '0'), '.'), ), ]); } $payload = [ 'service_company_key' => (string) $company['company_key'], 'service_company_name' => (string) $company['company_name'], 'store_company_key' => $storeCompany['key'], 'store_company_name' => $storeCompany['name'], 'store_company_credit_code' => $storeCompany['unified_social_credit_code'], 'service_type_code' => (string) $company['service_type_code'], 'base_metric' => array_key_exists((string) ($data['base_metric'] ?? ''), self::BASE_METRICS) ? (string) $data['base_metric'] : 'settlement_income', 'fee_rate' => round($feeRatePercent / 100, 6), 'effective_from' => $effectiveFrom, 'effective_to' => $effectiveTo !== '' ? $effectiveTo : null, 'contract_no' => $this->nullableText($data['contract_no'] ?? null, 64), 'contract_status' => in_array($data['contract_status'] ?? 'pending', ['pending', 'stamped', 'waived'], true) ? $data['contract_status'] : 'pending', '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, ]; return $this->connection->transaction(function () use ( $tenantId, $serviceCompanyId, $relationId, $payload, $actor, ): array { if ($payload['status'] === 'active' && $this->relationOverlaps($tenantId, $payload, $relationId)) { throw ValidationException::withMessages([ 'effective_from' => '同一店铺公司和服务类型在生效期间内已存在服务公司,不能重叠。', ]); } if ($relationId !== null) { $existing = $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('id', $relationId) ->first(); if (! $existing) { throw ValidationException::withMessages(['effective_from' => '结算关系不存在。']); } $payload['rule_version'] = (int) $existing->rule_version + 1; $payload['updated_at'] = now(); $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('id', $relationId) ->update($payload); $this->recordRelationVersion($tenantId, $relationId, (array) $existing, $actor); $updated = (array) $this->connection->table('svc_service_relations')->find($relationId); $this->recordRelationVersion($tenantId, $relationId, $updated, $actor); return $updated; } $payload['company_id'] = $tenantId; $payload['service_company_id'] = $serviceCompanyId; $payload['rule_version'] = 1; $payload['created_by'] = $actor; $payload['created_at'] = now(); $payload['updated_at'] = now(); $newId = (int) $this->connection->table('svc_service_relations')->insertGetId($payload); $created = (array) $this->connection->table('svc_service_relations')->find($newId); $this->recordRelationVersion($tenantId, $newId, $created, $actor); return $created; }); } /** * 将客服公司 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, string $personKey, string $effectiveFrom, string $actor, ): array { if ($this->serviceCompany($tenantId, $serviceCompanyId) === null) { throw ValidationException::withMessages(['person_key' => '服务公司不存在。']); } $person = $this->availablePeople($tenantId)[$personKey] ?? null; if (! $person) { throw ValidationException::withMessages(['person_key' => '请选择有效人员。']); } return $this->connection->transaction(function () use ( $tenantId, $serviceCompanyId, $personKey, $person, $effectiveFrom, $actor, ): array { $overlap = $this->connection->table('svc_service_people') ->where('company_id', $tenantId) ->where('person_key', $personKey) ->where('status', 'active') ->where('effective_from', '<=', '9999-12-31') ->where(static fn ($query) => $query ->whereNull('effective_to') ->orWhere('effective_to', '>=', $effectiveFrom)) ->exists(); if ($overlap) { throw ValidationException::withMessages([ 'person_key' => '该人员在此日期已有有效服务公司归属。', ]); } $payload = [ 'company_id' => $tenantId, 'service_company_id' => $serviceCompanyId, 'person_key' => $personKey, 'employee_name' => $person['employee_name'], 'alias' => $person['alias'] ?: null, 'department' => $person['department'] ?: null, 'position' => $person['position'] ?: null, 'effective_from' => $effectiveFrom, 'effective_to' => null, 'status' => 'active', 'source' => 'labor_snapshot', 'created_at' => now(), 'updated_at' => now(), ]; $newId = (int) $this->connection->table('svc_service_people')->insertGetId($payload); return (array) $this->connection->table('svc_service_people')->find($newId); }); } public function endPersonAssignment( int $tenantId, int $serviceCompanyId, int $assignmentId, string $effectiveTo, ): void { $updated = $this->connection->table('svc_service_people') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('id', $assignmentId) ->update([ 'effective_to' => $effectiveTo, 'status' => $effectiveTo <= CarbonImmutable::today()->toDateString() ? 'inactive' : 'active', 'updated_at' => now(), ]); if ($updated !== 1) { throw ValidationException::withMessages(['assignment' => '人员归属记录不存在。']); } } /** * @return array{generated:int,errors:list,source_generation:string} */ public function generateSettlements( int $tenantId, int $serviceCompanyId, string $period, string $actor, ): array { $relations = $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('status', 'active') ->orderBy('id') ->get(); if ($relations->isEmpty()) { throw ValidationException::withMessages(['period' => '请先配置至少一条店铺公司结算关系。']); } $paidGeneration = $this->activeGeneration('store-report.paid-sales'); $lifecycleGeneration = $this->activeGeneration('store-report.store-lifecycles'); if ($paidGeneration === null || $lifecycleGeneration === null) { throw ValidationException::withMessages(['period' => '业绩明细或店铺主体期间尚未完成同步。']); } $periodStart = CarbonImmutable::createFromFormat('!Y-m', $period)->startOfMonth(); $periodEnd = $periodStart->addMonth(); $baseRows = $this->baseRowsByStoreCompany( $paidGeneration, $lifecycleGeneration, $periodStart, $periodEnd, ); $availableStoreCompanyKeys = collect($this->storeCompanies($tenantId)) ->pluck('key') ->flip(); $sourceGeneration = mb_substr( 'paid:' . $paidGeneration . '|life:' . $lifecycleGeneration, 0, 191, ); return $this->connection->transaction(function () use ( $tenantId, $serviceCompanyId, $period, $periodStart, $periodEnd, $relations, $baseRows, $availableStoreCompanyKeys, $sourceGeneration, $actor, ): array { $generated = 0; $errors = []; foreach ($relations as $relation) { $relationEnd = $relation->effective_to ?: '2999-12-31'; if ($relation->effective_from > $periodStart->toDateString() || $relationEnd < $periodEnd->subDay()->toDateString()) { $errors[] = $relation->store_company_name . ':结算关系未覆盖整个月份。'; continue; } if ((string) $relation->base_metric !== 'settlement_income') { $errors[] = $relation->store_company_name . ':当前尚不支持该收入口径。'; continue; } if (! isset($availableStoreCompanyKeys[$relation->store_company_key])) { $errors[] = $relation->store_company_name . ':未找到当前店铺主体期间,暂不生成结算。'; continue; } $unique = [ 'company_id' => $tenantId, 'period' => $period, 'service_company_id' => $serviceCompanyId, 'store_company_key' => $relation->store_company_key, 'service_type_code' => $relation->service_type_code, ]; $existing = $this->connection->table('svc_service_settlements') ->where($unique) ->first(); if ($existing && (string) $existing->status === 'confirmed') { $errors[] = $relation->store_company_name . ':已确认结算不可覆盖。'; continue; } $rows = collect($baseRows[$relation->store_company_key] ?? []); $baseAmount = round((float) $rows->sum('base_amount'), 4); $serviceFee = round((float) $rows->sum('base_amount') * (float) $relation->fee_rate, 2); $payload = [ ...$unique, 'relation_id' => (int) $relation->id, 'store_company_name' => (string) $relation->store_company_name, 'rule_version' => (int) $relation->rule_version, 'base_metric' => (string) $relation->base_metric, 'base_amount' => $baseAmount, 'fee_rate' => (float) $relation->fee_rate, 'service_fee' => $serviceFee, 'invoice_status' => 'pending', 'invoice_amount' => 0, 'payment_status' => 'pending', 'paid_amount' => 0, 'status' => 'draft', 'source_generation' => $sourceGeneration, 'generated_by' => $actor, 'generated_at' => now(), 'confirmed_by' => null, 'confirmed_at' => null, 'updated_at' => now(), ]; if ($existing) { $settlementId = (int) $existing->id; $this->connection->table('svc_service_settlements') ->where('id', $settlementId) ->update($payload); } else { $payload['created_at'] = now(); $settlementId = (int) $this->connection->table('svc_service_settlements')->insertGetId($payload); } $this->connection->table('svc_service_settlement_details') ->where('settlement_id', $settlementId) ->delete(); foreach ($rows as $row) { $this->connection->table('svc_service_settlement_details')->insert([ 'settlement_id' => $settlementId, 'erp_store_name' => $row['erp_store_name'], 'entity_period_from' => $row['entity_period_from'], 'entity_period_to' => $row['entity_period_to'], 'base_amount' => $row['base_amount'], 'service_fee' => round((float) $row['base_amount'] * (float) $relation->fee_rate, 2), 'source_generation' => $sourceGeneration, 'created_at' => now(), 'updated_at' => now(), ]); } $generated++; } return [ 'generated' => $generated, 'errors' => $errors, 'source_generation' => $sourceGeneration, ]; }); } /** @param array $data */ public function updateSettlement( int $tenantId, int $serviceCompanyId, int $settlementId, array $data, string $actor, ): void { $settlement = $this->connection->table('svc_service_settlements') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('id', $settlementId) ->first(); if (! $settlement) { throw ValidationException::withMessages(['settlement' => '结算记录不存在。']); } $payload = [ 'invoice_status' => in_array($data['invoice_status'] ?? $settlement->invoice_status, [ 'pending', 'partial', 'invoiced', 'waived', ], true) ? $data['invoice_status'] : $settlement->invoice_status, 'invoice_amount' => max(0, (float) ($data['invoice_amount'] ?? $settlement->invoice_amount)), 'payment_status' => in_array($data['payment_status'] ?? $settlement->payment_status, [ 'pending', 'partial', 'paid', ], true) ? $data['payment_status'] : $settlement->payment_status, 'paid_amount' => max(0, (float) ($data['paid_amount'] ?? $settlement->paid_amount)), 'notes' => $this->nullableText($data['notes'] ?? $settlement->notes, 1000), 'updated_at' => now(), ]; $serviceFee = (float) $settlement->service_fee; if ((float) $payload['invoice_amount'] > $serviceFee || (float) $payload['paid_amount'] > $serviceFee) { throw ValidationException::withMessages([ 'invoice_amount' => '开票金额和到账金额不能超过当前结算金额。', ]); } if (($data['action'] ?? '') === 'confirm') { if ((string) $settlement->status === 'confirmed') { return; } $payload['status'] = 'confirmed'; $payload['confirmed_by'] = $actor; $payload['confirmed_at'] = now(); } $this->connection->table('svc_service_settlements') ->where('company_id', $tenantId) ->where('service_company_id', $serviceCompanyId) ->where('id', $settlementId) ->update($payload); } public static function companyKey(string $companyName, ?string $creditCode = null): string { $creditCode = strtoupper((string) preg_replace('/\s+/u', '', trim((string) $creditCode))); if ($creditCode !== '') { return 'code:' . $creditCode; } $normalized = mb_strtolower((string) preg_replace('/\s+/u', '', trim($companyName))); return 'name:' . $normalized; } public static function personKey(string $name, string $alias = '', string $department = ''): string { $normalized = mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($alias)) . '|' . mb_strtolower(trim($department)); return 'labor:' . hash('sha256', $normalized); } /** @return array>> */ private function baseRowsByStoreCompany( string $paidGeneration, string $lifecycleGeneration, CarbonImmutable $periodStart, CarbonImmutable $periodEnd, ): array { $periodEndDate = $periodEnd->subDay()->toDateString(); $prefix = $this->connection->getTablePrefix(); $salesAlias = $prefix . 'sales'; $lifecycleAlias = $prefix . 'lifecycle'; $rows = $this->connection->table('store_report_paid_sales_rows AS sales') ->join('store_report_lifecycle_rows AS lifecycle', function (JoinClause $join) use ( $salesAlias, $lifecycleAlias, ): void { $join->on('lifecycle.erp_store_name', '=', 'sales.store_name') ->whereRaw( "DATE({$lifecycleAlias}.effective_at) <= DATE({$salesAlias}.occurred_on)", ) ->whereRaw( "({$lifecycleAlias}.expires_at IS NULL " . "OR DATE({$lifecycleAlias}.expires_at) >= DATE({$salesAlias}.occurred_on))", ); }) ->where('sales.generation', $paidGeneration) ->where('lifecycle.generation', $lifecycleGeneration) ->where('sales.occurred_on', '>=', $periodStart->toDateString()) ->where('sales.occurred_on', '<', $periodEnd->toDateString()) ->whereNotNull('lifecycle.company_name') ->where('lifecycle.company_name', '<>', '') ->select([ 'lifecycle.company_name', 'lifecycle.unified_social_credit_code', 'sales.store_name', $this->connection->raw( "MIN(DATE({$lifecycleAlias}.effective_at)) AS entity_period_from", ), $this->connection->raw( "MAX(COALESCE(DATE({$lifecycleAlias}.expires_at), '{$periodEndDate}')) " . 'AS entity_period_to', ), $this->connection->raw("SUM({$salesAlias}.settlement_income) AS base_amount"), ]) ->groupBy( 'lifecycle.company_name', 'lifecycle.unified_social_credit_code', 'sales.store_name', ) ->orderBy('lifecycle.company_name') ->orderBy('sales.store_name') ->get(); $grouped = []; foreach ($rows as $row) { $key = self::companyKey( (string) $row->company_name, (string) ($row->unified_social_credit_code ?? ''), ); $grouped[$key][] = [ 'company_name' => (string) $row->company_name, 'unified_social_credit_code' => $row->unified_social_credit_code, 'erp_store_name' => (string) $row->store_name, 'entity_period_from' => $row->entity_period_from, 'entity_period_to' => $row->entity_period_to, 'base_amount' => (float) $row->base_amount, ]; } return $grouped; } private function relationOverlaps(int $tenantId, array $payload, ?int $ignoreId): bool { $query = $this->connection->table('svc_service_relations') ->where('company_id', $tenantId) ->where('store_company_key', $payload['store_company_key']) ->where('service_type_code', $payload['service_type_code']) ->where('status', 'active') ->where('effective_from', '<=', $payload['effective_to'] ?: '2999-12-31') ->where(static fn ($builder) => $builder ->whereNull('effective_to') ->orWhere('effective_to', '>=', $payload['effective_from'])); if ($ignoreId !== null) { $query->where('id', '<>', $ignoreId); } return $query->lockForUpdate()->exists(); } private function activeGeneration(string $datasetKey): ?string { $state = $this->connection->table('store_report_import_states') ->where('dataset_key', $datasetKey) ->first(); $generation = trim((string) ($state->active_generation ?? '')); return $generation !== '' ? $generation : null; } /** @param array $relation */ private function recordRelationVersion( int $tenantId, int $relationId, array $relation, string $actor, ): void { $version = (int) ($relation['rule_version'] ?? 0); if ($version < 1 || $this->connection->table('svc_service_relation_versions') ->where('relation_id', $relationId) ->where('rule_version', $version) ->exists()) { return; } $this->connection->table('svc_service_relation_versions')->insert([ 'company_id' => $tenantId, 'relation_id' => $relationId, 'service_company_id' => (int) ($relation['service_company_id'] ?? 0), 'store_company_key' => (string) ($relation['store_company_key'] ?? ''), 'store_company_name' => (string) ($relation['store_company_name'] ?? ''), 'service_type_code' => (string) ($relation['service_type_code'] ?? ''), 'base_metric' => (string) ($relation['base_metric'] ?? 'settlement_income'), 'fee_rate' => (float) ($relation['fee_rate'] ?? 0), 'effective_from' => (string) ($relation['effective_from'] ?? ''), 'effective_to' => $relation['effective_to'] ?? null, 'contract_no' => $relation['contract_no'] ?? null, 'contract_status' => (string) ($relation['contract_status'] ?? 'pending'), 'status' => (string) ($relation['status'] ?? 'active'), 'rule_version' => $version, 'notes' => $relation['notes'] ?? null, 'changed_by' => $actor, 'created_at' => now(), ]); } private function nullableText(mixed $value, int $length): ?string { $text = trim((string) ($value ?? '')); return $text === '' ? null : mb_substr($text, 0, $length); } public static function fixedFeeRatePercent(string $serviceTypeCode): ?float { return self::FIXED_FEE_RATES_PERCENT[$serviceTypeCode] ?? null; } private static function feeRatePercentLabel(float $percent): string { 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) { return 'customer_service'; } if (mb_stripos($label, '仓储') !== false) { return 'warehouse'; } if (mb_stripos($label, '运营') !== false) { return 'operations'; } return 'other'; } }