提现管理主体跟随店铺维度报表最新存续期间。 PR #559 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
295 lines
11 KiB
PHP
295 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\DatabaseManager;
|
||
use Illuminate\Support\Carbon;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 生成店铺维度报表的店铺主体期间。
|
||
*
|
||
* 期间来源按优先级:
|
||
* 1. 期初数据:负责人提供的店铺变更初始数据,老主体自 2026-07-01 起、
|
||
* 失效时间为变更日前一天;新主体从变更日当天生效。
|
||
* 2. 财务系统主体:未列入期初数据的店铺自 2026-07-01 起按系统登记主体生成一条期间。
|
||
* 3. 爬虫新主体:store_report_shop_period_changes 记录爬虫首次抓到的新主体,
|
||
* 以抓到日期作为新期间生效日,老期间失效时间改为前一天。
|
||
*
|
||
* 同一店铺的期间严格按日期衔接,不重复也不留空档,报表一行代表一个主体期间。
|
||
*/
|
||
class StoreReportPeriodBuilder
|
||
{
|
||
public const INITIAL_EFFECTIVE_AT = '2026-07-01';
|
||
|
||
public const CHANGES_TABLE = 'store_report_shop_period_changes';
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(DatabaseManager $databases)
|
||
{
|
||
$this->connection = $databases->connection(
|
||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||
);
|
||
}
|
||
|
||
public static function initialEffectiveAt(): string
|
||
{
|
||
return StoreReportInitialPeriods::effectiveAt();
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,mixed>> $stores 报表店铺行;只需要 erp_store_name,company_name 作为系统主体缺失时的回退值。
|
||
* @return array<string,list<array{company_name:?string,effective_at:string,expires_at:?string,source:string}>>
|
||
*/
|
||
public function build(array $stores): array
|
||
{
|
||
$names = [];
|
||
$fallbackCompanies = [];
|
||
$knownEntities = [];
|
||
foreach ($stores as $store) {
|
||
$name = trim((string) ($store['erp_store_name'] ?? ''));
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
$names[$name] = $name;
|
||
$company = trim((string) ($store['company_name'] ?? ''));
|
||
if ($company !== '') {
|
||
$fallbackCompanies[$name] = $company;
|
||
}
|
||
// 店铺行已带系统主体时不再重复查询镜像;null 表示系统确实没有登记主体。
|
||
if (array_key_exists('system_company_name', $store)) {
|
||
$knownEntities[$name] = $this->cleanCompany($store['system_company_name']);
|
||
}
|
||
}
|
||
|
||
return $this->assemble(array_values($names), $fallbackCompanies, $knownEntities);
|
||
}
|
||
|
||
/**
|
||
* 店铺维度报表当前仍存续的主体:失效时间为空,且生效时间最新。
|
||
*
|
||
* @param array<int,array<string,mixed>> $stores
|
||
* @return array<string,string>
|
||
*/
|
||
public function currentCompanies(array $stores): array
|
||
{
|
||
$result = [];
|
||
|
||
foreach ($this->build($stores) as $storeName => $periods) {
|
||
$active = null;
|
||
foreach ($periods as $period) {
|
||
if (($period['expires_at'] ?? null) !== null) {
|
||
continue;
|
||
}
|
||
$company = $this->cleanCompany($period['company_name'] ?? null);
|
||
if ($company === null) {
|
||
continue;
|
||
}
|
||
$effectiveAt = $this->date($period['effective_at'] ?? null) ?? '';
|
||
if ($active === null || strcmp($effectiveAt, $active['effective_at']) > 0) {
|
||
$active = [
|
||
'company_name' => $company,
|
||
'effective_at' => $effectiveAt,
|
||
];
|
||
}
|
||
}
|
||
if ($active !== null) {
|
||
$result[$storeName] = $active['company_name'];
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $names
|
||
* @param array<string,string> $fallbackCompanies
|
||
* @param array<string,?string> $knownEntities
|
||
* @return array<string,list<array{company_name:?string,effective_at:string,expires_at:?string,source:string}>>
|
||
*/
|
||
private function assemble(array $names, array $fallbackCompanies, array $knownEntities = []): array
|
||
{
|
||
if ($names === []) {
|
||
return [];
|
||
}
|
||
|
||
$initial = StoreReportInitialPeriods::shops();
|
||
$initialDate = self::initialEffectiveAt();
|
||
$missing = array_values(array_diff($names, array_keys($knownEntities)));
|
||
$entities = $knownEntities + $this->systemEntities($missing);
|
||
$changes = $this->crawledChanges($names);
|
||
$periods = [];
|
||
|
||
foreach ($names as $name) {
|
||
$rule = $initial[$name] ?? null;
|
||
$changedAt = is_array($rule) ? $this->date($rule['changed_at'] ?? null) : null;
|
||
// 变更日必须晚于期初日,否则老主体期间为空,直接按新主体从期初日起算。
|
||
if (is_array($rule) && $changedAt !== null && $changedAt > $initialDate) {
|
||
$list = [
|
||
[
|
||
'company_name' => $this->cleanCompany($rule['previous_company'] ?? null),
|
||
'effective_at' => $initialDate,
|
||
'expires_at' => $this->dayBefore($changedAt),
|
||
'source' => 'initial',
|
||
],
|
||
[
|
||
'company_name' => $this->cleanCompany($rule['company'] ?? null),
|
||
'effective_at' => $changedAt,
|
||
'expires_at' => null,
|
||
'source' => 'initial',
|
||
],
|
||
];
|
||
} else {
|
||
$list = [[
|
||
'company_name' => (is_array($rule) ? $this->cleanCompany($rule['company'] ?? null) : null)
|
||
?? ($entities[$name] ?? ($fallbackCompanies[$name] ?? null)),
|
||
'effective_at' => $initialDate,
|
||
'expires_at' => null,
|
||
'source' => 'baseline',
|
||
]];
|
||
}
|
||
|
||
foreach ($changes[$name] ?? [] as $change) {
|
||
$company = $this->cleanCompany($change['company_name'] ?? null);
|
||
$observed = $this->date($change['observed_on'] ?? null);
|
||
if ($company === null || $observed === null || $observed < $initialDate) {
|
||
continue;
|
||
}
|
||
|
||
$last = &$list[count($list) - 1];
|
||
if ($this->sameCompany($last['company_name'], $company)) {
|
||
continue;
|
||
}
|
||
// 变更必须晚于当前期间生效日,避免爬虫记录早于期初数据时产生重叠。
|
||
if (strcmp($observed, (string) $last['effective_at']) <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$last['expires_at'] = $this->dayBefore($observed);
|
||
$list[] = [
|
||
'company_name' => $company,
|
||
'effective_at' => $observed,
|
||
'expires_at' => null,
|
||
'source' => 'crawl',
|
||
];
|
||
unset($last);
|
||
}
|
||
unset($last);
|
||
|
||
$periods[$name] = $list;
|
||
}
|
||
|
||
return $periods;
|
||
}
|
||
|
||
/**
|
||
* 财务系统登记的店铺主体(与 rpt.fys_shopInfo + fys_companyInfo 一致)。
|
||
*
|
||
* @param list<string> $names
|
||
* @return array<string,string>
|
||
*/
|
||
private function systemEntities(array $names): array
|
||
{
|
||
$entities = [];
|
||
foreach (array_chunk($names, 500) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
try {
|
||
$rows = $this->connection->select(
|
||
"SELECT s.erpShopName AS store_name, c.businessLicenseName AS company_name
|
||
FROM ops_fys_shop_info AS s
|
||
LEFT JOIN (
|
||
SELECT unifiedSocialCreditCode, MAX(Id) AS latest_id
|
||
FROM ops_fys_company_info
|
||
WHERE unifiedSocialCreditCode IS NOT NULL
|
||
GROUP BY unifiedSocialCreditCode
|
||
) AS latest ON latest.unifiedSocialCreditCode = s.unifiedSocialCreditCode
|
||
LEFT JOIN ops_fys_company_info AS c ON c.Id = latest.latest_id
|
||
WHERE s.erpShopName IN ({$placeholders})",
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
foreach ($rows as $row) {
|
||
$name = trim((string) ($row->store_name ?? ''));
|
||
$company = $this->cleanCompany($row->company_name ?? null);
|
||
if ($name !== '' && $company !== null) {
|
||
$entities[$name] = $company;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $entities;
|
||
}
|
||
|
||
/**
|
||
* 爬虫首次抓到的新主体记录,按抓到日期升序构成后续期间。
|
||
*
|
||
* @param list<string> $names
|
||
* @return array<string,list<array{company_name:?string,observed_on:?string}>>
|
||
*/
|
||
private function crawledChanges(array $names): array
|
||
{
|
||
$changes = [];
|
||
foreach (array_chunk($names, 500) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
try {
|
||
$rows = $this->connection->select(
|
||
"SELECT erp_store_name, company_name, observed_on
|
||
FROM " . self::CHANGES_TABLE . "
|
||
WHERE erp_store_name IN ({$placeholders})
|
||
ORDER BY observed_on ASC, id ASC",
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
foreach ($rows as $row) {
|
||
$name = trim((string) ($row->erp_store_name ?? ''));
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
$changes[$name][] = [
|
||
'company_name' => $row->company_name ?? null,
|
||
'observed_on' => $row->observed_on ?? null,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $changes;
|
||
}
|
||
|
||
private function cleanCompany(mixed $value): ?string
|
||
{
|
||
$company = trim((string) ($value ?? ''));
|
||
|
||
return $company === '' ? null : $company;
|
||
}
|
||
|
||
private function sameCompany(?string $left, ?string $right): bool
|
||
{
|
||
return mb_strtolower(trim((string) $left)) === mb_strtolower(trim((string) $right));
|
||
}
|
||
|
||
private function date(mixed $value): ?string
|
||
{
|
||
$value = trim((string) ($value ?? ''));
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return Carbon::parse($value)->format('Y-m-d');
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function dayBefore(string $date): string
|
||
{
|
||
return Carbon::parse($date)->subDay()->format('Y-m-d');
|
||
}
|
||
}
|