- 店铺主体商标数据逻辑配置新增编辑页:多商标分隔符、同日主体冲突口径、 商标号缺失处理、爬虫断更开关与天数、四类预警责任人 - 店铺维度报表逻辑配置新增期初数据编辑页:114 家变更店铺可增删改, 期初生效日随设置保存 - 规则与期初数据改存系统库 settings(扁平键),首次打开自动从 config/store_report_initial_periods.php 固化,之后不再依赖代码文件 - 控制台任务通过 bootCompanyContext() 读取同一份公司级配置 - 新增迁移清理早期嵌套设置键 - 新增 2 个测试覆盖配置保存与期初店铺增删
268 lines
11 KiB
PHP
268 lines
11 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Modules\EcommerceFinance\Repositories\DataWarningMirrorRepository;
|
||
use Modules\EcommerceFinance\Services\CrawlerDataGuard;
|
||
use Modules\EcommerceFinance\Services\DataLogicSettings;
|
||
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
|
||
/**
|
||
* 将爬虫抓到的店铺主体写入期间变更表。
|
||
*
|
||
* 逻辑对应「店铺主体商标数据逻辑配置」页面的 R5、R6:
|
||
* - 只在主体真的发生变化时新增一条变更记录,生效日期取 crawl_date;
|
||
* - 同一天流程表也记录了同店铺变更但主体不同时,以爬虫为准,同时生成
|
||
* 待人工复审的预警。
|
||
*/
|
||
class SyncCrawlerShopSubjects extends Command
|
||
{
|
||
protected $signature = 'store-report:sync-crawler-subjects
|
||
{--dry-run : 只输出将要记录的变更,不写库}';
|
||
|
||
protected $description = '按爬虫抓取结果生成店铺主体期间变更,并登记同日冲突预警';
|
||
|
||
public function handle(DataWarningMirrorRepository $warnings, CrawlerDataGuard $guard): int
|
||
{
|
||
DataLogicSettings::bootCompanyContext();
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$schema = $connection->getSchemaBuilder();
|
||
|
||
if (! $schema->hasTable('ops_crawl_shop_company')) {
|
||
$this->error('爬虫店铺主体镜像尚未完成首次同步,本次不生成期间变更。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if (! $schema->hasTable('store_report_shop_period_changes')) {
|
||
$this->error('期间变更表尚未迁移,请先执行数据库迁移。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$official = collect($connection->table('ops_fys_company_info')
|
||
->select(['unifiedSocialCreditCode', 'businessLicenseName'])
|
||
->get())
|
||
->keyBy(fn (object $row): string => trim((string) $row->unifiedSocialCreditCode));
|
||
|
||
$existing = $connection->table('store_report_shop_period_changes')
|
||
->select(['erp_store_name', 'company_name', 'observed_on'])
|
||
->orderBy('observed_on')
|
||
->orderBy('id')
|
||
->get();
|
||
$latestChange = [];
|
||
$changesByStore = [];
|
||
foreach ($existing as $row) {
|
||
$name = trim((string) $row->erp_store_name);
|
||
$latestChange[$name] = (string) $row->company_name;
|
||
$changesByStore[$name][] = $row;
|
||
}
|
||
|
||
// 已知主体基线:优先用期间构建器的当前期间(期初数据 + 系统主体 + 历史爬虫变更),
|
||
// 保证只为“真的发生变化”的店铺记录新期间。
|
||
$baseline = $this->baselineCompanies($connection);
|
||
$flow = $this->flowCompanies($connection);
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
$created = 0;
|
||
$conflicts = 0;
|
||
$skipped = 0;
|
||
$newWarnings = [];
|
||
|
||
foreach ($this->latestCrawlRows($connection) as $row) {
|
||
$storeName = trim((string) ($row->shop_name ?? ''));
|
||
if ($storeName === '') {
|
||
continue;
|
||
}
|
||
|
||
$company = $this->resolveCompany($row, $official);
|
||
$observedOn = trim((string) ($row->crawl_date ?? ''));
|
||
if ($company === null || $observedOn === '') {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$known = $latestChange[$storeName] ?? ($baseline[$storeName] ?? null);
|
||
if ($known !== null && mb_strtolower(trim($known)) === mb_strtolower($company)) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
// 同一天流程表给出不同主体:按配置的冲突口径处理,默认以爬虫为准并留待人工复审。
|
||
$flowCompany = $flow[$storeName][$observedOn] ?? null;
|
||
if ($flowCompany !== null && mb_strtolower(trim($flowCompany)) !== mb_strtolower($company)) {
|
||
$conflicts++;
|
||
$newWarnings[] = [
|
||
'warning_key' => 'subject-conflict:' . md5($storeName . '|' . $observedOn . '|' . $company),
|
||
'warning_type' => CrawlerDataGuard::TYPE_SUBJECT_CONFLICT,
|
||
'risk_level' => 'high',
|
||
'related_object' => $storeName,
|
||
'risk_description' => sprintf('店铺「%s」%s 流程记录主体为「%s」,爬虫抓取为「%s」,系统已按爬虫调整期间。', $storeName, $observedOn, $flowCompany, $company),
|
||
'evidence' => sprintf('flow=%s,crawl=%s,crawl_date=%s', $flowCompany, $company, $observedOn),
|
||
'impact' => '主体期间与收入归属',
|
||
'trigger_rule' => 'R6',
|
||
'due_at' => now()->addDays(3),
|
||
];
|
||
if (! DataLogicSettings::crawlWinsOnConflict()) {
|
||
$this->warn(sprintf('%s:%s 与流程主体「%s」冲突,按配置保留流程主体并等待复审。', $storeName, $observedOn, $flowCompany));
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$businessKey = hash('sha256', $storeName . '|' . $company . '|' . $observedOn);
|
||
if (! $dryRun) {
|
||
$connection->table('store_report_shop_period_changes')->updateOrInsert(
|
||
['business_key' => $businessKey],
|
||
[
|
||
'erp_store_name' => $storeName,
|
||
'company_name' => $company,
|
||
'unified_social_credit_code' => trim((string) ($row->tax_no ?? '')) ?: null,
|
||
'observed_on' => $observedOn,
|
||
'source' => 'crawl_shop_company',
|
||
'updated_at' => now(),
|
||
'created_at' => now(),
|
||
],
|
||
);
|
||
}
|
||
|
||
$latestChange[$storeName] = $company;
|
||
$created++;
|
||
$this->line(sprintf('%s:%s 起主体变更为 %s%s', $storeName, $observedOn, $company, $dryRun ? '(演练)' : ''));
|
||
}
|
||
|
||
if (! $dryRun && $newWarnings !== []) {
|
||
foreach ($newWarnings as $warning) {
|
||
$warning['responsible_person'] = $guard->responsiblePersonFor((string) $warning['warning_type']);
|
||
$warnings->upsert($warning);
|
||
}
|
||
}
|
||
|
||
$this->info(sprintf(
|
||
'%s:新增主体变更 %d 条,同日冲突预警 %d 条,跳过 %d 家店铺。',
|
||
$dryRun ? '演练完成' : '同步完成',
|
||
$created,
|
||
$conflicts,
|
||
$skipped,
|
||
));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* 当前系统认可的主体:把期间构建器的当前期间(最后一条)作为比较基线。
|
||
*
|
||
* @return array<string,string>
|
||
*/
|
||
private function baselineCompanies($connection): array
|
||
{
|
||
if (! $connection->getSchemaBuilder()->hasTable('ops_fys_shop_info')) {
|
||
return [];
|
||
}
|
||
|
||
$stores = $connection->table('ops_fys_shop_info')
|
||
->select(['Id', 'erpShopName', 'unifiedSocialCreditCode'])
|
||
->get()
|
||
->map(static fn (object $row): array => (array) $row)
|
||
->all();
|
||
$companies = $connection->table('ops_fys_company_info')
|
||
->select(['Id', 'unifiedSocialCreditCode', 'businessLicenseName'])
|
||
->get()
|
||
->map(static fn (object $row): array => (array) $row)
|
||
->all();
|
||
$byCreditCode = [];
|
||
foreach ($companies as $company) {
|
||
$code = trim((string) ($company['unifiedSocialCreditCode'] ?? ''));
|
||
if ($code !== '') {
|
||
$byCreditCode[$code] = (string) $company['businessLicenseName'];
|
||
}
|
||
}
|
||
foreach ($stores as &$store) {
|
||
$code = trim((string) ($store['unifiedSocialCreditCode'] ?? ''));
|
||
$store = [
|
||
'erp_store_name' => (string) ($store['erpShopName'] ?? ''),
|
||
'company_name' => $byCreditCode[$code] ?? null,
|
||
'system_company_name' => $byCreditCode[$code] ?? null,
|
||
];
|
||
}
|
||
unset($store);
|
||
|
||
$periods = app(StoreReportPeriodBuilder::class)->build($stores);
|
||
$baseline = [];
|
||
foreach ($periods as $name => $list) {
|
||
$last = end($list);
|
||
$company = trim((string) ($last['company_name'] ?? ''));
|
||
if ($company !== '') {
|
||
$baseline[$name] = $company;
|
||
}
|
||
}
|
||
|
||
return $baseline;
|
||
}
|
||
|
||
/** @return list<object> */
|
||
private function latestCrawlRows($connection): array
|
||
{
|
||
return $connection->table('ops_crawl_shop_company')
|
||
->orderBy('shop_name')
|
||
->orderByDesc('crawl_date')
|
||
->orderByDesc('id')
|
||
->get()
|
||
->groupBy(static fn (object $row): string => (string) $row->shop_name)
|
||
->map(static fn ($rows): object => $rows->first())
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* 主体名称以 tax_no 对应的工商主体名为准,保证与主体报表合并键一致;
|
||
* 工商资料缺失时回退爬虫抓到的名称。
|
||
*/
|
||
private function resolveCompany(object $row, $official): ?string
|
||
{
|
||
$taxNo = trim((string) ($row->tax_no ?? ''));
|
||
if ($taxNo !== '' && $official->has($taxNo)) {
|
||
$name = trim((string) $official->get($taxNo)->businessLicenseName);
|
||
if ($name !== '') {
|
||
return $name;
|
||
}
|
||
}
|
||
|
||
$name = trim((string) ($row->company_name ?? ''));
|
||
|
||
return $name === '' ? null : $name;
|
||
}
|
||
|
||
/** @return array<string,array<string,string>> */
|
||
private function flowCompanies($connection): array
|
||
{
|
||
if (! $connection->getSchemaBuilder()->hasTable('store_report_lifecycle_rows')) {
|
||
return [];
|
||
}
|
||
|
||
$state = $connection->table('store_report_import_states')
|
||
->where('dataset_key', 'store-report.store-lifecycles')
|
||
->first();
|
||
$generation = trim((string) ($state->active_generation ?? ''));
|
||
if ($generation === '') {
|
||
return [];
|
||
}
|
||
|
||
$result = [];
|
||
foreach ($connection->table('store_report_lifecycle_rows')
|
||
->where('generation', $generation)
|
||
->select(['erp_store_name', 'company_name', 'effective_at'])
|
||
->get() as $row) {
|
||
$name = trim((string) $row->erp_store_name);
|
||
$date = substr(trim((string) $row->effective_at), 0, 10);
|
||
if ($name === '' || $date === '') {
|
||
continue;
|
||
}
|
||
$result[$name][$date] = (string) $row->company_name;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|