middleware('auth'); $this->middleware('company.identify'); } public function index(Request $request): View { $tenantId = (int) (company_id() ?: 1); $period = $this->period($request); $companyPlanSyncError = $this->syncCompanyPlan($tenantId); $serviceCompanies = $this->repository->serviceCompanies($tenantId, $period); return view('business.pages.financial-operations.service-company-reconciliation', [ 'isDetail' => false, 'period' => $period, 'periodOptions' => $this->periodOptions(), 'serviceCompanies' => $serviceCompanies, 'serviceCompany' => null, 'relations' => [], 'settlements' => [], 'assignedPeople' => [], 'availablePeople' => [], 'storeCompanies' => [], 'serviceTypes' => ServiceCompanyReconciliationRepository::SERVICE_TYPES, 'fixedFeeRatePercent' => null, 'companyDirectory' => $this->repository->companyDirectory(), 'companyPlanSyncError' => $companyPlanSyncError, 'canEdit' => FinanceLedgerAccess::canEditMapping(user()), 'summary' => [ 'companies' => count($serviceCompanies), 'people' => array_sum(array_column($serviceCompanies, 'people_count')), 'labor_cost' => array_sum(array_column($serviceCompanies, 'labor_cost')), 'settlement_amount' => array_sum(array_column($serviceCompanies, 'settlement_amount')), 'pending' => count(array_filter( $serviceCompanies, static fn (array $row): bool => in_array( $row['reconciliation_status'], ['待生成', '待确认', '未配置'], true, ), )), ], ]); } public function show(Request $request, int $serviceCompany): View { $tenantId = (int) (company_id() ?: 1); $companyPlanSyncError = $this->syncCompanyPlan($tenantId); $company = $this->repository->serviceCompany($tenantId, $serviceCompany); abort_if($company === null, 404); $period = $this->period($request); $allRelations = $this->repository->relations($tenantId, $serviceCompany); $settlements = $this->repository->settlements($tenantId, $serviceCompany, $period); $storeCompanyIncome = $this->repository->storeCompanyIncome($tenantId, $period); $invoiceCompletion = $this->completionFilter($request, 'invoice_completion'); $paymentCompletion = $this->completionFilter($request, 'payment_completion'); $contractAttachments = $this->repository->contractAttachmentsForRelations( $tenantId, $serviceCompany, array_map(static fn (array $relation): int => (int) $relation['id'], $allRelations), ); $relations = array_values(array_filter( $this->relationsWithSettlementState( $allRelations, $settlements, $contractAttachments, $storeCompanyIncome, ), static function (array $relation) use ($invoiceCompletion, $paymentCompletion): bool { if ($invoiceCompletion === 'unfinished' && $paymentCompletion === 'unfinished') { return $relation['invoice_completion'] === 'unfinished' || $relation['payment_completion'] === 'unfinished'; } return $relation['invoice_completion'] === $invoiceCompletion && $relation['payment_completion'] === $paymentCompletion; }, )); $assignedPeople = $this->repository->assignedPeople($tenantId, $serviceCompany); $availablePeople = array_values($this->repository->availablePeople($tenantId)); $periodStart = CarbonImmutable::createFromFormat('!Y-m', $period)->startOfMonth(); $periodEnd = $periodStart->endOfMonth(); $currentPersonKeys = array_column( array_filter($assignedPeople, static fn (array $row): bool => $row['is_current']), 'person_key', ); return view('business.pages.financial-operations.service-company-reconciliation', [ 'isDetail' => true, 'period' => $period, 'periodOptions' => $this->periodOptions(), 'serviceCompanies' => [], 'serviceCompany' => $company, 'relations' => $relations, 'relationTotal' => count($allRelations), 'invoiceCompletion' => $invoiceCompletion, 'paymentCompletion' => $paymentCompletion, 'settlements' => $settlements, 'assignedPeople' => $assignedPeople, 'availablePeople' => $availablePeople, 'currentPersonKeys' => array_flip($currentPersonKeys), 'storeCompanies' => $this->repository->storeCompanies($tenantId), 'serviceTypes' => ServiceCompanyReconciliationRepository::SERVICE_TYPES, 'fixedFeeRatePercent' => ServiceCompanyReconciliationRepository::fixedFeeRatePercent( (string) $company['service_type_code'], ), 'companyDirectory' => $this->repository->companyDirectory(), 'companyPlanSyncError' => $companyPlanSyncError, 'canEdit' => FinanceLedgerAccess::canEditMapping(user()), 'summary' => [ 'people' => count($currentPersonKeys), 'labor_cost' => array_sum(array_column( array_filter($assignedPeople, static fn (array $row): bool => $row['is_current']), 'salary', )), 'stores' => count(array_filter( $allRelations, static fn (array $row): bool => $row['status'] === 'active' && $row['effective_from'] <= $periodEnd->toDateString() && ($row['effective_to'] === null || $row['effective_to'] >= $periodStart->toDateString()), )), 'settlement_amount' => array_sum(array_column($settlements, 'service_fee')), ], ]); } /** * @param list> $relations * @param list> $settlements * @param array>> $contractAttachments * @param array $storeCompanyIncome * @return list> */ private function relationsWithSettlementState( array $relations, array $settlements, array $contractAttachments, array $storeCompanyIncome, ): array { $settlementsByRelation = []; foreach ($settlements as $settlement) { $key = (string) $settlement['store_company_key'] . '|' . (string) $settlement['service_type_code']; $settlementsByRelation[$key] = $settlement; } return array_map(static function (array $relation) use ( $settlementsByRelation, $contractAttachments, $storeCompanyIncome, ): array { $key = (string) $relation['store_company_key'] . '|' . (string) $relation['service_type_code']; $settlement = $settlementsByRelation[$key] ?? null; $incomeAmount = (float) ($storeCompanyIncome[(string) $relation['store_company_key']] ?? 0); $calculatedSettlementAmount = round($incomeAmount * (float) $relation['fee_rate'], 2); $invoiceCompleted = $settlement !== null && in_array((string) $settlement['invoice_status'], ['invoiced', 'waived'], true); $paymentCompleted = $settlement !== null && (string) $settlement['payment_status'] === 'paid'; return [ ...$relation, 'period_settlement_id' => $settlement['id'] ?? null, 'income_amount' => $incomeAmount, 'settlement_amount' => $settlement !== null ? (float) $settlement['service_fee'] : $calculatedSettlementAmount, 'invoice_status' => $settlement['invoice_status'] ?? null, 'payment_status' => $settlement['payment_status'] ?? null, 'invoice_completion' => $invoiceCompleted ? 'completed' : 'unfinished', 'payment_completion' => $paymentCompleted ? 'completed' : 'unfinished', 'contract_attachments' => $contractAttachments[(int) $relation['id']] ?? [], ]; }, $relations); } private function completionFilter(Request $request, string $key): string { $value = (string) $request->query($key, 'unfinished'); return in_array($value, ['completed', 'unfinished'], true) ? $value : 'unfinished'; } public function storeCompany(Request $request): RedirectResponse { $this->authorizeEdit(); $validated = $this->validateCompany($request); $company = $this->repository->saveServiceCompany( (int) (company_id() ?: 1), $validated, (string) user()->name, ); return redirect() ->route('business.service-company-reconciliation.show', [ 'serviceCompany' => $company['id'], ]) ->with('success', '服务公司已登记。'); } public function updateCompany(Request $request, int $serviceCompany): RedirectResponse { $this->authorizeEdit(); $validated = $this->validateCompany($request); $this->repository->saveServiceCompany( (int) (company_id() ?: 1), $validated, (string) user()->name, $serviceCompany, ); return back()->with('success', '服务公司信息已更新。'); } public function storeRelation(Request $request, int $serviceCompany): RedirectResponse { $this->authorizeEdit(); $validated = $this->validateRelation($request); $this->repository->saveRelation( (int) (company_id() ?: 1), $serviceCompany, $validated, (string) user()->name, ); return back()->with('success', '店铺公司结算关系已保存。'); } public function updateRelation( Request $request, int $serviceCompany, int $relation, ): RedirectResponse { $this->authorizeEdit(); $validated = $this->validateRelation($request); $this->repository->saveRelation( (int) (company_id() ?: 1), $serviceCompany, $validated, (string) user()->name, $relation, ); return back()->with('success', '店铺公司结算关系已更新并生成新版本。'); } public function storeRelationAttachment( Request $request, int $serviceCompany, int $relation, ): RedirectResponse { $this->authorizeEdit(); $validated = $request->validate([ 'attachment' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png,webp,doc,docx', 'max:20480'], 'period' => ['required', 'date_format:Y-m'], 'invoice_completion' => ['nullable', 'in:completed,unfinished'], 'payment_completion' => ['nullable', 'in:completed,unfinished'], ]); $tenantId = (int) (company_id() ?: 1); $file = $validated['attachment']; $originalName = mb_substr(basename((string) $file->getClientOriginalName()), 0, 255); $extension = strtolower((string) $file->getClientOriginalExtension()); $disk = Storage::disk('uploads'); $directory = sprintf( 'service-company-contracts/%d/%d/%d', $tenantId, $serviceCompany, $relation, ); $filename = Str::uuid()->toString() . ($extension !== '' ? '.' . $extension : ''); $path = $disk->putFileAs($directory, $file, $filename); if (! is_string($path) || $path === '') { throw new \RuntimeException('合同附件保存失败,请稍后重试。'); } try { $this->repository->saveContractAttachment( $tenantId, $serviceCompany, $relation, [ 'original_name' => $originalName, 'path' => $path, 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size' => (int) $file->getSize(), ], (string) user()->name, ); } catch (Throwable $exception) { $disk->delete($path); throw $exception; } return redirect() ->route('business.service-company-reconciliation.show', [ 'serviceCompany' => $serviceCompany, 'period' => $validated['period'], 'tab' => 'relations', 'invoice_completion' => $validated['invoice_completion'] ?? 'unfinished', 'payment_completion' => $validated['payment_completion'] ?? 'unfinished', ]) ->with('success', '合同附件已上传。'); } public function relationAttachment( int $serviceCompany, int $relation, int $attachment, ): StreamedResponse { $tenantId = (int) (company_id() ?: 1); $file = $this->repository->contractAttachment( $tenantId, $serviceCompany, $relation, $attachment, ); abort_if($file === null, 404); $disk = Storage::disk('uploads'); abort_unless($disk->exists((string) $file['path']), 404, '合同附件不存在。'); return $disk->response((string) $file['path'], (string) $file['original_name'], [ 'Cache-Control' => 'private, no-store', 'Content-Type' => (string) ($file['mime_type'] ?: 'application/octet-stream'), 'X-Content-Type-Options' => 'nosniff', ]); } public function assignPerson(Request $request, int $serviceCompany): RedirectResponse { $this->authorizeEdit(); $validated = $request->validate([ 'person_key' => ['required', 'string', 'max:191'], 'effective_from' => ['required', 'date_format:Y-m-d'], ]); $this->repository->assignPerson( (int) (company_id() ?: 1), $serviceCompany, $validated['person_key'], $validated['effective_from'], (string) user()->name, ); return back()->with('success', '人员归属已保存。'); } public function endPerson(Request $request, int $serviceCompany, int $assignment): RedirectResponse { $this->authorizeEdit(); $validated = $request->validate([ 'effective_to' => ['required', 'date_format:Y-m-d'], ]); $this->repository->endPersonAssignment( (int) (company_id() ?: 1), $serviceCompany, $assignment, $validated['effective_to'], ); return back()->with('success', '人员归属已结束。'); } public function generateSettlements(Request $request, int $serviceCompany): RedirectResponse { $this->authorizeEdit(); $validated = $request->validate([ 'period' => ['required', 'date_format:Y-m'], ]); $result = $this->repository->generateSettlements( (int) (company_id() ?: 1), $serviceCompany, $validated['period'], (string) user()->name, ); $message = "已生成 {$result['generated']} 条结算预览。"; if ($result['errors'] !== []) { $message .= ' 未生成:' . implode(';', array_slice($result['errors'], 0, 5)); } return redirect() ->route('business.service-company-reconciliation.show', [ 'serviceCompany' => $serviceCompany, 'period' => $validated['period'], 'tab' => 'relations', ]) ->with($result['generated'] > 0 ? 'success' : 'error', $message); } public function updateSettlement( Request $request, int $serviceCompany, int $settlement, ): RedirectResponse { $this->authorizeEdit(); $validated = $request->validate([ 'action' => ['nullable', 'in:confirm,save'], 'invoice_status' => ['nullable', 'in:pending,partial,invoiced,waived'], 'invoice_amount' => ['nullable', 'numeric', 'min:0'], 'payment_status' => ['nullable', 'in:pending,partial,paid'], 'paid_amount' => ['nullable', 'numeric', 'min:0'], 'notes' => ['nullable', 'string', 'max:1000'], ]); $this->repository->updateSettlement( (int) (company_id() ?: 1), $serviceCompany, $settlement, $validated, (string) user()->name, ); return back()->with('success', '开票与到账状态已更新。'); } private function period(Request $request): string { $period = trim((string) $request->query('period', '')); if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1) { return $period; } return CarbonImmutable::now()->subMonthNoOverflow()->format('Y-m'); } /** @return list */ private function periodOptions(): array { $current = CarbonImmutable::now()->startOfMonth(); return collect(range(0, 11))->map(static function (int $monthsAgo) use ($current): array { $month = $current->subMonthsNoOverflow($monthsAgo); return [ 'value' => $month->format('Y-m'), 'label' => $month->format('Y年n月'), ]; })->all(); } private function authorizeEdit(): void { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '当前账号没有维护服务公司对账的权限。'); } private function syncCompanyPlan(int $tenantId): ?string { try { $rows = $this->companyPlanRepository->serviceCompanyRows($tenantId); $this->repository->syncCompanyPlanServiceCompanies($tenantId, $rows); return null; } catch (\Throwable $exception) { report($exception); return '公司盘点服务公司同步暂时失败,当前先显示已登记的服务公司。'; } } /** @return array */ private function validateCompany(Request $request): array { return $request->validate([ 'company_name' => ['required', 'string', 'max:200'], 'unified_social_credit_code' => ['nullable', 'string', 'max:64'], 'service_type_code' => ['required', 'in:' . implode(',', array_keys( ServiceCompanyReconciliationRepository::SERVICE_TYPES, ))], 'taxpayer_type' => ['nullable', 'string', 'max:64'], 'business_registration_type' => ['nullable', 'string', 'max:64'], 'company_status' => ['nullable', 'in:正常,注销中,已注销'], 'planning_target_amount' => ['nullable', 'numeric', 'min:0'], 'status' => ['required', 'in:active,inactive'], 'notes' => ['nullable', 'string', 'max:500'], ]); } /** @return array */ private function validateRelation(Request $request): array { return $request->validate([ 'store_company_key' => ['required', 'string', 'max:191'], 'base_metric' => ['required', 'in:' . implode(',', array_keys( ServiceCompanyReconciliationRepository::BASE_METRICS, ))], 'fee_rate_percent' => ['required', 'numeric', 'min:0', 'max:100'], 'effective_from' => ['required', 'date_format:Y-m-d'], 'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'], 'contract_no' => ['nullable', 'string', 'max:64'], 'contract_status' => ['required', 'in:pending,stamped,waived'], 'status' => ['required', 'in:active,inactive'], 'notes' => ['nullable', 'string', 'max:500'], ]); } }