CaiWuXiTong/modules/EcommerceFinance/Services/StoreReportPeriodBuilder.php
92451 03acb8b1a0 fix: 提现管理按店铺报表取存续主体
提现管理主体跟随店铺维度报表最新存续期间。

PR #559
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-25 20:36:57 +08:00

295 lines
11 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 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');
}
}