519 lines
21 KiB
PHP
519 lines
21 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Business;
|
||
|
||
use App\Business\CompanyPlan\CompanyPlanRepository;
|
||
use App\Business\ServiceCompanyReconciliation\ServiceCompanyReconciliationRepository;
|
||
use App\Utilities\FinanceLedgerAccess;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Routing\Controller;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\View\View;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
use Throwable;
|
||
|
||
class ServiceCompanyReconciliation extends Controller
|
||
{
|
||
public function __construct(
|
||
private readonly ServiceCompanyReconciliationRepository $repository,
|
||
private readonly CompanyPlanRepository $companyPlanRepository,
|
||
) {
|
||
$this->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<array<string,mixed>> $relations
|
||
* @param list<array<string,mixed>> $settlements
|
||
* @param array<int,list<array<string,mixed>>> $contractAttachments
|
||
* @param array<string,float> $storeCompanyIncome
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<array{value:string,label:string}> */
|
||
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<string,mixed> */
|
||
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<string,mixed> */
|
||
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'],
|
||
]);
|
||
}
|
||
}
|