1337 lines
58 KiB
PHP
1337 lines
58 KiB
PHP
<?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' => '业绩明细收入(不减退款)',
|
||
];
|
||
|
||
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<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');
|
||
$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<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% 之间。']);
|
||
}
|
||
$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<array{service_company_name:string,store_company_name:string,fee_rate:float,source_row?:int}> $rows
|
||
* @return array{created:int,updated:int,unchanged:int,inactive:int,errors:list<string>}
|
||
*/
|
||
public function syncCustomerServiceMappings(
|
||
int $tenantId,
|
||
array $rows,
|
||
string $effectiveFrom,
|
||
string $actor,
|
||
): array {
|
||
$result = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'inactive' => 0, 'errors' => []];
|
||
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/', $effectiveFrom) !== 1) {
|
||
$result['errors'][] = '生效日期格式必须是 YYYY-MM-DD。';
|
||
|
||
return $result;
|
||
}
|
||
|
||
$serviceCompanies = $this->connection->table('svc_service_companies')
|
||
->where('company_id', $tenantId)
|
||
->where('service_type_code', 'customer_service')
|
||
->where('status', 'active')
|
||
->get();
|
||
$servicesByName = [];
|
||
foreach ($serviceCompanies as $serviceCompany) {
|
||
$nameKey = $this->mappingCompanyNameKey((string) $serviceCompany->company_name);
|
||
if (isset($servicesByName[$nameKey])) {
|
||
$result['errors'][] = '客服公司名称重复:' . $serviceCompany->company_name;
|
||
continue;
|
||
}
|
||
$servicesByName[$nameKey] = $serviceCompany;
|
||
}
|
||
|
||
$directoryByName = [];
|
||
foreach ($this->companyDirectory() as $directoryCompany) {
|
||
$directoryByName[$this->mappingCompanyNameKey((string) $directoryCompany['company_name'])] = $directoryCompany;
|
||
}
|
||
$storesByName = [];
|
||
foreach ($this->storeCompanies($tenantId) as $storeCompany) {
|
||
$storesByName[$this->mappingCompanyNameKey((string) $storeCompany['name'])] = $storeCompany;
|
||
}
|
||
|
||
$prepared = [];
|
||
$seen = [];
|
||
foreach ($rows as $index => $row) {
|
||
$sourceRow = (int) ($row['source_row'] ?? ($index + 2));
|
||
$serviceName = trim((string) ($row['service_company_name'] ?? ''));
|
||
$storeName = trim((string) ($row['store_company_name'] ?? ''));
|
||
$feeRate = round((float) ($row['fee_rate'] ?? 0), 6);
|
||
if ($serviceName === '' && $storeName === '') {
|
||
continue;
|
||
}
|
||
if ($serviceName === '') {
|
||
continue;
|
||
}
|
||
if ($storeName === '') {
|
||
$result['errors'][] = "第 {$sourceRow} 行缺少店铺公司名称。";
|
||
continue;
|
||
}
|
||
if ($feeRate <= 0 || $feeRate > 1) {
|
||
$result['errors'][] = "第 {$sourceRow} 行结算比例无效。";
|
||
continue;
|
||
}
|
||
|
||
$serviceCompany = $servicesByName[$this->mappingCompanyNameKey($serviceName)] ?? null;
|
||
if (! $serviceCompany) {
|
||
$result['errors'][] = "第 {$sourceRow} 行客服公司不存在或未启用:{$serviceName}";
|
||
continue;
|
||
}
|
||
$nameKey = $this->mappingCompanyNameKey($storeName);
|
||
$storeCompany = $storesByName[$nameKey] ?? null;
|
||
$directoryCompany = $directoryByName[$nameKey] ?? null;
|
||
if (! $storeCompany && ! $directoryCompany) {
|
||
$result['errors'][] = "第 {$sourceRow} 行店铺公司不在公司基础信息中:{$storeName}";
|
||
continue;
|
||
}
|
||
|
||
$canonicalStoreName = trim((string) (
|
||
$storeCompany['name']
|
||
?? $directoryCompany['company_name']
|
||
));
|
||
$creditCode = strtoupper(trim((string) (
|
||
$storeCompany['unified_social_credit_code']
|
||
?? $directoryCompany['unified_social_credit_code']
|
||
?? ''
|
||
)));
|
||
$storeCompanyKey = self::companyKey($canonicalStoreName, $creditCode);
|
||
$uniqueKey = (int) $serviceCompany->id . '|' . $storeCompanyKey . '|' . $effectiveFrom;
|
||
if (isset($seen[$uniqueKey])) {
|
||
$result['errors'][] = "第 {$sourceRow} 行店铺公司重复:{$canonicalStoreName}";
|
||
continue;
|
||
}
|
||
$seen[$uniqueKey] = true;
|
||
$prepared[] = [
|
||
'service_company_id' => (int) $serviceCompany->id,
|
||
'service_company_key' => (string) $serviceCompany->company_key,
|
||
'service_company_name' => (string) $serviceCompany->company_name,
|
||
'store_company_key' => $storeCompanyKey,
|
||
'store_company_name' => $canonicalStoreName,
|
||
'store_company_credit_code' => $creditCode !== '' ? $creditCode : null,
|
||
'fee_rate' => $feeRate,
|
||
'effective_from' => $effectiveFrom,
|
||
];
|
||
}
|
||
|
||
if ($result['errors'] !== []) {
|
||
return $result;
|
||
}
|
||
|
||
$syncedAt = now();
|
||
$this->connection->transaction(function () use (
|
||
$tenantId,
|
||
$prepared,
|
||
$effectiveFrom,
|
||
$actor,
|
||
$syncedAt,
|
||
$seen,
|
||
&$result,
|
||
): void {
|
||
foreach ($prepared as $row) {
|
||
$payload = [
|
||
'service_company_key' => $row['service_company_key'],
|
||
'service_company_name' => $row['service_company_name'],
|
||
'store_company_key' => $row['store_company_key'],
|
||
'store_company_name' => $row['store_company_name'],
|
||
'store_company_credit_code' => $row['store_company_credit_code'],
|
||
'service_type_code' => 'customer_service',
|
||
'base_metric' => 'settlement_income',
|
||
'fee_rate' => $row['fee_rate'],
|
||
'effective_from' => $row['effective_from'],
|
||
'effective_to' => null,
|
||
'contract_no' => null,
|
||
'contract_status' => 'pending',
|
||
'status' => 'active',
|
||
'source' => 'customer_service_mapping',
|
||
'source_synced_at' => $syncedAt,
|
||
'notes' => '来源:客服公司 Excel 店铺映射',
|
||
'updated_by' => $actor,
|
||
'updated_at' => $syncedAt,
|
||
];
|
||
$existing = $this->connection->table('svc_service_relations')
|
||
->where('company_id', $tenantId)
|
||
->where('service_company_id', $row['service_company_id'])
|
||
->where('store_company_key', $row['store_company_key'])
|
||
->where('service_type_code', 'customer_service')
|
||
->where('effective_from', $effectiveFrom)
|
||
->first();
|
||
|
||
if (! $existing) {
|
||
$this->connection->table('svc_service_relations')->insert([
|
||
...$payload,
|
||
'company_id' => $tenantId,
|
||
'service_company_id' => $row['service_company_id'],
|
||
'rule_version' => 1,
|
||
'created_by' => $actor,
|
||
'created_at' => $syncedAt,
|
||
]);
|
||
$result['created']++;
|
||
continue;
|
||
}
|
||
|
||
$old = (array) $existing;
|
||
$changed = (string) $existing->service_company_key !== $row['service_company_key']
|
||
|| (string) $existing->service_company_name !== $row['service_company_name']
|
||
|| (string) $existing->store_company_name !== $row['store_company_name']
|
||
|| (string) ($existing->store_company_credit_code ?? '') !== (string) ($row['store_company_credit_code'] ?? '')
|
||
|| abs((float) $existing->fee_rate - $row['fee_rate']) > 0.000001
|
||
|| (string) $existing->status !== 'active';
|
||
if (! $changed) {
|
||
$this->connection->table('svc_service_relations')
|
||
->where('id', $existing->id)
|
||
->update([
|
||
'source' => 'customer_service_mapping',
|
||
'source_synced_at' => $syncedAt,
|
||
'updated_by' => $actor,
|
||
'updated_at' => $syncedAt,
|
||
]);
|
||
$result['unchanged']++;
|
||
continue;
|
||
}
|
||
|
||
$payload['rule_version'] = (int) $existing->rule_version + 1;
|
||
$this->recordRelationVersion($tenantId, (int) $existing->id, $old, $actor);
|
||
$this->connection->table('svc_service_relations')
|
||
->where('id', $existing->id)
|
||
->update($payload);
|
||
$updated = (array) $this->connection->table('svc_service_relations')->find($existing->id);
|
||
$this->recordRelationVersion($tenantId, (int) $existing->id, $updated, $actor);
|
||
$result['updated']++;
|
||
}
|
||
|
||
$imported = $this->connection->table('svc_service_relations')
|
||
->where('company_id', $tenantId)
|
||
->where('source', 'customer_service_mapping')
|
||
->where('status', 'active')
|
||
->get();
|
||
foreach ($imported as $relation) {
|
||
$key = (int) $relation->service_company_id
|
||
. '|'
|
||
. (string) $relation->store_company_key
|
||
. '|'
|
||
. (string) $relation->effective_from;
|
||
if (isset($seen[$key])) {
|
||
continue;
|
||
}
|
||
$this->connection->table('svc_service_relations')
|
||
->where('id', $relation->id)
|
||
->update([
|
||
'status' => 'inactive',
|
||
'source_synced_at' => $syncedAt,
|
||
'updated_by' => $actor,
|
||
'updated_at' => $syncedAt,
|
||
]);
|
||
$result['inactive']++;
|
||
}
|
||
});
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function assignPerson(
|
||
int $tenantId,
|
||
int $serviceCompanyId,
|
||
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,
|
||
);
|
||
$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<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);
|
||
}
|
||
|
||
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';
|
||
}
|
||
}
|