CaiWuXiTong/app/Business/ServiceCompanyReconciliation/ServiceCompanyReconciliationRepository.php

1057 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business\ServiceCompanyReconciliation;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use RuntimeException;
class ServiceCompanyReconciliationRepository
{
public const SERVICE_TYPES = [
'customer_service' => '客服服务公司',
'operations' => '运营服务公司',
'warehouse' => '仓储服务公司',
'other' => '其他服务公司',
];
public const BASE_METRICS = [
'settlement_income' => '业绩明细收入(不减退款)',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/** @return list<array<string,mixed>> */
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');
$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_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<string,mixed>|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<array<string,mixed>> $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<array<string,mixed>> */
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<array<string,mixed>> */
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<array<string,mixed>> */
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<string,array<string,mixed>> */
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<array<string,mixed>> */
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<array<string,mixed>> */
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<string,mixed> $data
* @return array<string,mixed>
*/
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<string,mixed> $data
* @return array<string,mixed>
*/
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% 之间。']);
}
$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',
'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;
});
}
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<string>,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,
);
$sourceGeneration = mb_substr(
'paid:' . $paidGeneration . '|life:' . $lifecycleGeneration,
0,
191,
);
return $this->connection->transaction(function () use (
$tenantId,
$serviceCompanyId,
$period,
$periodStart,
$periodEnd,
$relations,
$baseRows,
$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;
}
$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<string,mixed> $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<string,list<array<string,mixed>>> */
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<string,mixed> $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);
}
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';
}
}