CaiWuXiTong/modules/EcommerceFinance/Services/StoreReportPeriodBuilder.php
WuYongJie 78857a51d9 feat: 店铺维度报表按主体期间拆分并支持期初数据维护
- 改为按期初数据+系统主体+爬虫新主体生成主体期间,一行一期间,修复流程历史重复行
- 变更日当天归新主体、老主体失效显示变更日前一天;关店和其他非正常状态两列显示 -
- 期初数据首次打开固化为系统库设置,可在「规则信息 → 店铺维度报表逻辑配置」在线增删改
- 爬虫店铺主体按税号归一,首次抓到的新主体写入期间变更表并自动生成新期间
- 新增期间变更表迁移与 crawl_shop_company 镜像数据集
2026-10-06 18:42:12 +08:00

259 lines
9.6 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 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');
}
}