Compare commits

...

12 Commits

Author SHA1 Message Date
2df9d046ac feat: 税金预测表页面标注逻辑配置入口 2026-10-05 11:17:30 +08:00
37e8892e8a feat: 新增税金预测表逻辑配置页面 2026-10-05 11:17:03 +08:00
3ac88cf43d feat: 新增税金预测表页面与取数口径说明 2026-10-05 11:13:22 +08:00
b8726c91f8 feat: 内部流转报表导入工作簿数据并支持查询导出
- 新增共享镜像表 business_internal_flow_records 与整份替换导入命令 internal-flow:import
- 按 Excel 表头展示抬头、标签、预计9月销量、开票、所需进项成本、收票、剩余缺口与开票比例
- 复用公司基础信息的字段查询、表头排序、筛选字段与导出 Excel 交互
- 新增导入与页面测试,覆盖合计行跳过、重导入替换、筛选排序与未导入空状态
2026-10-05 11:02:07 +08:00
967b62ca2f feat: 数据逻辑配置页面支持在线维护
- 店铺主体商标数据逻辑配置新增编辑页:多商标分隔符、同日主体冲突口径、
  商标号缺失处理、爬虫断更开关与天数、四类预警责任人
- 店铺维度报表逻辑配置新增期初数据编辑页:114 家变更店铺可增删改,
  期初生效日随设置保存
- 规则与期初数据改存系统库 settings(扁平键),首次打开自动从
  config/store_report_initial_periods.php 固化,之后不再依赖代码文件
- 控制台任务通过 bootCompanyContext() 读取同一份公司级配置
- 新增迁移清理早期嵌套设置键
- 新增 2 个测试覆盖配置保存与期初店铺增删
2026-10-03 21:52:43 +08:00
561c42d830 feat: 店铺主体期间与爬虫数据逻辑
- 店铺维度报表按期初数据+系统主体+爬虫新主体生成主体期间,一行一期间,修复流程历史重复行
- 变更日当天归新主体、老主体失效显示变更日前一天;关店和其他非正常状态两列显示 -
- 新增店铺维度报表逻辑配置页(R1-R8)、主体商标数据逻辑配置页和预警页面
- 爬虫店铺主体按税号归一,首次抓到的新主体写入期间变更表并自动生成新期间
- 店铺基础信息、商标展示按爬虫优先;新增爬虫数据质量检查与预警处理
2026-10-03 13:28:08 +08:00
fdf7f7ea85 fix: hide empty directory query form grid item 2026-09-29 11:25:45 +08:00
21a3393c7d feat: connect report query controls 2026-09-24 18:24:39 +08:00
e6a30137df feat: improve business directory filters and export 2026-09-24 00:00:26 +08:00
f0b22b5a24 feat: unify business table interactions 2026-09-09 14:35:07 +08:00
e33f22a24f fix: preserve lifecycle effective dates 2026-09-08 16:29:54 +08:00
f6f66b6ff1 feat: add internal flow report entry 2026-09-08 14:44:34 +08:00
56 changed files with 7152 additions and 283 deletions

View File

@ -88,10 +88,12 @@ class UpstreamBusinessInformation
private function companyPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('company-management', 'basic-information');
return $this->page(
'company-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('company-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -133,10 +135,12 @@ SQL);
private function storePage(int $page, int $perPage): array
{
$filters = $this->pageFilters('store-management', 'basic-information');
return $this->page(
'store-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('store-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -157,7 +161,11 @@ SQL);
private function storeAccountPage(int $page, int $perPage): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate($page, $perPage);
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate(
$page,
$perPage,
$this->pageFilters('store-management', 'store-accounts'),
);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。');
@ -189,7 +197,7 @@ SQL);
*/
private function storeMatchesFilters(array $row, array $filters): bool
{
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'company_type'] as $key) {
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected === '') {
continue;
@ -198,10 +206,14 @@ SQL);
$actual = $key === 'store_id'
? trim((string) ($row['erp_store_name'] ?? ''))
: trim((string) ($row[$key] ?? ''));
if ($actual !== $expected) {
if ($expected !== '' && ! str_contains(mb_strtolower($actual), mb_strtolower($expected))) {
return false;
}
}
$companyType = trim((string) ($filters['company_type'] ?? ''));
if ($companyType !== '' && trim((string) ($row['company_type'] ?? '')) !== $companyType) {
return false;
}
$statusMap = ['0' => '正常', '3' => '关闭中', '6' => '已关闭'];
$status = trim((string) ($filters['status'] ?? ''));
@ -247,7 +259,8 @@ SQL);
private function subjectOperatingReportPage(int $page, int $perPage): array
{
$rows = $this->storeReportRows();
$filters = $this->pageFilters('company-management', 'subject-operating-report');
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
$entities = [];
foreach ($rows as $row) {
@ -279,20 +292,64 @@ SQL);
}
$rows = array_values($entities);
usort($rows, static fn (array $left, array $right): int => strcmp($left['entity'], $right['entity']));
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($rows, $offset, $perPage);
$details = $this->companyDetails(array_column($pageRows, 'entity'));
foreach ($pageRows as &$row) {
$details = $this->companyDetails(array_column($rows, 'entity'));
foreach ($rows as &$row) {
$row['company_details'] = $details[$row['entity']] ?? [];
$row['city'] = $this->cityFromDetails($row['company_details']);
}
unset($row);
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
$this->sortSubjectRows($rows, $filters);
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($rows, $offset, $perPage);
return ['data' => $pageRows, 'total' => count($rows)];
}
/** @param array<string,mixed> $row @param array<string,string> $filters */
private function subjectMatchesFilters(array $row, array $filters): bool
{
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
return false;
}
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
$needle = trim((string) ($filters[$key] ?? ''));
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
return false;
}
}
$canChange = trim((string) ($filters['can_change_entity'] ?? ''));
return $canChange === '' || (string) ($row['can_change_entity'] ?? '') === $canChange;
}
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
private function sortSubjectRows(array &$rows, array $filters): void
{
$key = (string) ($filters['_sort'] ?? '');
$allowed = [
'city', 'entity', 'company_type', 'rolling_12m_income',
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
'can_change_entity', 'plan', 'new_entity',
];
if (! in_array($key, $allowed, true)) {
return;
}
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
$leftValue = $left[$key] ?? null;
$rightValue = $right[$key] ?? null;
if (is_numeric($leftValue) && is_numeric($rightValue)) {
return ((float) $leftValue <=> (float) $rightValue) * $direction;
}
return strnatcasecmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
});
}
private function sumNullable(array &$row, string $key, mixed $value): void
{
if ($value === null || $value === '') {
@ -471,10 +528,12 @@ SQL);
private function trademarkPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('trademark-management', 'information');
return $this->page(
'trademark-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('trademark-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -511,10 +570,14 @@ SQL);
];
}
private function page(string $scope, int $page, int $perPage, callable $resolver): array
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
{
if ($this->mirror->ready($scope)) {
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage));
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
}
if ($filters !== []) {
throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。');
}
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
@ -529,6 +592,34 @@ SQL);
});
}
/** @return array<string,string> */
private function pageFilters(string $section, string $page): array
{
$fields = collect(config('business_navigation'))
->firstWhere('slug', $section)['pages'] ?? [];
$pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page);
$allowed = collect($pageConfig['directory']['fields'] ?? [])
->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account')
->pluck('name')->all();
if ($section === 'company-management' && $page === 'basic-information') {
$allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people']));
}
$filters = [];
foreach ($allowed as $name) {
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
$value = trim((string) request($key, ''));
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
}
}
$sort = trim((string) request('sort', ''));
if (in_array($sort, $allowed, true)) {
$filters['_sort'] = $sort;
$filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc';
}
return $filters;
}
/** @param array{data: array<int,array<string,mixed>>, total: int} $payload */
private function normalize(string $scope, array $payload): array
{
@ -550,6 +641,7 @@ SQL);
$row = $this->toUtf8($row);
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
$row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null);
$row['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? null);
$row['company_status'] = match ((string) ($row['company_status'] ?? '')) {
'0' => '正常',
'1' => '已注销',

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\CrawlerDataGuard;
use Modules\EcommerceFinance\Services\DataLogicSettings;
use Throwable;
class CheckCrawlerWarnings extends Command
{
protected $signature = 'crawler:warnings:check {--quiet-summary : 只输出汇总}';
protected $description = '检查店铺主体、商标爬虫数据的更新和一致性,生成预警记录';
public function handle(CrawlerDataGuard $guard): int
{
DataLogicSettings::bootCompanyContext();
try {
$summary = $guard->check();
} catch (Throwable $exception) {
$this->error('预警检查失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($summary === []) {
$this->info('预警检查完成:没有发现需要处理的记录。');
return self::SUCCESS;
}
$this->table(
['预警类型', '本次检查命中'],
collect($summary)->map(fn (int $count, string $type): array => [$type, $count])->values()->all(),
);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
use Throwable;
class ImportInternalFlowReport extends Command
{
protected $signature = 'internal-flow:import
{path : 「一般纳税人内部流转」工作簿路径(.xlsx)}';
protected $description = '导入内部流转报表明细,整份替换共享业务镜像中的上一版数据';
public function handle(InternalFlowWorkbookImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'));
} catch (Throwable $exception) {
report($exception);
$this->error('内部流转报表导入失败:' . $exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'内部流转报表导入完成:明细 %s 行,跳过合计/空行 %s 行,来源文件 %s。',
number_format($result['imported']),
number_format($result['skipped']),
$result['source_file'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,267 @@
<?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;
}
}

View File

@ -38,9 +38,12 @@ class SyncStoreReportMirror extends Command
}
$this->info(sprintf(
'店铺报表镜像:生命周期 %s/%d 行,账单 %s/%d 行,日汇总 %s/%d 行。',
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行。',
$result['lifecycles']['status'],
$result['lifecycles']['source_rows'],
$result['period_changes']['status'],
$result['period_changes']['checked'],
$result['period_changes']['created'],
$result['bills']['status'],
$result['bills']['source_rows'],
$result['daily']['status'],

View File

@ -26,9 +26,14 @@ use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\DataWarningMirrorRepository;
use Modules\EcommerceFinance\Services\DataLogicSettings;
use Modules\EcommerceFinance\Services\TaxForecastLogic;
use Modules\EcommerceFinance\Services\StoreReportInitialPeriods;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
@ -257,6 +262,13 @@ class Pages extends Controller
$tabColumn = null;
$upstreamError = null;
// 内部流转报表读取共享业务镜像中由工作簿导入的数据集,不走上游只读查询。
if ($page && $content === null && $this->isInternalFlowReportPage($section, $page)) {
$internalFlow = $this->internalFlowReportRows();
$rows = $internalFlow['rows'];
$upstreamError = $internalFlow['error'];
}
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],
@ -362,6 +374,53 @@ class Pages extends Controller
return $section === 'fund-management' && $page === 'store-receiving-accounts';
}
private function isInternalFlowReportPage(array $section, array $page): bool
{
return ($section['slug'] ?? null) === 'store-management'
&& ($page['slug'] ?? null) === 'internal-flow-report';
}
/**
* 内部流转报表明细。数据来自共享业务镜像中按工作簿整份替换的导入结果,
* 尚未导入时保留空页面并说明原因,不把“未导入”当成业务数据为 0。
*
* @return array{rows: LengthAwarePaginator, error: ?string}
*/
private function internalFlowReportRows(): array
{
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
$paginate = fn (array $items, int $total): LengthAwarePaginator => new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
);
$repository = app(InternalFlowReportRepository::class);
if (! $repository->ready()) {
return [
'rows' => $paginate([], 0),
'error' => '内部流转数据尚未导入。导入《一般纳税人内部流转》工作簿后,本页会显示明细。',
];
}
$payload = $repository->paginate(
$currentPage,
$perPage,
[
'company_name' => (string) request('company_name', ''),
'tag' => (string) request('tag', ''),
],
request('sort') === null ? null : (string) request('sort'),
(string) request('direction', 'asc'),
);
return ['rows' => $paginate($payload['data'], $payload['total']), 'error' => null];
}
private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator
{
$query = \App\Models\Business\StoreReceivingAccount::query();
@ -509,10 +568,218 @@ class Pages extends Controller
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'warnings' => $this->contentForWarnings(),
'store-report-data-logic' => $this->prepareStoreReportLogic(),
'tax-forecast-data-logic' => $this->contentForTaxForecastLogic(),
default => null,
};
}
/** 首次打开报表逻辑页时把代码里的期初数据固化成可编辑设置。 */
protected function prepareStoreReportLogic(): ?array
{
StoreReportInitialPeriods::seedFromConfig();
return null;
}
/**
* 店铺主体、商标爬虫数据预警。列表和复审动作都只读写共享业务镜像库,
* 页面不直接访问上游。
*/
protected function contentForWarnings(): array
{
$clean = static fn (mixed $value, int $length = 40): string => mb_substr(trim((string) $value), 0, $length);
$filters = [
'warning_type' => $clean(request('warning_type', '')),
'risk_level' => $clean(request('risk_level', ''), 16),
'review_status' => $clean(request('review_status', ''), 16),
'warning_number' => $clean(request('warning_number', '')),
'related_object' => $clean(request('related_object', '')),
'responsible_person' => $clean(request('responsible_person', '')),
];
$page = max((int) request('page', 1), 1);
$repository = app(DataWarningMirrorRepository::class);
$payload = $repository->paginate($page, 50, $filters);
return [
'view' => 'business.pages.trademark-management.warnings',
'warningRows' => $payload['data'],
'warningTotal' => $payload['total'],
'warningPending' => $payload['pending'],
'warningReady' => $repository->ready(),
'warningFilters' => $filters,
];
}
/**
* 预警人工复审。确认=系统按爬虫强制覆盖的结果正确;驳回=以流程数据为准,
* 由后续期间合并任务改回并重算收入归属。
*/
public function reviewWarning(Request $request, int $warning)
{
$data = $request->validate([
'decision' => ['required', 'in:confirmed,rejected'],
'responsible_person' => ['nullable', 'string', 'max:191'],
'review_note' => ['nullable', 'string', 'max:500'],
]);
$repository = app(DataWarningMirrorRepository::class);
$record = $repository->find($warning);
abort_if($record === null, 404);
$reviewer = (string) (user()->name ?? user()->email ?? '系统用户');
$repository->review(
$warning,
(string) $data['decision'],
$reviewer,
$data['review_note'] ?? null,
$data['responsible_person'] ?? null,
);
$label = $data['decision'] === 'confirmed' ? '已确认' : '已驳回';
$redirect = redirect()
->route('business.pages.show', ['section' => 'trademark-management', 'page' => 'warnings'])
->with('success', "预警 {$record['warning_number']} {$label}。");
return $request->expectsJson()
? response()->json(['success' => true, 'message' => "预警 {$record['warning_number']} {$label}。"])
: $redirect;
}
/**
* 店铺主体商标数据逻辑配置:编辑页与保存动作。
*/
public function editEntityTrademarkLogic()
{
return view('business.pages.trademark-management.entity-trademark-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'entity-trademark-data-logic', 'title' => '店铺主体商标数据逻辑配置'],
'settings' => DataLogicSettings::current(),
]);
}
public function updateEntityTrademarkLogic(Request $request)
{
$data = $request->validate([
'trademark_separator' => ['required', 'string', 'max:8'],
'same_day_conflict_policy' => ['required', 'in:crawl_wins,flow_wins'],
'missing_trademark_action' => ['required', 'in:warn_keep,warn_hide'],
'stale_warning_enabled' => ['required', 'in:0,1'],
'stale_after_days' => ['required', 'integer', 'min:1', 'max:30'],
'owner_stale' => ['nullable', 'string', 'max:191'],
'owner_subject_name' => ['nullable', 'string', 'max:191'],
'owner_subject_conflict' => ['nullable', 'string', 'max:191'],
'owner_trademark' => ['nullable', 'string', 'max:191'],
'crawl_priority' => ['required', 'in:1,0'],
]);
DataLogicSettings::save($data);
return redirect()
->route('business.pages.show', ['section' => 'trademark-management', 'page' => 'entity-trademark-data-logic'])
->with('success', '店铺主体商标数据逻辑配置已保存。');
}
/**
* 店铺维度报表逻辑配置:编辑页与期初主体数据维护。
*/
public function editStoreReportLogic()
{
$periods = StoreReportInitialPeriods::current();
$shops = $periods['shops'];
uasort($shops, static fn (array $left, array $right): int => strcmp($left['changed_at'], $right['changed_at']));
return view('business.pages.trademark-management.store-report-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'store-report-data-logic', 'title' => '店铺维度报表逻辑配置'],
'initialEffectiveAt' => $periods['initial_effective_at'],
'initialShops' => $shops,
]);
}
public function updateStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
'previous_company' => ['required', 'string', 'max:191'],
'company' => ['required', 'string', 'max:191'],
'changed_at' => ['required', 'date_format:Y-m-d'],
]);
StoreReportInitialPeriods::putShop($data['store_name'], [
'previous_company' => $data['previous_company'],
'company' => $data['company'],
'changed_at' => $data['changed_at'],
]);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已保存。', $data['store_name']));
}
public function destroyStoreReportInitialPeriod(Request $request)
{
$data = $request->validate([
'store_name' => ['required', 'string', 'max:191'],
]);
StoreReportInitialPeriods::forgetShop($data['store_name']);
return redirect()
->route('business.store-report-logic.edit')
->with('success', sprintf('店铺「%s」的期初主体已删除。', $data['store_name']));
}
/**
* 税金预测表逻辑配置:规则清单、数据源状态和运行参数。
*/
protected function contentForTaxForecastLogic(): array
{
return [
'view' => 'business.pages.company-management.tax-forecast-data-logic',
'taxRules' => TaxForecastLogic::rules(),
'taxSettings' => TaxForecastLogic::current(),
'taxHealth' => TaxForecastLogic::health(),
];
}
public function editTaxForecastLogic()
{
return view('business.pages.company-management.tax-forecast-data-logic-edit', [
'section' => ['slug' => 'trademark-management', 'title' => '商标管理'],
'page' => ['slug' => 'tax-forecast-data-logic', 'title' => '税金预测表逻辑配置'],
'settings' => TaxForecastLogic::current(),
'incomeForecastSources' => TaxForecastLogic::INCOME_FORECAST_SOURCES,
]);
}
public function updateTaxForecastLogic(Request $request)
{
$data = $request->validate([
'vat_small_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'vat_free_threshold' => ['required', 'numeric', 'min:0'],
'vat_general_enabled' => ['required', 'in:0,1'],
'surtax_city_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_local_education_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'surtax_halved' => ['required', 'in:0,1'],
'stamp_duty_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'stamp_duty_halved' => ['required', 'in:0,1'],
'cit_effective_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'cit_threshold' => ['required', 'numeric', 'min:0'],
'individual_halved_threshold' => ['required', 'numeric', 'min:0'],
'assessed_income_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'income_forecast_source' => ['required', 'in:' . implode(',', array_keys(TaxForecastLogic::INCOME_FORECAST_SOURCES))],
]);
TaxForecastLogic::save($data);
return redirect()
->route('business.pages.show', ['section' => 'trademark-management', 'page' => 'tax-forecast-data-logic'])
->with('success', '税金预测表逻辑配置已保存,下次预测按新参数计算。');
}
/**
* 快递附加费报价维护。
*

View File

@ -50,7 +50,7 @@ class SidebarPermissions
return $navigation;
}
return array_values(array_filter(array_map(function (array $section) use ($allowed) {
$navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) {
$sectionKey = $section['slug'];
if (in_array($sectionKey, $allowed, true)) {
@ -92,6 +92,38 @@ class SidebarPermissions
return null;
}, $navigation)));
// 新增的公司成本安排、税金预测表与主体经营报表共用公司经营报表权限。
// 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。
foreach ($navigation as &$section) {
if (($section['slug'] ?? null) !== 'company-management') {
continue;
}
$hasSubjectReport = collect($section['pages'] ?? [])->contains('slug', 'subject-operating-report');
if ($hasSubjectReport) {
$configuredPages = collect(BusinessNavigation::all())
->firstWhere('slug', 'company-management')['pages'] ?? [];
foreach (['company-cost-arrangement', 'tax-forecast'] as $sharedReportSlug) {
$alreadyVisible = collect($section['pages'] ?? [])->contains('slug', $sharedReportSlug);
if ($alreadyVisible) {
continue;
}
$sharedReportPage = collect($configuredPages)->firstWhere('slug', $sharedReportSlug);
if ($sharedReportPage) {
$section['pages'][] = $sharedReportPage;
}
}
}
}
unset($section);
return $navigation;
}
public static function allKeys(): array

View File

@ -47,6 +47,8 @@ return [
'operations_replica.rpt.fys_companyInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '公司资料'],
'operations_replica.rpt.fys_shopInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺资料/店铺报表'],
'operations_replica.rpt.fys_TrademarkInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商标资料'],
'operations_replica.rpt.crawl_shop_company' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺基础信息/店铺主体期间'],
'operations_replica.rpt.crawl_shop_trademark' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '店铺基础信息/店铺商标'],
'operations_replica.rpt.jst_sku' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '商品资料/售后费用'],
'operations_replica.rpt.jst_warehouseInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '售后费用'],
'operations_replica.rpt.jst_shopInfo' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '预留聚水潭店铺维表'],

View File

@ -26,6 +26,62 @@ return [
],
],
],
[
'slug' => 'company-cost-arrangement',
'title' => '公司成本安排',
'directory' => [
'icon' => 'account_balance_wallet',
'status' => '报表待定义',
'description' => '按公司主体查看当月收入、成本缺口及成本费用安排。',
'empty' => '公司成本安排尚未同步数据。',
'fields' => [
['name' => 'company', 'label' => '公司'],
['name' => 'company_type', 'label' => '公司类型'],
['name' => 'invoice_type', 'label' => '发票类型'],
['name' => 'current_month_income_forecast', 'label' => '当月收入预测', 'kind' => 'number'],
['name' => 'historical_cost_gap', 'label' => '往期成本缺口', 'kind' => 'number'],
['name' => 'supplier_cost_arrangement', 'label' => '供应商成本安排', 'kind' => 'number'],
['name' => 'courier_cost_arrangement', 'label' => '快递成本安排', 'kind' => 'number'],
['name' => 'current_month_tax_forecast', 'label' => '当月税金预测', 'kind' => 'number'],
['name' => 'labor_cost_forecast', 'label' => '人工成本预测', 'kind' => 'number'],
['name' => 'other_expense_forecast', 'label' => '其他费用预测', 'kind' => 'number'],
],
],
],
[
'slug' => 'tax-forecast',
'title' => '税金预测表',
'directory' => [
'icon' => 'calculate',
'status' => '字段已定义',
'index_label' => '序号',
'description' => '按公司主体预测当月税金:先按公司税务类型、工商类型和征收方式确定口径,再计算增值税及附加、印花税和所得税;取数公式见 docs/税金预测表需求说明.md。',
'empty' => '税金预测表尚未接入取数:工商类型、征收方式(查账征收/核定征收)确认并补齐数据来源后开始计算。',
'fields' => [
['name' => 'company_name', 'label' => '公司名称'],
['name' => 'taxpayer_type', 'label' => '公司税务类型'],
['name' => 'business_registration_type', 'label' => '工商类型'],
['name' => 'collection_method', 'label' => '征收方式'],
['name' => 'assessed_income_rate', 'label' => '核定应税所得率(%)', 'kind' => 'number'],
['name' => 'forecast_month', 'label' => '预测月份'],
['name' => 'revenue_incl_tax', 'label' => '当月含税收入预测', 'kind' => 'number'],
['name' => 'revenue_excl_tax', 'label' => '当月不含税收入', 'kind' => 'number'],
['name' => 'quarter_sales_excl_tax', 'label' => '季度累计销售额(不含税)', 'kind' => 'number'],
['name' => 'cost_forecast', 'label' => '当月成本费用预测', 'kind' => 'number'],
['name' => 'taxable_income', 'label' => '预计应纳税所得额', 'kind' => 'number'],
['name' => 'vat_rate', 'label' => '增值税征收率/税率'],
['name' => 'vat_payable', 'label' => '预计增值税', 'kind' => 'number'],
['name' => 'urban_maintenance_tax', 'label' => '城建税', 'kind' => 'number'],
['name' => 'education_surcharge', 'label' => '教育费附加', 'kind' => 'number'],
['name' => 'local_education_surcharge', 'label' => '地方教育附加', 'kind' => 'number'],
['name' => 'stamp_duty', 'label' => '印花税', 'kind' => 'number'],
['name' => 'income_tax', 'label' => '企业所得税/经营所得个税', 'kind' => 'number'],
['name' => 'total_tax', 'label' => '税金合计', 'kind' => 'number'],
['name' => 'tax_burden_rate', 'label' => '综合税负率(%)', 'kind' => 'number'],
['name' => 'notes', 'label' => '备注'],
],
],
],
[
'slug' => 'basic-information',
'title' => '公司基础信息',
@ -173,6 +229,22 @@ return [
'fields' => [],
],
],
[
'slug' => 'entity-trademark-data-logic',
'title' => '店铺主体商标数据逻辑配置',
],
[
'slug' => 'store-report-data-logic',
'title' => '店铺维度报表逻辑配置',
],
[
'slug' => 'tax-forecast-data-logic',
'title' => '税金预测表逻辑配置',
],
[
'slug' => 'warnings',
'title' => '预警页面',
],
],
],
],
@ -212,6 +284,33 @@ return [
],
],
],
[
'slug' => 'internal-flow-report',
'title' => '内部流转报表',
'directory' => [
'icon' => 'swap_horiz',
'status' => '共享镜像',
'description' => '按《一般纳税人内部流转》工作簿导入:一行一个抬头,展示预计销量、开票、所需进项成本、收票来源与剩余缺口。',
'empty' => '内部流转数据尚未导入。导入工作簿后这里会显示明细。',
'fields' => [
['name' => 'company_name', 'label' => '抬头'],
['name' => 'tag', 'label' => '标签'],
['name' => 'projected_sales', 'label' => '预计9月销量', 'kind' => 'number'],
['name' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'kind' => 'number'],
['name' => 'internal_invoiced', 'label' => '开票-内部开票', 'kind' => 'number'],
['name' => 'required_input_cost', 'label' => '所需进项成本', 'kind' => 'number'],
['name' => 'receipt_from_supplier', 'label' => '收票-供应商', 'kind' => 'number'],
['name' => 'receipt_from_suofeiya', 'label' => '收票-索菲亚', 'kind' => 'number'],
['name' => 'receipt_from_gujia', 'label' => '收票-顾家', 'kind' => 'number'],
['name' => 'internal_transfer_1', 'label' => '收票-内部流转(来源)1', 'kind' => 'number'],
['name' => 'internal_transfer_2', 'label' => '收票-内部流转(来源)2', 'kind' => 'number'],
['name' => 'internal_transfer_3', 'label' => '收票-内部流转(来源)3', 'kind' => 'number'],
['name' => 'receipt_total', 'label' => '收票-合计', 'kind' => 'number'],
['name' => 'remaining_gap', 'label' => '剩余缺口', 'kind' => 'number'],
['name' => 'invoicing_ratio', 'label' => '开票比例'],
],
],
],
[
'slug' => 'basic-information',
'title' => '店铺基础信息',
@ -614,33 +713,6 @@ return [
],
],
],
[
'slug' => 'warnings',
'title' => '预警页面',
'directory' => [
'icon' => 'warning',
'status' => '尚未同步',
'description' => '集中展示销量、税务、主体、人员、商标和续签风险,并可关联待办编号追踪处理结果。',
'empty' => '预警数据尚未同步。完成规则引擎和预警来源接入后,这里会显示风险记录。',
'fields' => [
['name' => 'warning_number', 'label' => '预警编号'],
['name' => 'warning_type', 'label' => '预警类型'],
['name' => 'risk_level', 'label' => '风险等级'],
['name' => 'related_object', 'label' => '关联对象'],
['name' => 'risk_description', 'label' => '风险描述'],
['name' => 'impact_amount', 'label' => '影响金额', 'kind' => 'number'],
['name' => 'trigger_rule', 'label' => '触发规则'],
['name' => 'triggered_at', 'label' => '触发时间', 'kind' => 'date'],
['name' => 'owner', 'label' => '责任人'],
['name' => 'collaborator', 'label' => '协同人'],
['name' => 'due_at', 'label' => '截止时间', 'kind' => 'date'],
['name' => 'todo_number', 'label' => '待办编号'],
['name' => 'status', 'label' => '处理状态'],
['name' => 'evidence', 'label' => '处理证据'],
['name' => 'closed_by_date', 'label' => '关闭人/日期'],
],
],
],
],
],
];

View File

@ -0,0 +1,587 @@
<?php
/**
* 店铺维度报表的期初主体数据(2026-07-01 起)。
*
* 数据来自负责人提供的《店铺变更初始数据xlsx.xlsx》:每个变更店铺两行,
* 老主体自 2026-07-01 起生效、失效时间为店铺变更到新主体的当天;
* 报表按前一天展示失效时间,避免与到新主体的生效时间重叠,
* 因此这里直接保存「变更日前一天」作为老主体期间的失效时间。
*
* 未列入本表的店铺统一按 2026-07-01 起的当前主体计算,不再使用流程历史。
*/
return [
'initial_effective_at' => '2026-07-01',
'shops' => [
'京东-万客达家具旗舰店' => [
'previous_company' => '常州微振家居有限公司',
'company' => '武汉市朵莹米贸易有限公司',
'changed_at' => '2026-07-27',
],
'京东-亿家木家具旗舰店' => [
'previous_company' => '常州玺之橙家居有限公司',
'company' => '株洲市森境商贸有限公司',
'changed_at' => '2026-09-14',
],
'京东-亿家达家居旗舰店' => [
'previous_company' => '常州州圣家居有限公司',
'company' => '广州芯喻科技有限公司',
'changed_at' => '2026-09-11',
],
'京东-俚库节华专卖店' => [
'previous_company' => '常州淘之牧家居有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-10',
],
'京东-全颐家具旗舰店' => [
'previous_company' => '常州酷积吉电子商务有限公司',
'company' => '深圳市匠师科技有限公司',
'changed_at' => '2026-07-30',
],
'京东-励树旗舰店' => [
'previous_company' => '常州在木居家居有限公司',
'company' => '珠海市澄澄成贸易有限公司',
'changed_at' => '2026-08-05',
],
'京东-半隅雅居旗舰店' => [
'previous_company' => '常州尚之荷电子商务有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-27',
],
'京东-卓禾客厅家具旗舰店' => [
'previous_company' => '常州幂佳电子商务有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-08-25',
],
'京东-卓禾桌旗舰店' => [
'previous_company' => '常州素柚电子商务有限公司',
'company' => '武汉市焕独树科技有限公司',
'changed_at' => '2026-09-11',
],
'京东-墨尚思家具官方旗舰店' => [
'previous_company' => '常州米之信家居有限公司',
'company' => '深圳市澄镜贸易有限公司',
'changed_at' => '2026-08-05',
],
'京东-宛初家具旗舰店' => [
'previous_company' => '常州迈之时家居有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-28',
],
'京东-宜造旗舰店' => [
'previous_company' => '常州泰赫家居有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-24',
],
'京东-左印卧室家具旗舰店' => [
'previous_company' => '常州格之简电子商务有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-14',
],
'京东-左印旗舰店' => [
'previous_company' => '常州正琳电子商务有限公司',
'company' => '义乌市答馥贸易有限公司',
'changed_at' => '2026-09-11',
],
'京东-巷予家具旗舰店' => [
'previous_company' => '常州鹏冒家居有限公司',
'company' => '株洲市晞飏盛商贸有限公司',
'changed_at' => '2026-08-05',
],
'京东-新卓禾旗舰店' => [
'previous_company' => '常州仁之云家居有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-14',
],
'京东-木本诗家具旗舰店' => [
'previous_company' => '常州瓦铭家居有限公司',
'company' => '深圳市匠师科技有限公司',
'changed_at' => '2026-08-03',
],
'京东-未时旗舰店' => [
'previous_company' => '常州和之非家居有限公司',
'company' => '义乌市答馥贸易有限公司',
'changed_at' => '2026-07-24',
],
'京东-柯诺苏家具旗舰店' => [
'previous_company' => '常州旭致家居有限公司',
'company' => '株洲市晞飏盛商贸有限公司',
'changed_at' => '2026-08-05',
],
'京东-橡野里家具小店' => [
'previous_company' => '武进区潞城木易家具店',
'company' => '武汉市焕独树科技有限公司',
'changed_at' => '2026-07-09',
],
'京东-爱家猫' => [
'previous_company' => '常州峰麟电子商务有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-14',
],
'京东-爱木儿旗舰店' => [
'previous_company' => '常州泽之云家居有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-28',
],
'京东-礼想木旗舰店' => [
'previous_company' => '常州歆之吉家居有限公司',
'company' => '武汉市金逐玉贸易有限公司',
'changed_at' => '2026-08-15',
],
'京东-第一森林客厅家具旗舰店' => [
'previous_company' => '常州连音电子商务有限公司',
'company' => '广州芯喻科技有限公司',
'changed_at' => '2026-08-04',
],
'京东-第一森林家具旗舰店' => [
'previous_company' => '常州旭可电子商务有限公司',
'company' => '义乌市答馥贸易有限公司',
'changed_at' => '2026-07-27',
],
'京东-米客米家家具旗舰店' => [
'previous_company' => '常州欧鹰朗家居有限公司',
'company' => '上海臻磊居电子商务有限公司',
'changed_at' => '2026-09-10',
],
'京东-米昊旗舰店' => [
'previous_company' => '常州州圣家居有限公司',
'company' => '义乌市育旬贸易有限公司',
'changed_at' => '2026-07-30',
],
'京东-素成木旗舰店' => [
'previous_company' => '常州泱涵家居有限公司',
'company' => '武汉市金逐玉贸易有限公司',
'changed_at' => '2026-09-11',
],
'京东-纪辰家具旗舰店' => [
'previous_company' => '常州念晶家居有限公司',
'company' => '杭州照萍芋贸易有限公司',
'changed_at' => '2026-09-14',
],
'京东-贝壳家具旗舰店' => [
'previous_company' => '常州致圣家居有限公司',
'company' => '常州研选供应链有限公司',
'changed_at' => '2026-08-03',
],
'京东-赛初家具旗舰店' => [
'previous_company' => '常州嘉之钰家居有限公司',
'company' => '武汉市焕独树科技有限公司',
'changed_at' => '2026-09-11',
],
'京东-首升瓦舟迪专卖店' => [
'previous_company' => '常州绒灿家居有限公司',
'company' => '株洲市棱斛盛商贸有限公司',
'changed_at' => '2026-08-05',
],
'抖音-乾廷润凯家具专卖店' => [
'previous_company' => '常州迈而昕家居有限公司',
'company' => '武汉市焕独树科技有限公司',
'changed_at' => '2026-07-24',
],
'抖音-亿家达官方旗舰店' => [
'previous_company' => '常州格之简电子商务有限公司',
'company' => '广州市椿风贸易有限公司',
'changed_at' => '2026-07-22',
],
'抖音-亿家达家居专卖店' => [
'previous_company' => '常州庆之图家居有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-08-24',
],
'抖音-佰汇家具店' => [
'previous_company' => '常州巴宣家居有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-10',
],
'抖音-凌木家具店' => [
'previous_company' => '常州马月家居有限公司',
'company' => '武汉市焕独树科技有限公司',
'changed_at' => '2026-07-17',
],
'抖音-务原家具店' => [
'previous_company' => '常州格之麦家居有限公司',
'company' => '深圳市宙桦奇贸易有限公司',
'changed_at' => '2026-08-04',
],
'抖音-勤松苏得荷家具专卖店' => [
'previous_company' => '常州米之信家居有限公司',
'company' => '上海禄美佳商贸有限公司',
'changed_at' => '2026-07-26',
],
'抖音-吉友爱塑荣家居专卖店' => [
'previous_company' => '常州洁才家居有限公司',
'company' => '深圳市鑫鑫向洋科技有限公司',
'changed_at' => '2026-08-24',
],
'抖音-和辉家具店' => [
'previous_company' => '常州阳之红家居有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-07',
],
'抖音-常州科斯能家居有限公司' => [
'previous_company' => '常州俪能家居有限公司',
'company' => '义乌市络翘贸易有限公司',
'changed_at' => '2026-08-04',
],
'抖音-德朗蒂家具店' => [
'previous_company' => '常州蓝瞳家居有限公司',
'company' => '广州市墨拓贸易有限公司',
'changed_at' => '2026-07-17',
],
'抖音-星程家具店' => [
'previous_company' => '常州初之语家居有限公司',
'company' => '株洲市晞飏盛商贸有限公司',
'changed_at' => '2026-08-03',
],
'抖音-林舸家具店' => [
'previous_company' => '常州迈之时家居有限公司',
'company' => '珠海市澄澄成贸易有限公司',
'changed_at' => '2026-07-24',
],
'抖音-柯诺苏来导专卖店' => [
'previous_company' => '常州瓦铭家居有限公司',
'company' => '珠海市澄澄成贸易有限公司',
'changed_at' => '2026-07-24',
],
'抖音-柯诺苏艾利华家具专卖店' => [
'previous_company' => '常州初之语家居有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-07',
],
'抖音-樛木家具店' => [
'previous_company' => '常州绒美家居有限公司',
'company' => '深圳市澄镜贸易有限公司',
'changed_at' => '2026-08-03',
],
'抖音-畅森家具店' => [
'previous_company' => '常州茶木家居有限公司',
'company' => '义乌市答馥贸易有限公司',
'changed_at' => '2026-08-10',
],
'抖音-素屑家塑荣家具专卖店' => [
'previous_company' => '常州洁才家居有限公司',
'company' => '珠海市澄澄成贸易有限公司',
'changed_at' => '2026-07-24',
],
'抖音-纪辰欢而专卖店' => [
'previous_company' => '常州幂佳电子商务有限公司',
'company' => '广州市鼎流拓科技有限公司',
'changed_at' => '2026-07-24',
],
'抖音-艺煦白墙专卖店' => [
'previous_company' => '常州梦彬家居有限公司',
'company' => '深圳市澄镜贸易有限公司',
'changed_at' => '2026-08-04',
],
'抖音-蓝悦家具店' => [
'previous_company' => '常州初之语家居有限公司',
'company' => '株洲市晞飏盛商贸有限公司',
'changed_at' => '2026-08-03',
],
'抖音-赛初家具旗舰店' => [
'previous_company' => '常州绒美家居有限公司',
'company' => '义乌市育旬贸易有限公司',
'changed_at' => '2026-08-04',
],
'抖音-赛初莱夫特家具专卖店' => [
'previous_company' => '常州义之迈电子商务有限公司',
'company' => '珠海市澄澄成贸易有限公司',
'changed_at' => '2026-07-24',
],
'抖音-越堂家具店' => [
'previous_company' => '常州幂佳电子商务有限公司',
'company' => '广州市鼎流拓科技有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-UCU官方旗舰店' => [
'previous_company' => '常州格之简电子商务有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-09-14',
],
'拼多多-久伴吾家具旗舰店' => [
'previous_company' => '常州格之简电子商务有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-17',
],
'拼多多-亿佳木住宅家具官方旗舰店' => [
'previous_company' => '常州洁秋家居有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-07-16',
],
'拼多多-亿家木点好多专卖店' => [
'previous_company' => '常州欣之露电子商务有限公司',
'company' => '上海臻潭寨电子商务有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-亿家达书房家具官方旗舰店' => [
'previous_company' => '常州连音电子商务有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-亿家达住宅家具官方旗舰店' => [
'previous_company' => '常州悠益电子商务有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-17',
],
'拼多多-亿家达卧室家具官方旗舰店' => [
'previous_company' => '常州锋翎家居有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-亿家达官方旗舰店' => [
'previous_company' => '常州连音电子商务有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-16',
],
'拼多多-亿家达实木家具官方旗舰店' => [
'previous_company' => '常州连音电子商务有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-亿家达客厅家具官方旗舰店' => [
'previous_company' => '常州锋翎家居有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-亿家达旗舰店' => [
'previous_company' => '常州锋翎家居有限公司',
'company' => '青岛哲晖义商贸有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-亿家达桌椅官方旗舰店' => [
'previous_company' => '常州锋翎家居有限公司',
'company' => '武汉市鼎新致远科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-优乐熊家具旗舰店' => [
'previous_company' => '常州旭可电子商务有限公司',
'company' => '深圳市鑫鑫向洋科技有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-优乐猫家具旗舰店' => [
'previous_company' => '常州君之兰家居有限公司',
'company' => '义乌市竺芽电子商务有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-全米家具旗舰店' => [
'previous_company' => '常州酷积吉电子商务有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-华斯乐畅专卖店' => [
'previous_company' => '常州云栀电子商务有限公司',
'company' => '武汉市跨星河贸易有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-华斯予之福专卖店' => [
'previous_company' => '常州予之福家居有限公司',
'company' => '上海臻潭寨电子商务有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-卓禾住宅家具旗舰店' => [
'previous_company' => '常州歆森电子商务有限公司',
'company' => '安阳县峋庭电子商务有限公司',
'changed_at' => '2026-08-14',
],
'拼多多-卓禾家纺家具家装旗舰店' => [
'previous_company' => '常州庆之图家居有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-卓禾家纺家具旗舰店' => [
'previous_company' => '常州笛莫电子商务有限公司',
'company' => '安阳县峋庭电子商务有限公司',
'changed_at' => '2026-08-14',
],
'拼多多-卓禾旗舰店' => [
'previous_company' => '常州庆之图家居有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-08-12',
],
'拼多多-哈利木丫官方旗舰店' => [
'previous_company' => '常州旭可电子商务有限公司',
'company' => '深圳市鑫鑫向洋科技有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-墨尚思家具旗舰店' => [
'previous_company' => '常州苡宝家居有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-宜卡木住宅家具旗舰店' => [
'previous_company' => '常州书心家居有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-08-24',
],
'拼多多-宜自然住宅家具旗舰店' => [
'previous_company' => '常州笛莫电子商务有限公司',
'company' => '深圳市澜祺科技有限公司',
'changed_at' => '2026-07-16',
],
'拼多多-宜造家具旗舰店' => [
'previous_company' => '常州州圣家居有限公司',
'company' => '深圳市澄镜贸易有限公司',
'changed_at' => '2026-07-16',
],
'拼多多-屿林间家具旗舰店' => [
'previous_company' => '常州欣之露电子商务有限公司',
'company' => '广州市墨拓贸易有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-左印家具旗舰店' => [
'previous_company' => '常州歆森电子商务有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-07-17',
],
'拼多多-左昊家具旗舰店' => [
'previous_company' => '常州青号电子商务有限公司',
'company' => '深圳市匠师科技有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-归来舍下住宅家具旗舰店' => [
'previous_company' => '常州凯之贝家居有限公司',
'company' => '青岛铧胜靖商贸有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-惠木家特坤森专卖店' => [
'previous_company' => '常州冬之实家居有限公司',
'company' => '义乌市竺芽电子商务有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-慕加家具旗舰店' => [
'previous_company' => '常州青号电子商务有限公司',
'company' => '青岛哲晖义商贸有限公司',
'changed_at' => '2026-09-14',
],
'拼多多-星空夏日家纺家具家装官方旗舰店' => [
'previous_company' => '常州山之海家居有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-木小乐住宅家具旗舰店' => [
'previous_company' => '常州烟之雨家居有限公司',
'company' => '青岛疆拓杰商贸有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-木庭乐家具官方旗舰店' => [
'previous_company' => '常州赫晟电子商务有限公司',
'company' => '广州芯喻科技有限公司',
'changed_at' => '2026-07-13',
],
'拼多多-木野拾趣家具官方旗舰店' => [
'previous_company' => '常州笛莫电子商务有限公司',
'company' => '深圳市鑫鑫向洋科技有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-橡野里家具旗舰店' => [
'previous_company' => '常州橙兮电子商务有限公司',
'company' => '武汉市朵莹米贸易有限公司',
'changed_at' => '2026-07-17',
],
'拼多多-橡野里纳熙专卖店' => [
'previous_company' => '常州仁之云家居有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-欧意朗住宅家具旗舰店' => [
'previous_company' => '常州羽之间电子商务有限公司',
'company' => '安阳县峋庭电子商务有限公司',
'changed_at' => '2026-08-12',
],
'拼多多-欧意朗办公家具旗舰店' => [
'previous_company' => '常州莱夫特家居有限公司',
'company' => '安阳县峋庭电子商务有限公司',
'changed_at' => '2026-08-12',
],
'拼多多-欧意朗家具官方旗舰店' => [
'previous_company' => '常州森静电子商务有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-08-12',
],
'拼多多-欧意朗生活家具旗舰店' => [
'previous_company' => '常州锋翎家居有限公司',
'company' => '安阳县峋庭电子商务有限公司',
'changed_at' => '2026-08-12',
],
'拼多多-润万卓家具旗舰店' => [
'previous_company' => '常州平灿家居有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-24',
],
'拼多多-润典达旗舰店' => [
'previous_company' => '常州橙兮电子商务有限公司',
'company' => '上海禄美佳商贸有限公司',
'changed_at' => '2026-07-16',
],
'拼多多-满富油家具旗舰店' => [
'previous_company' => '常州皓熠电子商务有限公司',
'company' => '深圳市宙桦奇贸易有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-爱木儿家具旗舰店' => [
'previous_company' => '常州艺之瑾家居有限公司',
'company' => '南召县栎安商贸有限公司',
'changed_at' => '2026-09-10',
],
'拼多多-百里香晟陶专卖店' => [
'previous_company' => '常州晟陶电子商务有限公司',
'company' => '青岛铧胜靖商贸有限公司',
'changed_at' => '2026-09-14',
],
'拼多多-礼想木家具旗舰店' => [
'previous_company' => '常州雅之荷电子商务有限公司',
'company' => '武汉市朵莹米贸易有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-第一森林旗舰店' => [
'previous_company' => '常州思温家居有限公司',
'company' => '杭州照萍芋贸易有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-米昊家具旗舰店' => [
'previous_company' => '常州素柚电子商务有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-07-30',
],
'拼多多-美栖家家具官方旗舰店' => [
'previous_company' => '常州州圣家居有限公司',
'company' => '深圳市匠师科技有限公司',
'changed_at' => '2026-07-14',
],
'拼多多-花木涧家具旗舰店' => [
'previous_company' => '常州盈橡家居有限公司',
'company' => '杭州背多爱贸易有限公司',
'changed_at' => '2026-09-14',
],
'拼多多-莫埃家具旗舰店' => [
'previous_company' => '常州昀禾家居有限公司',
'company' => '青岛婳欣妍商贸有限公司',
'changed_at' => '2026-07-15',
],
'拼多多-迈磊凯家具专营店' => [
'previous_company' => '常州目之本电子商务有限公司',
'company' => '武汉市福流贸易有限公司',
'changed_at' => '2026-07-16',
],
'淘宝-平提家具源头工厂' => [
'previous_company' => '常州平提电子商务有限公司',
'company' => '义乌市育旬贸易有限公司',
'changed_at' => '2026-08-20',
],
'淘宝-康帕斯家具店' => [
'previous_company' => '永康市康帕斯贸易有限公司',
'company' => '深圳市澄镜贸易有限公司',
'changed_at' => '2026-08-20',
],
'淘宝-知木家具源头工厂店' => [
'previous_company' => '常州聪恩家居有限公司',
'company' => '广州市鼎流拓科技有限公司',
'changed_at' => '2026-08-14',
],
'阿里巴巴-达轩家居' => [
'previous_company' => '常州末屿家居有限公司',
'company' => '上海臻希鹏电子商务有限公司',
'changed_at' => '2026-09-24',
],
],
];

View File

@ -20,6 +20,16 @@ return [
env('APP_ENV', 'production') === 'production'
),
// 店铺主体、商标爬虫预警的默认责任人;留空时回退到系统内第一个启用用户。
'crawler_warnings' => [
'responsible' => [
'爬虫未更新' => trim((string) env('CRAWLER_WARNING_OWNER_STALE', '')),
'主体名称不一致' => trim((string) env('CRAWLER_WARNING_OWNER_SUBJECT_NAME', '')),
'同日主体冲突' => trim((string) env('CRAWLER_WARNING_OWNER_SUBJECT_CONFLICT', '')),
'关联缺失' => trim((string) env('CRAWLER_WARNING_OWNER_TRADEMARK', '')),
],
],
// 店铺经营报表由生产刷新节点自动完成首次同步并持续增量;开发机只读。
'store_report' => [
'enabled' => filter_var(env('STORE_REPORT_MIRROR_ENABLED', true), FILTER_VALIDATE_BOOL),
@ -103,6 +113,24 @@ return [
'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'],
'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.fys_TrademarkInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_TrademarkInfo', 'target' => 'ops_fys_trademark_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
// 店铺后台每天抓取的店铺→主体、店铺→商标映射。页面按最新 crawl_date
// 取每个店铺的当前值;update_time 作为同步水位线覆盖当天修正。
'operations_replica.rpt.crawl_shop_company' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'crawl_shop_company',
'target' => 'ops_crawl_shop_company',
'keys' => ['id'],
'watermark' => 'update_time',
],
'operations_replica.rpt.crawl_shop_trademark' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'crawl_shop_trademark',
'target' => 'ops_crawl_shop_trademark',
'keys' => ['id'],
'watermark' => 'update_time',
],
'operations_replica.rpt.jst_Orders' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('business_data_warnings')) {
return;
}
$schema->create('business_data_warnings', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('warning_key', 191)->unique();
$table->string('warning_number', 32)->unique();
$table->string('warning_type', 64);
$table->string('risk_level', 16);
$table->string('related_object', 191)->nullable();
$table->text('risk_description')->nullable();
$table->text('evidence')->nullable();
$table->string('impact', 191)->nullable();
$table->string('trigger_rule', 16)->nullable();
$table->dateTime('triggered_at');
$table->string('responsible_person', 191)->nullable();
$table->dateTime('due_at')->nullable();
$table->string('review_status', 32)->default('pending');
$table->string('reviewer', 191)->nullable();
$table->dateTime('reviewed_at')->nullable();
$table->text('review_note')->nullable();
$table->timestamps();
$table->index(['review_status', 'risk_level'], 'business_data_warnings_status_level_index');
$table->index('warning_type', 'business_data_warnings_type_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('business_data_warnings');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_report_shop_period_changes')) {
$schema->create('store_report_shop_period_changes', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('erp_store_name', 191);
$table->string('company_name', 191)->nullable();
$table->string('unified_social_credit_code', 64)->nullable();
$table->date('observed_on');
$table->string('source', 32)->default('crawl_shop_company');
$table->string('business_key', 64);
$table->timestamps();
$table->unique('business_key', 'store_report_period_changes_key_unique');
$table->index(['erp_store_name', 'observed_on'], 'store_report_period_changes_store_time_index');
});
}
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('store_report_shop_period_changes');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 早期实现把规则配置写成嵌套数组,设置组件会按点号展开成多行且无法回读。
* 清理这些残留键,规则与期初数据统一使用扁平键。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('settings')) {
return;
}
DB::table('settings')
->whereIn('key', [
'business_data_logic.entity_trademark',
'store_report.initial_periods',
'store_report.initial_periods.shops',
])
->orWhere('key', 'like', 'store_report.initial_periods.shops.%')
->delete();
}
public function down(): void
{
// 清理动作不可逆,也不影响现有数据。
}
};

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('business_internal_flow_records')) {
return;
}
$schema->create('business_internal_flow_records', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('sort_order')->default(0)->comment('源表行序,用于保持页面默认顺序');
$table->string('company_name', 191)->comment('抬头');
$table->string('tag', 32)->nullable()->comment('标签');
$table->decimal('projected_sales', 20, 6)->nullable()->comment('预计9月销量');
$table->decimal('invoiced_to_buyer', 20, 6)->nullable()->comment('开票-给买家开票');
$table->decimal('internal_invoiced', 20, 6)->nullable()->comment('开票-内部开票');
$table->decimal('required_input_cost', 20, 6)->nullable()->comment('所需进项成本');
$table->decimal('receipt_from_supplier', 20, 6)->nullable()->comment('收票-供应商');
$table->decimal('receipt_from_suofeiya', 20, 6)->nullable()->comment('收票-索菲亚');
$table->decimal('receipt_from_gujia', 20, 6)->nullable()->comment('收票-顾家');
$table->decimal('internal_transfer_1', 20, 6)->nullable()->comment('收票-内部流转(来源)1');
$table->decimal('internal_transfer_2', 20, 6)->nullable()->comment('收票-内部流转(来源)2');
$table->decimal('internal_transfer_3', 20, 6)->nullable()->comment('收票-内部流转(来源)3');
$table->decimal('receipt_total', 20, 6)->nullable()->comment('收票-合计');
$table->decimal('remaining_gap', 20, 6)->nullable()->comment('剩余缺口');
$table->string('invoicing_ratio', 32)->nullable()->comment('开票比例,源表存在 “/” 原样保留');
$table->string('source_file', 191)->nullable()->comment('导入文件名');
$table->timestamp('imported_at')->nullable()->comment('导入时间');
$table->timestamps();
$table->index('sort_order', 'business_internal_flow_records_sort_index');
$table->index('company_name', 'business_internal_flow_records_company_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('business_internal_flow_records');
}
};

View File

@ -84,7 +84,9 @@ calculate_local_image_fingerprint() {
deploy/entrypoint.sh \
deploy/local-healthcheck.sh
find resources/assets resources/views resources/lang -type f -print0
# resources/ 整体挂载进容器,Blade 视图在运行时编译,改视图不需要重建镜像;
# 只有需要编译进 public/ 的前端源码(resources/assets)才参与镜像指纹。
find resources/assets resources/lang -type f -print0
find public -type f \
! -path 'public/js/*' \
! -path 'public/vendor/*' \
@ -92,7 +94,7 @@ calculate_local_image_fingerprint() {
! -name 'mix-manifest.json' \
-print0
} \
| sort -z \
| LC_ALL=C sort -z \
| xargs -0 sha256sum \
| sha256sum \
| awk '{ print $1 }'
@ -405,11 +407,24 @@ if [ "$ready" -ne 1 ]; then
exit 1
fi
http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
http://127.0.0.1:18088/auth/login || true)"
# 应用刚启动时首个请求(共享库连接、缓存预热)可能明显偏慢,
# 这里按等待窗口重试,避免把“只是慢”误判成启动失败。
login_ready=0
for login_attempt in $(seq 1 8); do
http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 45 \
http://127.0.0.1:18088/auth/login || true)"
if [ "$http_code" != "200" ]; then
echo "错误:登录页返回 HTTP ${http_code:-000}。" >&2
if [ "$http_code" = "200" ]; then
login_ready=1
break
fi
echo " 等待登录页响应... ($login_attempt/8,当前 HTTP ${http_code:-000})"
sleep 5
done
if [ "$login_ready" -ne 1 ]; then
echo "错误:登录页在等待窗口内未返回 HTTP 200。" >&2
exit 1
fi

View File

@ -37,6 +37,8 @@
| 公司资料 | `rpt.fys_companyInfo` | `ops_fys_company_info` |
| 店铺资料 | `rpt.fys_shopInfo` | `ops_fys_shop_info` |
| 商标资料 | `rpt.fys_TrademarkInfo` | `ops_fys_trademark_info` |
| 店铺对应主体(爬虫) | `rpt.crawl_shop_company` | `ops_crawl_shop_company` |
| 店铺对应商标(爬虫) | `rpt.crawl_shop_trademark` | `ops_crawl_shop_trademark` |
| 订单 | `rpt.jst_Orders`、`rpt.jst_OrderItem` | `ops_jst_orders`、`ops_jst_order_item` |
| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*`(普通商品资料已切换) |
| 推广费用 | `rpt.ads_itemid_sales_cost_ratio_v2_di` | `ops_item_sales_cost_ratio` |
@ -110,3 +112,43 @@ php artisan business-mirror:sync-tables jiansun.dbo.jst_small_payment_order_map
```
两张表完成后,页面按映射表的工单创建时间稳定分页,并以 `woNo` 关联最新的工单主记录。筛选支持日期、工单号、店铺、支付宝批次号、线上订单号、状态和打款原因;表单详情从镜像中的 `businessFieldList` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。
## 店铺主体与商标爬虫数据(2026-10-03 接入)
店铺后台每天抓取的店铺对应主体、店铺对应商标数据进入共享镜像,是店铺主数据的最高优先级来源:
| 业务范围 | 上游对象 | 镜像目标 |
| --- | --- | --- |
| 店铺对应主体 | `rpt.crawl_shop_company` | `ops_crawl_shop_company` |
| 店铺对应商标 | `rpt.crawl_shop_trademark` | `ops_crawl_shop_trademark` |
规则:
1. 两个数据集按 `update_time` 增量同步,页面按每个店铺最新的 `crawl_date` 取值;爬虫没有覆盖的店铺继续使用财务系统原链路。
2. 店铺基础信息的「店铺主体」「商标」「商标号」三列优先显示爬虫结果;多商标在同一格内按商标号排序后用「、」串联,名称和号码顺序一一对应。
3. 展示、筛选、排序使用同一口径,商标名和商标号必须来自同一数据源,不允许只切换其中一个。
4. 爬虫主体与当前存续期间不一致时,`php artisan store-report:sync-crawler-subjects` 以抓到变化的 `crawl_date` 作为新主体生效日,老主体到同一天 00:00 结束;同一天流程表给出不同主体时以爬虫为准并生成待人工复审的预警。
5. `php artisan crawler:warnings:check` 检查爬虫断更、主体名称不一致、期间异常和关联缺失,写入「规则信息 → 预警页面」,每条预警指派责任人。
### 页面可维护项(2026-10-03 起)
规则和期初数据不再只写在代码里,负责人可以在页面上维护:
| 页面 | 入口 | 可维护内容 |
| --- | --- | --- |
| 店铺主体商标数据逻辑配置 | 规则信息 → 该页右上角「编辑配置」 | 多商标分隔符、同日主体冲突口径、商标号查不到资料时的处理、爬虫未更新检查开关与天数、四类预警责任人 |
| 店铺维度报表逻辑配置 | 规则信息 → 该页右上角「编辑期初数据」 | 期初生效日与 114 家变更店铺的老主体、新主体、变更日期(新增 / 修改 / 删除) |
页面写入系统库 `settings` 表,按公司隔离;命令行任务通过 `DataLogicSettings::bootCompanyContext()`
读取同一份配置。首次打开报表逻辑页会把 `config/store_report_initial_periods.php` 的内容固化成可编辑设置,
之后不再依赖代码文件。
首次全量必须在低峰期显式执行,顺序如下:
```bash
php artisan business-mirror:sync-tables operations_replica.rpt.crawl_shop_company --full
php artisan business-mirror:sync-tables operations_replica.rpt.crawl_shop_trademark --full
php artisan store-report:sync-crawler-subjects --dry-run # 先核对将要生成的主体期间变更
php artisan store-report:sync-crawler-subjects
php artisan crawler:warnings:check
```

View File

@ -40,7 +40,7 @@
| 页面/业务域 | 数据集 | 目标层 | 说明 |
| --- | --- | --- | --- |
| 公司、店铺、商标 | `fys_companyInfo`、`fys_shopInfo`、`fys_TrademarkInfo` | MariaDB 维表 | 行数小、点查与关联频繁 |
| 店铺经营报表 | `store_report_lifecycle_rows` + `store_report_daily_rows` | MariaDB 维表/汇总 | 页面已改为共享日汇总,不依赖 `runtime/bills.sqlite`,不扫描原始账单 |
| 店铺经营报表 | 期初主体配置 + `store_report_shop_period_changes` + `store_report_daily_rows` | MariaDB 汇总/配置 | 主体期间按期初数据和爬虫变更生成,一行一期间;页面读共享日汇总,不扫描原始账单。`store_report_lifecycle_rows` 仅保留流程原始记录备查 |
| 普通商品资料 | `jst_sku` + 采购价镜像 | MariaDB 维表/服务表 | 约 55 万商品仍适合行存筛选;保留现有完整导出校准 |
| 推广费用 | `ads_itemid_sales_cost_ratio_v2_di` | ClickHouse 事实表 | `jst_skumap` 不再作为页面就绪条件 |
| 售后费用、非京喜退款 | 订单、订单明细、退款主表、退款明细 | ClickHouse 事实表 + MariaDB SKU/仓库/店铺维表 | 四张事实表必须成组对账和切换,不能逐张切换造成跨库半成品 |

View File

@ -0,0 +1,62 @@
# 税金预测表需求说明
## 1. 文档状态
- 状态:页面和字段结构已建立;取数公式为初稿,税率口径和两个新增数据源待负责人确认。
- 页面:经营报表 → 税金预测表。
- 路由标识:`company-management/tax-forecast`。
- 适用对象:小微企业主体(小规模纳税人、小型微利企业、个体工商户、个人独资企业、合伙企业等)。
- 本文之外不自行扩展业务规则;正式口径以负责人和主管税务机关确认为准。
## 2. 展示粒度
一行 = 一个公司主体 × 一个预测月份。
先看“公司税务类型 + 工商类型 + 征收方式”三列确定算法口径,再看税种明细列;
不是同一个口径的主体不放在同一组做横向比较。
## 3. 字段顺序与取数公式
序号由目录表自动生成,不占数据字段。
| 顺序 | 字段 | 取数 / 公式 |
| ---: | --- | --- |
| 1 | 公司名称 | 公司基础信息镜像 `rpt.fys_companyInfo.businessLicenseName` |
| 2 | 公司税务类型 | 公司基础信息镜像 `taxCategoryAndType`:一般纳税人 / 小规模纳税人 / 个体工商户 / 无税务登记 |
| 3 | 工商类型 | **新增数据源**:工商登记类型(有限责任公司、有限责任公司(自然人独资)、个人独资企业、合伙企业、个体工商户)。用于区分经营所得个税与企业所得税 |
| 4 | 征收方式 | **新增数据源**:查账征收 / 核定征收 / 定期定额。核定户按核定通知书取数 |
| 5 | 核定应税所得率(%) | 核定征收时按主管税务机关核定通知书维护;定期定额户按核定定额折算 |
| 6 | 预测月份 | 页面参数,默认当月 |
| 7 | 当月含税收入预测 | 主体维度经营报表同口径:`bills.income` 按主体生效/失效期间归集到当月;当月未出账时取上月实际值或公司成本安排页的“当月收入预测” |
| 8 | 当月不含税收入 | 小规模:含税收入 ÷ (1 + 征收率);一般纳税人:按适用税率(6%/9%/13%)分别换算后合计 |
| 9 | 季度累计销售额(不含税) | 本季度 3 个月不含税销售额之和,作为小规模免征增值税的判断基数 |
| 10 | 当月成本费用预测 | 公司成本安排页:供应商成本安排 + 快递成本安排 + 人工成本预测 + 其他费用预测 |
| 11 | 预计应纳税所得额 | 查账征收:不含税收入 − 成本 − 费用 − 税金及附加;核定征收:不含税收入 × 核定应税所得率 |
| 12 | 增值税征收率/税率 | 小规模 1%(3% 减按 1%);一般纳税人按业务适用 6%/9%/13% |
| 13 | 预计增值税 | 小规模:季度销售额 > 30 万元时 = 不含税销售额 × 1%,≤ 30 万元 = 0;一般纳税人:销项税额 − 进项税额;个体工商户、个人独资企业同小规模口径 |
| 14 | 城建税 | 预计增值税 × 7%(市区)/ 5%(县城、镇)/ 1%(其他)× 50%(六税两费减半) |
| 15 | 教育费附加 | 预计增值税 × 3% × 50% |
| 16 | 地方教育附加 | 预计增值税 × 2% × 50% |
| 17 | 印花税 | 购销合同金额 × 0.03% × 50%(小规模、小微减半) |
| 18 | 企业所得税/经营所得个税 | 有限公司(小型微利):应纳税所得额 × 25% × 20%,年 ≤ 300 万元部分实际税负 5%;个人独资/合伙/个体户:经营所得五级超额累进(5%~35%),核定户按 收入 × 核定应税所得率 计算,个体工商户 200 万元以内减半 |
| 19 | 税金合计 | 增值税 + 城建税 + 教育费附加 + 地方教育附加 + 印花税 + 所得税 |
| 20 | 综合税负率(%) | 税金合计 ÷ 当月不含税收入 × 100% |
| 21 | 备注 | 数据来源、核定依据、需人工确认项;工资薪金个税、分红个税按实际发生单独测算,不并入本表 |
## 4. 政策口径(2026 年 10 月初稿,待财务确认)
以下按现行小微企业通用政策整理,执行期限和适用条件以最新公告为准:
- 小规模纳税人月销售额 10 万元以下(季度 30 万元以下)免征增值税;3% 征收率减按 1% 征收。
- 增值税小规模纳税人、小型微利企业和个体工商户“六税两费”减半征收(含城建税、教育费附加、地方教育附加、印花税等)。
- 小型微利企业年应纳税所得额不超过 300 万元部分,减按 25% 计入应纳税所得额,按 20% 税率缴纳企业所得税(实际税负 5%)。
- 个体工商户年应纳税所得额不超过 200 万元部分,经营所得个人所得税减半征收。
## 5. 数据缺口与前置条件
1. **工商类型**:现有共享镜像只有 `businessLicenseType`(内部管理分类,如“店铺-普通公司”“个体户”),无法区分个人独资企业、合伙企业、有限责任公司。需要确认来源:工商公示/企查查爬虫接入,或维护在公司档案中。
2. **征收方式与核定应税所得率**:属于税务登记信息,现有镜像没有。需要确认来源:电子税务局取数,或由财务在系统内维护。
3. **收入预测**:沿用主体维度经营报表的主体收入口径(`bills.income` + 店铺主体生命周期)。
4. **成本费用预测**:等待公司成本安排页接入供应商、快递、人工和其他费用数据。
以上 1、2 两项确认前,页面保持“字段已定义、数据待接入”,不显示推测数据。

View File

@ -31,7 +31,7 @@ class BusinessInformationMirrorRepository
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
public function paginate(string $scope, int $page, int $perPage): array
public function paginate(string $scope, int $page, int $perPage, array $filters = []): array
{
if (! $this->ready($scope)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
@ -41,9 +41,9 @@ class BusinessInformationMirrorRepository
$perPage = min(max($perPage, 1), 100);
return match ($scope) {
'company-information.read' => $this->companies($page, $perPage),
'store-information.read' => $this->stores($page, $perPage),
'trademark-information.read' => $this->trademarks($page, $perPage),
'company-information.read' => $this->companies($page, $perPage, $filters),
'store-information.read' => $this->stores($page, $perPage, $filters),
'trademark-information.read' => $this->trademarks($page, $perPage, $filters),
default => throw new InvalidArgumentException('未知的业务资料镜像范围:' . $scope),
};
}
@ -55,13 +55,21 @@ class BusinessInformationMirrorRepository
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$query = $this->storeQuery()->orderByDesc('s.Id');
$query = $this->storeQuery($this->crawlerSubqueries())->orderByDesc('s.Id');
$this->applyStoreFilters($query, $filters);
return $query
->get()
->map(static fn (object $row): array => (array) $row)
->all();
$rows = $query->get()->map(static fn (object $row): array => (array) $row)->all();
$trademarks = $this->crawlerTrademarkPairsForAll(
array_map(static fn (array $row): string => (string) ($row['erp_store_name'] ?? ''), $rows),
);
return array_map(
fn (array $row): array => $this->applyCrawlerPriority(
$row,
$trademarks[(string) ($row['erp_store_name'] ?? '')] ?? [],
),
$rows,
);
}
/**
@ -118,7 +126,7 @@ SQL)
] as $key => $column) {
$value = trim((string) ($filters[$key] ?? ''));
if ($value !== '') {
$query->where($column, '=', $value);
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
@ -162,7 +170,7 @@ SQL)
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function companies(int $page, int $perPage): array
private function companies(int $page, int $perPage, array $filters): array
{
$query = $this->connection->table('ops_fys_company_info')
->selectRaw(<<<'SQL'
@ -184,16 +192,261 @@ taxCategoryAndType AS taxpayer_category,
NULL AS shareholders
SQL);
return $this->page($query, $page, $perPage, 'Id');
$this->applyFieldFilters($query, $filters, [
'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName',
'establishment_date' => 'IncorporationDate',
'registered_at' => 'IncorporationDate',
'legal_representative' => 'legalRepresentative', 'registered_address' => 'registeredAddress',
'city' => 'registeredAddress',
'business_scope' => 'businessScope', 'subscribed_at' => 'registeredCapitalVaildTime',
'financial_officer' => 'financialManager',
'billing_officer' => 'financialManager',
'supervisor' => 'supervisor',
], ['subscribed_capital' => 'registeredCapital'], [
'company_type' => 'businessLicenseType',
'taxpayer_type' => 'taxCategoryAndType',
'taxpayer_category' => 'taxCategoryAndType',
]);
$companyStatus = trim((string) ($filters['company_status'] ?? ''));
if ($companyStatus !== '') {
$statusCode = ['正常' => '0', '已注销' => '1'][$companyStatus] ?? $companyStatus;
$query->where('companyStatus', '=', $statusCode);
}
$relatedPeople = mb_substr(trim((string) ($filters['related_people'] ?? '')), 0, 191);
if ($relatedPeople !== '') {
$like = '%' . addcslashes($relatedPeople, '%_\\') . '%';
$query->where(function ($query) use ($like): void {
$query->where('legalRepresentative', 'like', $like)
->orWhere('supervisor', 'like', $like)
->orWhere('financialManager', 'like', $like);
});
}
return $this->page($query, $page, $perPage, 'Id', $filters, [
'unified_social_credit_code' => 'unifiedSocialCreditCode', 'company_name' => 'businessLicenseName',
'taxpayer_type' => 'taxCategoryAndType', 'company_status' => 'companyStatus',
'subscribed_capital' => 'registeredCapital', 'registered_at' => 'IncorporationDate',
'legal_representative' => 'legalRepresentative', 'supervisor' => 'supervisor',
'billing_officer' => 'financialManager', 'registered_address' => 'registeredAddress',
'city' => 'registeredAddress', 'business_scope' => 'businessScope',
'basic_deposit_account' => 'accountSerialNumber', 'subscribed_at' => 'registeredCapitalVaildTime',
]);
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function stores(int $page, int $perPage): array
private function stores(int $page, int $perPage, array $filters): array
{
return $this->page($this->storeQuery(), $page, $perPage, 's.Id');
$crawler = $this->crawlerSubqueries();
$query = $this->storeQuery($crawler);
$this->applyFieldFilters($query, $filters, [
'store_id' => 's.sotreId', 'store_name' => 's.erpShopName',
'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead',
'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime',
], [], ['platform' => 's.platform']);
$this->applyCrawlerPriorityFilters($query, $filters, $crawler);
$storeStatus = trim((string) ($filters['store_status'] ?? ''));
if ($storeStatus !== '') {
$statusCode = ['正常' => '0', '关闭中' => '3', '已关闭' => '6'][$storeStatus] ?? $storeStatus;
$query->where('s.shopState', '=', $statusCode);
}
$withdrawalAccount = trim((string) ($filters['withdrawal_account'] ?? ''));
if ($withdrawalAccount === '已配置') {
$query->whereNotNull('s.shopBankAccount')->where('s.shopBankAccount', '<>', '');
} elseif ($withdrawalAccount === '未配置') {
$query->where(function ($query): void {
$query->whereNull('s.shopBankAccount')->orWhere('s.shopBankAccount', '=', '');
});
}
$result = $this->page($query, $page, $perPage, 's.Id', $filters, [
'platform' => 's.platform', 'store_id' => 's.sotreId', 'store_name' => 's.erpShopName',
'store_status' => 's.shopState',
'business_department' => 's.responsibleDepartment', 'store_manager' => 's.shopHead',
'department_manager' => 's.customerDeptManager', 'opened_at' => 's.openTime',
'renewed_at' => 's.renewalTime', 'withdrawal_account' => 's.shopBankAccount',
]);
$result['data'] = array_map(
fn (array $row): array => $this->applyCrawlerPriority(
$row,
$this->crawlerTrademarkPairsFor((string) ($row['erp_store_name'] ?? '')),
),
$result['data'],
);
return $result;
}
private function storeQuery()
/**
* 店铺基础信息页的展示优先级:爬虫抓到的店铺主体、商标名称和商标号优先,
* 爬虫没有覆盖的店铺保留财务系统原有链路的结果。
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
/**
* @param array<string,mixed> $row
* @param list<array{number:string,name:string}> $trademarkPairs
* @return array<string,mixed>
*/
private function applyCrawlerPriority(array $row, array $trademarkPairs = []): array
{
// 多商标拼接放在 PHP 层:SQLite 测试库不支持 group_concat 的 order by 语法。
if ($trademarkPairs !== []) {
$separator = \Modules\EcommerceFinance\Services\DataLogicSettings::separator();
$row['crawler_trademark_name'] = implode($separator, array_filter(array_column($trademarkPairs, 'name')));
$row['crawler_trademark_number'] = implode($separator, array_filter(array_column($trademarkPairs, 'number')));
}
// 保留财务系统登记主体,供店铺维度报表生成期初期间;展示仍按爬虫优先。
$row['system_company_name'] = $row['company_name'] ?? null;
$company = trim((string) ($row['crawler_company_name'] ?? ''));
if ($company !== '') {
$row['company_name'] = $company;
}
$trademarkName = trim((string) ($row['crawler_trademark_name'] ?? ''));
if ($trademarkName !== '') {
$row['trademark_name'] = $trademarkName;
}
$trademarkNumber = trim((string) ($row['crawler_trademark_number'] ?? ''));
if ($trademarkNumber !== '') {
$row['trademark_number'] = $trademarkNumber;
}
unset(
$row['crawler_company_name'],
$row['crawler_trademark_name'],
$row['crawler_trademark_number'],
);
return $row;
}
/**
* 某店铺爬虫抓到的全部商标,按商标号排序,保证名称与号码一一对应。
*
* @return list<array{number:string,name:string}>
*/
/** @return list<array{number:string,name:string}> */
private function crawlerTrademarkPairsFor(string $storeName): array
{
$all = $this->crawlerTrademarkPairsForAll([$storeName]);
return $all[trim($storeName)] ?? [];
}
/**
* 批量加载多个店铺的爬虫商标,避免列表页逐行查询。
*
* @param list<string> $storeNames
* @return array<string,list<array{number:string,name:string}>>|array{}
*/
private function crawlerTrademarkPairsForAll(array $storeNames): array
{
$names = array_values(array_unique(array_filter(array_map('trim', $storeNames))));
if ($names === [] || ! $this->connection->getSchemaBuilder()->hasTable('ops_crawl_shop_trademark')) {
return [];
}
$result = [];
foreach (array_chunk($names, 500) as $chunk) {
$ranked = $this->connection->table('ops_crawl_shop_trademark')
->selectRaw('id, shop_name, trademark_no, trademark_name, crawl_date, row_number() over (partition by shop_name, trademark_no order by crawl_date desc, id desc) AS row_rank')
->whereIn('shop_name', $chunk);
$rows = $this->connection->table($ranked, 'crawler_trademark')
->where('row_rank', 1)
->orderBy('shop_name')
->orderBy('trademark_no')
->get();
foreach ($rows as $row) {
$store = trim((string) $row->shop_name);
$number = trim((string) $row->trademark_no);
if ($store === '' || $number === '') {
continue;
}
$result[$store][] = [
'number' => $number,
'name' => trim((string) $row->trademark_name),
];
}
}
return $result;
}
/** @param array<string,mixed> $crawler @param array<string,string> $filters */
private function applyCrawlerPriorityFilters($query, array $filters, array $crawler): void
{
$entity = mb_substr(trim((string) ($filters['store_entity'] ?? '')), 0, 191);
if ($entity !== '') {
$like = '%' . addcslashes($entity, '%_\\') . '%';
$query->where(function ($query) use ($crawler, $like): void {
$query->where('c.businessLicenseName', 'like', $like);
if ($crawler['store_entity'] !== null) {
$query->orWhere($crawler['store_entity'], 'like', $like);
}
});
}
foreach ([
'trademark' => ['crawler' => 'trademark_name', 'fallback' => 't.trademarkName'],
'trademark_number' => ['crawler' => 'trademark_no', 'fallback' => 's.trademarkNumber'],
] as $key => $columns) {
$value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191);
if ($value === '') {
continue;
}
$like = '%' . addcslashes($value, '%_\\') . '%';
if ($crawler['trademark_table'] === null) {
$query->where($columns['fallback'], 'like', $like);
continue;
}
// 多商标店铺不能在联表结果里只匹配最新一条,这里按全量商标做 EXISTS 匹配。
$query->where(function ($query) use ($columns, $like, $crawler): void {
$query->where($columns['fallback'], 'like', $like)
->orWhereExists(function ($sub) use ($columns, $like, $crawler): void {
$sub->selectRaw('1')
->from($crawler['trademark_table'] . ' AS crawler_trademark_filter')
->whereColumn('crawler_trademark_filter.shop_name', 's.erpShopName')
->where('crawler_trademark_filter.' . $columns['crawler'], 'like', $like);
});
});
}
}
/**
* 爬虫表的“每店铺最新一版”子查询。镜像缺失时返回 null,页面回退原链路。
*
* @return array{company:?object,store_entity:?string,trademark_table:?string}
*/
private function crawlerSubqueries(): array
{
$absence = ['company' => null, 'store_entity' => null, 'trademark_table' => null];
$schema = $this->connection->getSchemaBuilder();
if (! $schema->hasTable('ops_crawl_shop_company') && ! $schema->hasTable('ops_crawl_shop_trademark')) {
return $absence;
}
$company = null;
if ($schema->hasTable('ops_crawl_shop_company')) {
$ranked = $this->connection->table('ops_crawl_shop_company')
->selectRaw('id, shop_name, company_name, tax_no, crawl_date, row_number() over (partition by shop_name order by crawl_date desc, id desc) AS row_rank');
$company = $this->connection->table($ranked, 'crawler_company')
->where('row_rank', 1)
->select(['shop_name', 'company_name', 'tax_no']);
}
return [
'company' => $company,
'store_entity' => $company === null ? null : 'crawler_company.company_name',
'trademark_table' => $schema->hasTable('ops_crawl_shop_trademark') ? 'ops_crawl_shop_trademark' : null,
];
}
private function storeQuery(array $crawler = ['company' => null])
{
$latestCompanies = $this->connection->table('ops_fys_company_info')
->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id')
@ -219,6 +472,11 @@ SQL);
},
)
->leftJoin('ops_fys_trademark_info AS t', 't.Id', '=', 'trademark_keys.latest_id')
->when($crawler['company'] !== null, function ($query) use ($crawler): void {
$query->leftJoinSub($crawler['company'], 'crawler_company', function ($join): void {
$join->on('crawler_company.shop_name', '=', 's.erpShopName');
});
})
->selectRaw(<<<'SQL'
s.Id AS id,
s.sotreId AS store_id,
@ -237,14 +495,24 @@ s.customerDeptManager AS department_manager,
s.shopState AS store_status,
s.shopBankAccount AS withdrawal_account,
s.shopType AS store_type,
s.channelType AS channel_mode
s.channelType AS channel_mode,
NULL AS crawler_company_name,
NULL AS crawler_trademark_name,
NULL AS crawler_trademark_number
SQL);
if ($crawler['company'] !== null) {
$query->addSelect([
'crawler_company.company_name AS crawler_company_name',
'crawler_company.tax_no AS crawler_tax_no',
]);
}
return $query;
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function trademarks(int $page, int $perPage): array
private function trademarks(int $page, int $perPage, array $filters): array
{
$query = $this->connection->table('ops_fys_trademark_info')
->where('isDelete', 0)
@ -263,14 +531,69 @@ custodyDepartment AS custody_department,
remark AS notes
SQL);
return $this->page($query, $page, $perPage, 'Id');
$this->applyFieldFilters($query, $filters, [
'trademark_number' => 'trademarkNumber', 'trademark_name' => 'trademarkName',
'registration_date' => 'registrationDate',
'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification',
'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser',
'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment',
]);
$status = trim((string) ($filters['trademark_status'] ?? ''));
if ($status !== '') {
$statusCode = ['未启用' => '0', '有效' => '1'][$status] ?? $status;
$query->where('trademarkStatus', '=', $statusCode);
}
return $this->page($query, $page, $perPage, 'Id', $filters, [
'trademark_name' => 'trademarkName', 'trademark_number' => 'trademarkNumber',
'trademark_status' => 'trademarkStatus', 'registration_date' => 'registrationDate',
'expiry_date' => 'termOfValidity', 'international_classification' => 'internationalClassification',
'approved_goods' => 'approvedCommodities', 'final_holder' => 'transferenceUser',
'registered_address' => 'registrationAddress', 'custody_department' => 'custodyDepartment',
]);
}
/** @param array<string,string> $textColumns @param array<string,string> $exactColumns @param array<string,string> $enumColumns */
private function applyFieldFilters($query, array $filters, array $textColumns, array $exactColumns = [], array $enumColumns = []): void
{
foreach ($textColumns as $key => $column) {
$value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191);
if ($value !== '') {
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
foreach ($exactColumns as $key => $column) {
$value = trim((string) ($filters[$key] ?? ''));
if ($value !== '') {
$query->where($column, '=', $value);
}
}
foreach ($enumColumns as $key => $column) {
$value = trim((string) ($filters[$key] ?? ''));
if ($value !== '') {
$query->where($column, '=', $value);
}
}
foreach (['opened_at' => 's.openTime', 'renewed_at' => 's.renewalTime', 'registration_date' => 'registrationDate', 'expiry_date' => 'termOfValidity'] as $key => $column) {
if (! array_key_exists($key, $textColumns)) continue;
foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) {
$date = trim((string) ($filters[$key . $suffix] ?? ''));
if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/D', $date)) $query->where($column, $operator, $date);
}
}
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function page($query, int $page, int $perPage, string $orderColumn): array
private function page($query, int $page, int $perPage, string $orderColumn, array $filters = [], array $sortColumns = []): array
{
$total = (clone $query)->count();
$rows = $query->orderByDesc($orderColumn)
$sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null;
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
if ($sortColumn !== null) {
$query->orderBy($sortColumn, $direction)->orderByDesc($orderColumn);
} else {
$query->orderByDesc($orderColumn);
}
$rows = $query
->forPage($page, $perPage)
->get()
->map(static fn (object $row): array => (array) $row)

View File

@ -0,0 +1,189 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
class DataWarningMirrorRepository
{
private readonly ConnectionInterface $connection;
public function __construct(GenericBusinessMirrorRepository $mirror)
{
$this->connection = $mirror->connection();
}
public function ready(): bool
{
return $this->connection->getSchemaBuilder()->hasTable('business_data_warnings');
}
/**
* 生成或更新一条预警。同 warning_key 重复触发时只更新风险描述和影响,
* 保留首次触发时间、责任人指派和处理留痕。
*
* @param array<string,mixed> $warning
*/
public function upsert(array $warning): void
{
$key = (string) $warning['warning_key'];
$existing = $this->connection->table('business_data_warnings')->where('warning_key', $key)->first();
if ($existing !== null) {
$this->connection->table('business_data_warnings')->where('warning_key', $key)->update([
'warning_type' => $warning['warning_type'],
'risk_level' => $warning['risk_level'],
'related_object' => $warning['related_object'] ?? null,
'risk_description' => $warning['risk_description'] ?? null,
'evidence' => $warning['evidence'] ?? null,
'impact' => $warning['impact'] ?? null,
'trigger_rule' => $warning['trigger_rule'] ?? null,
'updated_at' => now(),
]);
return;
}
$this->connection->table('business_data_warnings')->insert([
'warning_key' => $key,
'warning_number' => $this->nextWarningNumber(),
'warning_type' => $warning['warning_type'],
'risk_level' => $warning['risk_level'],
'related_object' => $warning['related_object'] ?? null,
'risk_description' => $warning['risk_description'] ?? null,
'evidence' => $warning['evidence'] ?? null,
'impact' => $warning['impact'] ?? null,
'trigger_rule' => $warning['trigger_rule'] ?? null,
'triggered_at' => now(),
'responsible_person' => $warning['responsible_person'] ?? null,
'due_at' => $warning['due_at'] ?? null,
'review_status' => 'pending',
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* 预警页面数据。按触发时间倒序,等待复审的排在前面。
*
* @param array<string,string> $filters
* @return array{data: array<int,array<string,mixed>>, total: int, pending: int}
*/
public function paginate(int $page, int $perPage, array $filters = []): array
{
if (! $this->ready()) {
return ['data' => [], 'total' => 0, 'pending' => 0];
}
$query = $this->connection->table('business_data_warnings');
foreach (['warning_type', 'risk_level', 'review_status'] as $exact) {
$value = trim((string) ($filters[$exact] ?? ''));
if ($value !== '') {
$query->where($exact, '=', $value);
}
}
foreach (['warning_number', 'related_object', 'responsible_person'] as $text) {
$value = trim((string) ($filters[$text] ?? ''));
if ($value !== '') {
$query->where($text, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
$total = (clone $query)->count();
$pending = (clone $query)->where('review_status', '=', 'pending')->count();
$rows = $query
->orderByRaw("case when review_status = 'pending' then 0 else 1 end")
->orderByDesc('triggered_at')
->orderByDesc('id')
->forPage(max($page, 1), $perPage)
->get()
->map(static function (object $row): array {
$row = (array) $row;
$row['triggered_at'] = $row['triggered_at'] !== null
? Carbon::parse($row['triggered_at'])->format('Y-m-d H:i')
: null;
$row['reviewed_at'] = $row['reviewed_at'] !== null
? Carbon::parse($row['reviewed_at'])->format('Y-m-d H:i')
: null;
$row['due_at'] = $row['due_at'] !== null
? Carbon::parse($row['due_at'])->format('Y-m-d')
: null;
$row['review_status_label'] = match ((string) $row['review_status']) {
'pending' => '待复审',
'confirmed' => '已确认',
'rejected' => '已驳回',
default => (string) $row['review_status'],
};
return $row;
})
->all();
return ['data' => $rows, 'total' => $total, 'pending' => $pending];
}
/** @param array<int,array<string,mixed>> $warnings */
public function upsertMany(array $warnings): int
{
$count = 0;
foreach ($warnings as $warning) {
$this->upsert($warning);
$count++;
}
return $count;
}
public function find(int $id): ?array
{
if (! $this->ready()) {
return null;
}
$row = $this->connection->table('business_data_warnings')->where('id', $id)->first();
return $row === null ? null : (array) $row;
}
/**
* 人工复审:确认时保持系统按爬虫生成的结果,驳回时记录依据由后续
* 期间合并任务改回流程数据。
*/
public function review(int $id, string $status, string $reviewer, ?string $note, ?string $responsiblePerson = null): bool
{
if (! $this->ready()) {
return false;
}
$update = [
'review_status' => $status,
'reviewer' => $reviewer,
'reviewed_at' => now(),
'review_note' => $note,
'updated_at' => now(),
];
if ($responsiblePerson !== null && trim($responsiblePerson) !== '') {
$update['responsible_person'] = trim($responsiblePerson);
}
return $this->connection->table('business_data_warnings')->where('id', $id)->update($update) > 0;
}
private function nextWarningNumber(): string
{
$date = now()->format('Ymd');
$prefix = 'WARN-' . $date . '-';
$latest = $this->connection->table('business_data_warnings')
->where('warning_number', 'like', $prefix . '%')
->orderByDesc('warning_number')
->value('warning_number');
$sequence = $latest === null ? 0 : (int) substr((string) $latest, -3);
return $prefix . str_pad((string) ($sequence + 1), 3, '0', STR_PAD_LEFT);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
/**
* 内部流转报表读取共享业务镜像中的导入结果。
*
* 页面只读;数据由 InternalFlowWorkbookImporter 按整份工作簿替换导入。
*/
class InternalFlowReportRepository
{
public const TABLE = InternalFlowWorkbookImporter::TABLE;
/** 允许排序的列,防止把用户输入拼进 SQL。 */
public const SORTABLE = [
'company_name',
'tag',
'projected_sales',
'invoiced_to_buyer',
'internal_invoiced',
'required_input_cost',
'receipt_from_supplier',
'receipt_from_suofeiya',
'receipt_from_gujia',
'internal_transfer_1',
'internal_transfer_2',
'internal_transfer_3',
'receipt_total',
'remaining_gap',
'invoicing_ratio',
];
private readonly ConnectionInterface $connection;
public function __construct(GenericBusinessMirrorRepository $mirror)
{
$this->connection = $mirror->connection();
}
public function ready(): bool
{
return $this->connection->getSchemaBuilder()->hasTable(self::TABLE);
}
/**
* @param array{company_name?:string,tag?:string} $filters
* @return array{data: list<array<string,mixed>>, total: int}
*/
public function paginate(int $page, int $perPage, array $filters = [], ?string $sort = null, string $direction = 'asc'): array
{
if (! $this->ready()) {
return ['data' => [], 'total' => 0];
}
$query = $this->connection->table(self::TABLE);
$companyName = trim((string) ($filters['company_name'] ?? ''));
if ($companyName !== '') {
$query->where('company_name', 'like', '%' . addcslashes($companyName, '%_\\') . '%');
}
$tag = trim((string) ($filters['tag'] ?? ''));
if ($tag !== '') {
$query->where('tag', '=', $tag);
}
$total = (clone $query)->count();
$column = in_array($sort, self::SORTABLE, true) ? $sort : null;
$direction = strtolower($direction) === 'desc' ? 'desc' : 'asc';
if ($column !== null) {
$query->orderBy($column, $direction)->orderBy('sort_order');
} else {
$query->orderBy('sort_order')->orderBy('id');
}
$rows = $query
->forPage(max($page, 1), min(max($perPage, 1), 100))
->get()
->map(static fn (object $row): array => (array) $row)
->all();
return ['data' => $rows, 'total' => $total];
}
}

View File

@ -15,7 +15,7 @@ class StoreAccountMirrorRepository
) {}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
public function paginate(int $page, int $perPage): array
public function paginate(int $page, int $perPage, array $filters = []): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
@ -25,9 +25,46 @@ class StoreAccountMirrorRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->mirror->connection()->table($dataset->targetTable());
$columns = [
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
'profit_center' => 'profitCenter', 'account' => 'account', 'bound_phone' => 'boundPhone',
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
];
foreach ($columns as $key => $column) {
$value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191);
if ($value !== '') $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
$accountType = trim((string) ($filters['account_type'] ?? ''));
if ($accountType !== '') $query->where('accountType', '=', $accountType === '主账号' ? 'MAIN' : ($accountType === '子账号' ? 'SUB' : $accountType));
$passwordState = trim((string) ($filters['password_configured'] ?? ''));
if (in_array($passwordState, ['已设置', '未设置', '状态未同步'], true)) {
if ($passwordState === '状态未同步') $query->whereNull('passwordConfigured');
else $query->where('passwordConfigured', $passwordState === '已设置');
}
foreach (['created_at' => 'createdAt', 'updated_at' => 'updatedAt'] as $key => $column) {
foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) {
$date = trim((string) ($filters[$key . $suffix] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) $query->whereDate($column, $operator, $date);
}
}
$total = (clone $query)->count();
$sortColumns = [
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account',
'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone',
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
'created_at' => 'createdAt', 'updated_at' => 'updatedAt',
];
$sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null;
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
if ($sortColumn !== null) {
$query->orderBy($sortColumn, $direction)->orderByDesc('id');
} else {
$query->orderByDesc('id');
}
$rows = $query
->orderByDesc('id')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => [

View File

@ -5,13 +5,15 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
use RuntimeException;
/**
* 将经营分析副本库中的店铺资料与共享日汇总组合为店铺维度报表。
* 将店铺主体期间与共享日汇总组合为店铺维度报表。
*
* 页面不扫描千万级原始账单,也不依赖开发机或镜像中不存在的 SQLite。
* 生命周期区间统一使用 [effective_at, expires_at),避免主体切换日重复统计。
* 期间由 StoreReportPeriodBuilder 按期初数据和爬虫变更生成:变更店铺的老主体
* 失效时间为变更日前一天、新主体从变更日当天生效,收入按实际日期归集,不重复统计。
*/
class StoreReportRepository
{
@ -26,12 +28,14 @@ class StoreReportRepository
/** 主表 rpt.fys_shopinfo 中这些平台不支持变更主体(能否变更=否),其余平台均为是。 */
public const UNCHANGEABLE_PLATFORMS = ['唯品会MP', '淘工厂'];
private readonly ConnectionInterface $connection;
private readonly StoreReportPeriodBuilder $periods;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
$this->periods = new StoreReportPeriodBuilder($database);
}
/**
@ -52,15 +56,32 @@ class StoreReportRepository
foreach (['erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager', 'entity', 'company_type'] as $key) {
$rules[$key] = ['nullable', 'string', 'max:191'];
}
foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) {
$rules[$key] = ['nullable', 'string', 'max:64'];
}
$rules['status'] = ['nullable', 'string', 'in:0,3,6'];
$rules['can_change'] = ['nullable', 'string', 'in:是,否'];
$sortFields = [
'erp_store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager',
'opened_at', 'status', 'can_change', 'entity', 'company_type', 'effective_at',
'invalid_at', 'period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
];
$rules['sort'] = ['nullable', 'string', 'in:' . implode(',', $sortFields)];
$rules['direction'] = ['nullable', 'string', 'in:asc,desc'];
return validator(request()->only(array_keys($rules)), $rules)->validate();
$filters = validator(request()->only(array_keys($rules)), $rules)->validate();
if (! empty($filters['sort'])) {
$filters['_sort'] = $filters['sort'];
$filters['_direction'] = ($filters['direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
}
unset($filters['sort'], $filters['direction']);
return $filters;
}
/**
* @param array<int,array<string,mixed>> $stores
* @param array<string,string> $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、生命周期区间及归属覆盖字段。
* @param array<string,string> $filters 未覆盖字段已在上游 allStores 过滤;这里过滤主体、主体期间及归属覆盖字段。
* @return array<int,array<string,mixed>>
*/
public function rows(array $stores, ?int $reportYear = null, array $filters = []): array
@ -70,17 +91,19 @@ class StoreReportRepository
}
$dailyGeneration = $this->activeGeneration(self::DAILY_DATASET);
$lifecycleGeneration = $this->activeGeneration(self::LIFECYCLES_DATASET);
$names = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['erp_store_name'] ?? '')),
$stores,
))));
$lifecycles = $this->lifecycles($names, $lifecycleGeneration);
$lifecycleSales = $this->lifecycleSales($names, $lifecycleGeneration, $dailyGeneration);
$fallbackSales = $this->fallbackSales(
array_values(array_diff($names, array_keys($lifecycles))),
$dailyGeneration,
);
// 期间由期初数据、系统主体和爬虫变更生成,同一店铺不再展开流程历史重复行。
$periods = $this->periods->build($stores);
$multiPeriod = [];
$singleNames = [];
foreach ($periods as $name => $list) {
if (count($list) > 1) {
$multiPeriod[$name] = $list;
} else {
$singleNames[] = $name;
}
}
$singleSales = $this->singlePeriodSales($singleNames, $dailyGeneration);
$splitSales = $this->periodSales($multiPeriod, $dailyGeneration);
$reportYear = $reportYear ?? (int) now()->format('Y');
$currentMonth = now()->startOfMonth();
$rollingMonths = collect(range(12, 1))->map(
@ -94,17 +117,18 @@ class StoreReportRepository
continue;
}
$periods = $lifecycles[$storeName] ?? [[
$list = $periods[$storeName] ?? [[
'company_name' => $store['company_name'] ?? null,
'effective_at' => self::DEFAULT_EFFECTIVE_AT,
'expires_at' => self::DEFAULT_EXPIRES_AT,
'lifecycle_id' => null,
'expires_at' => null,
'source' => 'fallback',
]];
$split = count($list) > 1;
foreach ($periods as $period) {
$saleData = $period['lifecycle_id'] === null
? ($fallbackSales[$storeName] ?? ['period' => 0.0, 'months' => []])
: ($lifecycleSales[(string) $period['lifecycle_id']] ?? ['period' => 0.0, 'months' => []]);
foreach ($list as $index => $period) {
$saleData = $split
? ($splitSales[$storeName][$index] ?? ['period' => 0.0, 'months' => []])
: ($singleSales[$storeName] ?? ['period' => 0.0, 'months' => []]);
$monthly = [];
$monthlyIncome = [];
for ($month = 1; $month <= 12; $month++) {
@ -123,14 +147,19 @@ class StoreReportRepository
$effectiveAt = $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT);
$expiresAt = $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT);
$status = $this->status($store['store_status'] ?? null);
// 关店和其他非正常状态不展示期间日期,避免把已关闭店铺计入在营期间。
$normal = $status === '开店中';
$row = [
...$store,
'store_id' => $storeName,
'status' => $this->status($store['store_status'] ?? null),
'status' => $status,
'can_change' => $this->canChange($store['platform'] ?? null),
'entity' => $period['company_name'] ?: ($store['company_name'] ?? null),
'effective_at' => $effectiveAt,
'invalid_at' => $expiresAt === self::DEFAULT_EXPIRES_AT ? '存续中' : $expiresAt,
'effective_at' => $normal ? $effectiveAt : '-',
'invalid_at' => $normal
? ($expiresAt === self::DEFAULT_EXPIRES_AT ? '存续中' : $expiresAt)
: '-',
'period_sales' => (float) $saleData['period'],
'rolling_12m_income' => array_intersect($rollingMonths, array_keys($saleData['months'])) === []
? null
@ -139,7 +168,7 @@ class StoreReportRepository
...$monthlyIncome,
];
if (! $this->matchesFilters($row, $expiresAt, $filters)) {
if (! $this->matchesFilters($row, $effectiveAt, $expiresAt, $filters)) {
continue;
}
@ -147,82 +176,105 @@ class StoreReportRepository
}
}
return $this->sortRows($rows, $filters);
}
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
private function sortRows(array $rows, array $filters): array
{
$key = (string) ($filters['_sort'] ?? '');
if ($key === '') {
return $rows;
}
$allowed = [
'erp_store_name' => 'erp_store_name', 'store_id' => 'store_id',
'profit_center' => 'profit_center', 'store_manager' => 'store_manager',
'department_manager' => 'department_manager', 'opened_at' => 'opened_at',
'status' => 'status', 'can_change' => 'can_change', 'entity' => 'entity',
'company_type' => 'company_type', 'effective_at' => 'effective_at',
'invalid_at' => 'invalid_at', 'period_sales' => 'period_sales',
...array_combine(
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12)),
),
];
$column = $allowed[$key] ?? null;
if ($column === null) {
return $rows;
}
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
usort($rows, static function (array $left, array $right) use ($column, $direction): int {
$leftValue = $left[$column] ?? null;
$rightValue = $right[$column] ?? null;
if (is_numeric($leftValue) && is_numeric($rightValue)) {
return ((float) $leftValue <=> (float) $rightValue) * $direction;
}
return strnatcasecmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
});
return $rows;
}
/** @return array<string,array<int,array<string,mixed>>> */
private function lifecycles(array $names, string $generation): array
/**
* 多期间店铺按每个期间的主体生效日、失效日(含当天)拆分日汇总收入。
*
* @param array<string,list<array<string,mixed>>> $periodsByStore
* @return array<string,array<int,array{period:float,months:array<string,float>}>>
*/
private function periodSales(array $periodsByStore, string $dailyGeneration): array
{
$result = [];
foreach ($this->nameChunks($names) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $this->select(
"SELECT id, erp_store_name, company_name, effective_at, expires_at
FROM store_report_lifecycle_rows
WHERE generation = ? AND erp_store_name IN ($placeholders)
ORDER BY erp_store_name, effective_at, id",
[$generation, ...$chunk],
);
foreach ($rows as $row) {
$result[(string) $row['erp_store_name']][] = [
'lifecycle_id' => (int) $row['id'],
'company_name' => $row['company_name'],
'effective_at' => $row['effective_at'],
'expires_at' => $row['expires_at'],
$flat = [];
foreach ($periodsByStore as $name => $list) {
foreach ($list as $index => $period) {
$flat[] = [
'key' => $name . "\x1f" . $index,
'store' => $name,
'effective_at' => $this->date($period['effective_at'] ?? null, self::DEFAULT_EFFECTIVE_AT),
'expires_at' => $this->date($period['expires_at'] ?? null, self::DEFAULT_EXPIRES_AT),
];
}
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function lifecycleSales(array $names, string $lifecycleGeneration, string $dailyGeneration): array
{
if ($names === []) {
return [];
}
$month = $this->connection->getDriverName() === 'sqlite'
? "substr(d.occurred_on, 1, 7)"
: "DATE_FORMAT(d.occurred_on, '%Y-%m')";
$lowerBound = $this->connection->getDriverName() === 'sqlite'
? 'date(d.occurred_on) >= date(COALESCE(l.effective_at, ?))'
: 'd.occurred_on >= COALESCE(l.effective_at, ?)';
$upperBound = $this->connection->getDriverName() === 'sqlite'
? 'date(d.occurred_on) < date(COALESCE(l.expires_at, ?))'
: 'd.occurred_on < COALESCE(l.expires_at, ?)';
$result = [];
foreach ($this->nameChunks($names) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$collected = [];
foreach (array_chunk($flat, 200) as $chunk) {
$selects = [];
$bindings = [];
foreach ($chunk as $row) {
$selects[] = 'SELECT ? AS period_key, ? AS store_name, ? AS effective_at, ? AS expires_at';
array_push($bindings, $row['key'], $row['store'], $row['effective_at'], $row['expires_at']);
}
$rows = $this->select(
"SELECT l.id AS lifecycle_id, {$month} AS bill_month,
"SELECT p.period_key, {$month} AS bill_month,
SUM(COALESCE(d.income, 0)) AS income_total
FROM store_report_lifecycle_rows AS l
FROM (" . implode(' UNION ALL ', $selects) . ") AS p
INNER JOIN store_report_daily_rows AS d
ON d.store_name = l.erp_store_name
ON d.store_name = p.store_name
AND d.generation = ?
AND {$lowerBound}
AND {$upperBound}
WHERE l.generation = ? AND l.erp_store_name IN ($placeholders)
GROUP BY l.id, {$month}",
[
$dailyGeneration,
self::DEFAULT_EFFECTIVE_AT,
self::DEFAULT_EXPIRES_AT,
$lifecycleGeneration,
...$chunk,
],
AND d.occurred_on >= p.effective_at
AND d.occurred_on <= p.expires_at
GROUP BY p.period_key, {$month}",
[...$bindings, $dailyGeneration],
);
$this->collectSales($result, $rows, 'lifecycle_id');
$this->collectSales($collected, $rows, 'period_key');
}
$result = [];
foreach ($collected as $key => $sales) {
[$store, $index] = explode("\x1f", (string) $key, 2) + [1 => '0'];
$result[$store][(int) $index] = $sales;
}
return $result;
}
/** @return array<string,array{period:float,months:array<string,float>}> */
private function fallbackSales(array $names, string $dailyGeneration): array
private function singlePeriodSales(array $names, string $dailyGeneration): array
{
if ($names === []) {
return [];
@ -302,24 +354,30 @@ class StoreReportRepository
}
/**
* 生命周期字段的行级过滤:主体、能否变更、负责人、利润中心精确匹配;生效、失效时间左闭右开。
* 期间字段的行级过滤:主体、能否变更、负责人、利润中心精确匹配;生效、失效时间左闭右开。
*
* 店铺负责人/部门负责人/利润中心展示店铺账号镜像(PostgreSQL)覆盖后的值,
* 与页面筛选保持一致,因此在这里按行值匹配而不是上游镜像 SQL。
*
* @param array<string,mixed> $row
* @param array<string,mixed> $row 展示行(关店店铺的期间字段已显示为“-”)
* @param string $effectiveAt 期间真实生效日,不受状态展示影响
* @param string $expiresAt 期间真实失效日,存续中按 2999-01-01 参与过滤
* @param array<string,string> $filters
*/
private function matchesFilters(array $row, string $expiresAt, array $filters): bool
private function matchesFilters(array $row, string $effectiveAt, string $expiresAt, array $filters): bool
{
foreach (['entity', 'can_change', 'store_manager', 'department_manager', 'profit_center'] as $key) {
foreach (['entity', 'store_manager', 'department_manager', 'profit_center'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected !== '' && trim((string) ($row[$key] ?? '')) !== $expected) {
if ($expected !== '' && ! str_contains(mb_strtolower(trim((string) ($row[$key] ?? ''))), mb_strtolower($expected))) {
return false;
}
}
$canChange = trim((string) ($filters['can_change'] ?? ''));
if ($canChange !== '' && trim((string) ($row['can_change'] ?? '')) !== $canChange) {
return false;
}
foreach (['effective_at' => (string) $row['effective_at'], 'invalid_at' => $expiresAt] as $key => $value) {
foreach (['effective_at' => $effectiveAt, 'invalid_at' => $expiresAt] as $key => $value) {
$from = trim((string) ($filters[$key . '_from'] ?? ''));
$to = trim((string) ($filters[$key . '_to'] ?? ''));
if ($from !== '' && strcmp($value, $from) < 0) {
@ -330,6 +388,13 @@ class StoreReportRepository
}
}
foreach (['period_sales', ...array_map(static fn (int $month): string => sprintf('sales_%02d', $month), range(1, 12))] as $key) {
$needle = trim((string) ($filters[$key] ?? ''));
if ($needle !== '' && ! str_contains((string) ($row[$key] ?? ''), $needle)) {
return false;
}
}
return true;
}

View File

@ -0,0 +1,246 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\DataWarningMirrorRepository;
use Throwable;
/**
* 店铺主体、商标爬虫数据的每日质量检查。
*
* 检查项对应「店铺主体商标数据逻辑配置」页面的 R7、R8、R9:
* - 爬虫更新检测;
* - 主体名称一致性;
* - 关联完整性(含同日主体冲突、商标号无法匹配商标基础资料)。
*/
class CrawlerDataGuard
{
public const TYPE_STALE = '爬虫未更新';
public const TYPE_SUBJECT_NAME = '主体名称不一致';
public const TYPE_SUBJECT_CONFLICT = '同日主体冲突';
public const TYPE_TRADEMARK_MISSING = '关联缺失';
/** @var array<string,string> */
private const RESPONSIBLE_BY_TYPE = [
self::TYPE_STALE => '数据同步责任人',
self::TYPE_SUBJECT_NAME => '主体资料责任人',
self::TYPE_SUBJECT_CONFLICT => '主体变更责任人',
self::TYPE_TRADEMARK_MISSING => '商标资料责任人',
];
/** @return array<string,int> 各类型检查数量统计 */
public function check(): array
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$warnings = $this->staleWarnings($connection);
$warnings = [...$warnings, ...$this->subjectNameWarnings($connection)];
$warnings = [...$warnings, ...$this->trademarkWarnings($connection)];
$repository = app(DataWarningMirrorRepository::class);
foreach ($warnings as $warning) {
$warning['responsible_person'] ??= $this->responsiblePerson((string) $warning['warning_type']);
$repository->upsert($warning);
}
$summary = [];
foreach ($warnings as $warning) {
$summary[$warning['warning_type']] = ($summary[$warning['warning_type']] ?? 0) + 1;
}
return $summary;
}
/** 供期间合并命令复用同一套责任人指派口径。 */
public function responsiblePersonFor(string $type): ?string
{
return $this->responsiblePerson($type);
}
/** @return list<array<string,mixed>> */
private function staleWarnings($connection): array
{
$sources = [
['table' => 'ops_crawl_shop_company', 'label' => '店铺对应主体'],
['table' => 'ops_crawl_shop_trademark', 'label' => '店铺对应商标'],
];
$warnings = [];
foreach ($sources as $source) {
if (! $connection->getSchemaBuilder()->hasTable($source['table'])) {
continue;
}
$row = $connection->table($source['table'])
->selectRaw('MAX(crawl_date) AS latest_date, MAX(update_time) AS latest_update, COUNT(*) AS row_count')
->first();
$latestDate = trim((string) ($row->latest_date ?? ''));
if ($latestDate === '') {
continue;
}
$days = now()->startOfDay()->diffInDays(Carbon::parse($latestDate)->startOfDay());
if (! DataLogicSettings::staleWarningEnabled() || $days < DataLogicSettings::staleAfterDays()) {
continue;
}
$warnings[] = [
'warning_key' => 'crawler-stale:' . $source['table'],
'warning_type' => self::TYPE_STALE,
'risk_level' => 'high',
'related_object' => $source['label'],
'risk_description' => sprintf('%s 已 %d 天没有新抓取数据,最新抓取日期 %s。', $source['label'], $days, $latestDate),
'evidence' => sprintf('最新 crawl_date=%s,最新 update_time=%s,累计 %d 行。', $latestDate, (string) ($row->latest_update ?? ''), (int) ($row->row_count ?? 0)),
'impact' => sprintf('%d 天未更新', $days),
'trigger_rule' => 'R7',
'due_at' => now()->addDay(),
];
}
return $warnings;
}
/** @return list<array<string,mixed>> */
private function subjectNameWarnings($connection): array
{
if (! $connection->getSchemaBuilder()->hasTable('ops_crawl_shop_company')) {
return [];
}
$companies = collect($connection->table('ops_fys_company_info')
->select(['unifiedSocialCreditCode', 'businessLicenseName'])
->get())
->keyBy(fn (object $row): string => trim((string) $row->unifiedSocialCreditCode));
$seen = [];
$warnings = [];
foreach ($this->latestCompanyRows($connection) as $row) {
$taxNo = trim((string) ($row->tax_no ?? ''));
$crawlName = trim((string) ($row->company_name ?? ''));
if ($taxNo === '' || $crawlName === '' || ! $companies->has($taxNo)) {
continue;
}
$officialName = trim((string) $companies->get($taxNo)->businessLicenseName);
if ($officialName === '' || $officialName === $crawlName) {
continue;
}
$key = $taxNo . '|' . $crawlName;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$warnings[] = [
'warning_key' => 'subject-name:' . md5($key),
'warning_type' => self::TYPE_SUBJECT_NAME,
'risk_level' => 'low',
'related_object' => (string) ($row->shop_name ?? ''),
'risk_description' => sprintf('店铺「%s」爬虫主体名与工商主体名不一致。', (string) $row->shop_name),
'evidence' => sprintf('tax_no=%s,爬虫=%s,工商=%s', $taxNo, $crawlName, $officialName),
'impact' => '名称核对',
'trigger_rule' => 'R8',
'due_at' => now()->addDays(7),
];
}
return $warnings;
}
/** @return list<array<string,mixed>> */
private function trademarkWarnings($connection): array
{
if (! $connection->getSchemaBuilder()->hasTable('ops_crawl_shop_trademark')) {
return [];
}
$known = collect($connection->table('ops_fys_trademark_info')
->where('isDelete', 0)
->pluck('trademarkNumber'))
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->flip();
$seen = [];
$warnings = [];
foreach ($this->latestTrademarkRows($connection) as $row) {
$number = trim((string) ($row->trademark_no ?? ''));
if ($number === '' || $known->has($number) || isset($seen[$number])) {
continue;
}
$seen[$number] = true;
$warnings[] = [
'warning_key' => 'trademark-missing:' . md5($number),
'warning_type' => self::TYPE_TRADEMARK_MISSING,
'risk_level' => 'mid',
'related_object' => $number,
'risk_description' => sprintf('爬虫商标号 %s 在商标基础资料中查不到对应记录。', $number),
'evidence' => sprintf('店铺=%s,商标名称=%s', (string) ($row->shop_name ?? ''), (string) ($row->trademark_name ?? '')),
'impact' => '商标资料缺失',
'trigger_rule' => 'R9',
'due_at' => now()->addDays(3),
];
}
return $warnings;
}
/** @return list<object> */
private function latestCompanyRows($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();
}
/** @return list<object> */
private function latestTrademarkRows($connection): array
{
return $connection->table('ops_crawl_shop_trademark')
->orderBy('shop_name')
->orderByDesc('crawl_date')
->orderByDesc('id')
->get()
->groupBy(static fn (object $row): string => (string) $row->shop_name)
->flatMap(static fn ($rows) => $rows->groupBy(static fn (object $row): string => trim((string) $row->trademark_no))->map(static fn ($same): object => $same->first()))
->values()
->all();
}
private function responsiblePerson(string $type): ?string
{
$maintained = DataLogicSettings::ownerFor($type);
if ($maintained !== '') {
return $maintained;
}
$configured = trim((string) config('upstream_mirrors.crawler_warnings.responsible.' . $type, ''));
if ($configured !== '') {
return $configured;
}
try {
$user = User::query()->where('enabled', true)->orderBy('id')->first();
return $user !== null ? (string) $user->name : (self::RESPONSIBLE_BY_TYPE[$type] ?? '数据责任人');
} catch (Throwable $exception) {
Log::warning('预警责任人回退到默认标签。', ['exception' => $exception::class]);
return self::RESPONSIBLE_BY_TYPE[$type] ?? '数据责任人';
}
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Modules\EcommerceFinance\Services\CrawlerDataGuard;
/**
* 「店铺主体商标数据逻辑配置」的可维护参数。
*
* 页面展示的规则目录仍由 ShopEntityTrademarkLogic 维护;这里保存负责人
* 可以调整的运行参数,读取时以设置值优先、代码默认值兜底。
*/
class DataLogicSettings
{
private const PREFIX = 'business_data_logic';
/** 扁平键:设置组件会按点号展开存储,不能嵌套数组。 */
public const KEYS = [
'crawl_priority' => self::PREFIX . '.crawl_priority',
'trademark_separator' => self::PREFIX . '.trademark_separator',
'same_day_conflict_policy' => self::PREFIX . '.same_day_conflict_policy',
'missing_trademark_action' => self::PREFIX . '.missing_trademark_action',
'stale_warning_enabled' => self::PREFIX . '.stale_warning_enabled',
'stale_after_days' => self::PREFIX . '.stale_after_days',
'owner_stale' => self::PREFIX . '.owner.stale',
'owner_subject_name' => self::PREFIX . '.owner.subject_name',
'owner_subject_conflict' => self::PREFIX . '.owner.subject_conflict',
'owner_trademark' => self::PREFIX . '.owner.trademark',
];
/**
* @return array<string,mixed>
*/
public static function defaults(): array
{
return [
'crawl_priority' => '1',
'trademark_separator' => '、',
'same_day_conflict_policy' => 'crawl_wins',
'missing_trademark_action' => 'warn_keep',
'stale_warning_enabled' => '1',
'stale_after_days' => '1',
'owner_stale' => '',
'owner_subject_name' => '',
'owner_subject_conflict' => '',
'owner_trademark' => '',
];
}
/**
* @return array<string,mixed>
*/
public static function current(): array
{
$values = [];
foreach (self::KEYS as $key => $settingKey) {
$value = setting($settingKey, null);
$values[$key] = is_array($value) ? implode('', $value) : (string) ($value ?? '');
}
return array_merge(self::defaults(), array_filter($values, static fn (string $value): bool => $value !== ''));
}
public static function value(string $key, mixed $default = null): mixed
{
$current = self::current();
return $current[$key] ?? ($default ?? (self::defaults()[$key] ?? null));
}
/** @param array<string,mixed> $values */
public static function save(array $values): void
{
$payload = [];
foreach (self::defaults() as $key => $default) {
if (! array_key_exists($key, $values) || ! isset(self::KEYS[$key])) {
continue;
}
$payload[self::KEYS[$key]] = mb_substr(trim((string) $values[$key]), 0, 191);
}
if ($payload !== []) {
setting($payload)->save();
}
}
public static function separator(): string
{
$value = trim((string) self::value('trademark_separator', '、'));
return $value === '' ? '、' : $value;
}
public static function crawlWinsOnConflict(): bool
{
return (string) self::value('same_day_conflict_policy', 'crawl_wins') === 'crawl_wins';
}
public static function staleWarningEnabled(): bool
{
return (string) self::value('stale_warning_enabled', '1') === '1';
}
public static function staleAfterDays(): int
{
return max((int) self::value('stale_after_days', 1), 1);
}
public static function ownerFor(string $warningType): string
{
$map = [
CrawlerDataGuard::TYPE_STALE => 'owner_stale',
CrawlerDataGuard::TYPE_SUBJECT_NAME => 'owner_subject_name',
CrawlerDataGuard::TYPE_SUBJECT_CONFLICT => 'owner_subject_conflict',
CrawlerDataGuard::TYPE_TRADEMARK_MISSING => 'owner_trademark',
];
$key = $map[$warningType] ?? null;
return $key === null ? '' : trim((string) self::value($key, ''));
}
/**
* 控制台命令没有公司上下文,设置组件默认按 company_id = null 读取。
* 这里切到第一个公司,保证命令和网页读到同一份配置。
*/
public static function bootCompanyContext(): void
{
try {
$company = \App\Models\Common\Company::query()->orderBy('id')->first();
$company?->makeCurrent();
} catch (\Throwable) {
// 读取失败时保持原上下文,使用代码默认值。
}
}
}

View File

@ -0,0 +1,326 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
/**
* 「一般纳税人内部流转」工作簿导入器。
*
* 源表使用三行合并表头(抬头 / 标签 / 预计9月销量 / 开票 / 所需进项成本 /
* 收票 / 剩余缺口 / 开票比例),导入时按表头标签匹配列,不依赖列位置;
* 合计行与空行不进入明细表。
*/
class InternalFlowWorkbookImporter
{
public const TABLE = 'business_internal_flow_records';
public const SHEET_TITLE = '一般纳税人内部流转';
/** 页面表头契约:与 Excel 表头顺序一致。 */
public const COLUMNS = [
['field' => 'company_name', 'label' => '抬头', 'type' => 'text'],
['field' => 'tag', 'label' => '标签', 'type' => 'text'],
['field' => 'projected_sales', 'label' => '预计9月销量', 'type' => 'amount'],
['field' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'type' => 'amount'],
['field' => 'internal_invoiced', 'label' => '开票-内部开票', 'type' => 'amount'],
['field' => 'required_input_cost', 'label' => '所需进项成本', 'type' => 'amount'],
['field' => 'receipt_from_supplier', 'label' => '收票-供应商', 'type' => 'amount'],
['field' => 'receipt_from_suofeiya', 'label' => '收票-索菲亚', 'type' => 'amount'],
['field' => 'receipt_from_gujia', 'label' => '收票-顾家', 'type' => 'amount'],
['field' => 'internal_transfer_1', 'label' => '收票-内部流转(来源)1', 'type' => 'amount'],
['field' => 'internal_transfer_2', 'label' => '收票-内部流转(来源)2', 'type' => 'amount'],
['field' => 'internal_transfer_3', 'label' => '收票-内部流转(来源)3', 'type' => 'amount'],
['field' => 'receipt_total', 'label' => '收票-合计', 'type' => 'amount'],
['field' => 'remaining_gap', 'label' => '剩余缺口', 'type' => 'amount'],
['field' => 'invoicing_ratio', 'label' => '开票比例', 'type' => 'text'],
];
private const HEADER_ROWS = 3;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{imported:int,skipped:int,source_file:string}
*/
public function import(string $path): array
{
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new RuntimeException('内部流转工作簿不存在或不可读:' . $path);
}
if (! Schema::connection($this->connection->getName())->hasTable(self::TABLE)) {
throw new RuntimeException('内部流转数据表尚未创建,请先执行数据库迁移。');
}
[$rows, $skipped] = $this->readRows($realPath);
if ($rows === []) {
throw new RuntimeException('表格中没有可导入的数据行,请检查“' . self::SHEET_TITLE . '”工作表。');
}
$now = Carbon::now();
$sourceFile = basename($realPath);
$this->connection->transaction(function () use ($rows, $now, $sourceFile): void {
// 该报表是一份整体替换的月度数据集,重新导入时先清空上一版。
$this->connection->table(self::TABLE)->delete();
foreach (array_chunk($rows, 200) as $chunk) {
$this->connection->table(self::TABLE)->insert(array_map(
static fn (array $row): array => $row + [
'source_file' => $sourceFile,
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
$chunk,
));
}
});
return [
'imported' => count($rows),
'skipped' => $skipped,
'source_file' => $sourceFile,
];
}
/**
* @return array{0: list<array<string,mixed>>, 1: int}
*/
private function readRows(string $path): array
{
$spreadsheet = IOFactory::load($path);
$sheet = $this->resolveSheet($spreadsheet->getAllSheets());
$grid = $sheet->toArray(null, true, false, false);
$this->expandMergedCells($grid, $sheet);
$headerRow = $this->resolveHeaderRow($grid);
$columnMap = $this->resolveColumnMap($grid, $headerRow);
$missing = array_values(array_diff(
array_column(self::COLUMNS, 'field'),
array_values($columnMap),
));
if ($missing !== []) {
$labels = implode('、', array_map(
static fn (string $field): string => (string) (collect(self::COLUMNS)->firstWhere('field', $field)['label'] ?? $field),
$missing,
));
throw new RuntimeException('内部流转表头缺少列:' . $labels . '。');
}
$types = array_column(self::COLUMNS, 'type', 'field');
$rows = [];
$skipped = 0;
$sortOrder = 0;
foreach (array_slice($grid, $headerRow + self::HEADER_ROWS) as $line) {
$values = [];
foreach ($columnMap as $columnIndex => $field) {
$values[$field] = $this->normalizeValue($line[$columnIndex] ?? null, $types[$field] ?? 'text');
}
$title = trim((string) ($values['company_name'] ?? ''));
if ($title === '' || $title === '合计') {
if ($title !== '' || collect($values)->filter(static fn ($value): bool => $value !== null && $value !== '')->isNotEmpty()) {
$skipped++;
}
continue;
}
$values['company_name'] = $title;
$values['sort_order'] = ++$sortOrder;
$rows[] = $values;
}
return [$rows, $skipped];
}
/**
* @param list<Worksheet> $sheets
*/
private function resolveSheet(array $sheets): Worksheet
{
foreach ($sheets as $sheet) {
if ($sheet->getTitle() === self::SHEET_TITLE) {
return $sheet;
}
}
throw new RuntimeException('未找到“' . self::SHEET_TITLE . '”工作表。');
}
/**
* @param list<list<mixed>> $grid
*/
private function expandMergedCells(array &$grid, Worksheet $sheet): void
{
foreach ($sheet->getMergeCells() as $range) {
try {
[[$startColumn, $startRow], [$endColumn, $endRow]] = Coordinate::rangeBoundaries($range);
} catch (\Throwable) {
continue;
}
$value = $grid[$startRow - 1][$startColumn - 1] ?? null;
for ($row = $startRow; $row <= $endRow; $row++) {
for ($column = $startColumn; $column <= $endColumn; $column++) {
$grid[$row - 1][$column - 1] = $value;
}
}
}
}
/**
* @param list<list<mixed>> $grid
*/
private function resolveHeaderRow(array $grid): int
{
foreach (array_slice($grid, 0, 10, true) as $index => $line) {
if (collect($line)->contains(fn ($value): bool => $this->normalizeHeader($value) === '抬头')) {
return (int) $index;
}
}
throw new RuntimeException('未找到以“抬头”开头的工作簿表头。');
}
/**
* 按三行表头的标签路径匹配目标字段,允许列位置变化。
*
* @param list<list<mixed>> $grid
* @return array<int,string> 列下标 => 字段名
*/
private function resolveColumnMap(array $grid, int $headerRow): array
{
$map = [];
$columnCount = 0;
foreach (array_slice($grid, $headerRow, self::HEADER_ROWS) as $line) {
$columnCount = max($columnCount, count($line));
}
for ($column = 0; $column < $columnCount; $column++) {
$path = [];
for ($row = $headerRow; $row < $headerRow + self::HEADER_ROWS; $row++) {
$value = $this->normalizeHeader($grid[$row][$column] ?? null);
if ($value !== '') {
$path[] = $value;
}
}
$field = $this->matchField($path);
if ($field !== null) {
$map[$column] = $field;
}
}
return $map;
}
/**
* @param list<string> $path
*/
private function matchField(array $path): ?string
{
if ($path === []) {
return null;
}
$leaf = (string) end($path);
return match (true) {
$leaf === '抬头' => 'company_name',
$leaf === '标签' => 'tag',
preg_match('/^预计\d+月销量$/u', $leaf) === 1 => 'projected_sales',
$leaf === '给买家开票' => 'invoiced_to_buyer',
$leaf === '内部开票' => 'internal_invoiced',
$leaf === '所需进项成本' => 'required_input_cost',
$leaf === '供应商' => 'receipt_from_supplier',
$leaf === '索菲亚' => 'receipt_from_suofeiya',
$leaf === '顾家' => 'receipt_from_gujia',
$leaf === '合计' => 'receipt_total',
$leaf === '剩余缺口' => 'remaining_gap',
$leaf === '开票比例' => 'invoicing_ratio',
in_array($leaf, ['1', '2', '3'], true) && in_array('内部流转(来源)', $path, true) => 'internal_transfer_' . $leaf,
default => null,
};
}
private function normalizeHeader(mixed $value): string
{
if ($value === null || is_bool($value) || is_array($value)) {
return '';
}
$text = trim((string) $value);
$text = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], $text);
return $text;
}
private function normalizeValue(mixed $value, string $type): ?string
{
if ($value === null) {
return null;
}
if ($type === 'text') {
if (is_float($value) && floor($value) === $value) {
return (string) (int) $value;
}
$text = trim((string) $value);
return $text === '' ? null : $text;
}
if (is_int($value) || is_float($value)) {
return $this->decimal($value);
}
$text = trim((string) $value);
if ($text === '' || in_array($text, ['/', '—', '-', 'N/A'], true)) {
return null;
}
$normalized = str_replace([',', ','], '', $text);
return is_numeric($normalized) ? $this->decimal((float) $normalized) : null;
}
private function decimal(float $value): string
{
$formatted = rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.');
return $formatted === '' || $formatted === '-' ? '0' : $formatted;
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 「店铺主体商标数据逻辑配置」页面展示的规则目录。
*
* 这里集中维护运行逻辑的事实描述,页面只负责渲染;新增或调整规则时
* 同步修改本目录和实际实现,避免页面说明与系统行为不一致。
*/
class ShopEntityTrademarkLogic
{
public const CRAWL_COMPANY_DATASET = 'operations_replica.rpt.crawl_shop_company';
public const CRAWL_TRADEMARK_DATASET = 'operations_replica.rpt.crawl_shop_trademark';
/**
* 页面规则目录:类别 => 规则列表。
*
* @return array<string, list<array<string, string>>>
*/
public static function rules(): array
{
return [
'数据来源与优先级' => [
[
'code' => 'R1',
'object' => '店铺主体、店铺商标',
'logic' => '优先使用每天从店铺后台抓取的数据;爬虫没有覆盖的店铺回退到财务系统流程数据(统一社会信用代码关联主体、商标号关联商标)。',
'source' => 'crawl_shop_company / crawl_shop_trademark(爬虫)',
'priority' => '爬虫优先,流程数据兜底',
],
[
'code' => 'R2',
'object' => '店铺匹配与主体归一键',
'logic' => '爬虫 shop_name 与店铺基础信息 erpShopName 精确匹配;主体按 tax_no 解析工商主体名称后再关联,保证与主体维度经营报表的合并键一致。',
'source' => 'crawl_shop_company + fys_shopInfo + fys_companyInfo',
'priority' => '精确匹配,不做模糊匹配',
],
[
'code' => 'R3',
'object' => '多商标展示',
'logic' => '同一店铺存在多个商标时,商标名称和商标号都按同一顺序用「、」串联,两个字段一一对应。',
'source' => 'crawl_shop_trademark',
'priority' => '名称与号码同源、同顺序',
],
[
'code' => 'R4',
'object' => '商标号展示与查询',
'logic' => '商标号和商标名同时切换数据来源,展示、筛选、排序、导出四处使用同一口径。',
'source' => 'crawl_shop_trademark 优先,原商标号链路兜底',
'priority' => '同源同口径',
],
],
'期间切割与冲突处理' => [
[
'code' => 'R5',
'object' => '店铺主体生效期间',
'logic' => '爬虫最新一次抓取的主体与当前存续期间不一致时,以抓到变化的 crawl_date 作为新主体生效日;老主体到同一天 00:00 结束,收入按左闭右开区间归集。',
'source' => 'crawl_shop_company + store_report_lifecycle_rows',
'priority' => '爬虫驱动期间新增',
],
[
'code' => 'R6',
'object' => '同日主体冲突',
'logic' => '流程表与爬虫同一天给出不同主体时,以爬虫为准直接调整期间,同时生成预警并指派责任人人工复审;复审驳回后改回流程主体并重算该店铺期间和收入归属。',
'source' => 'crawl_shop_company + fys_shopInfo_flow',
'priority' => '爬虫强制覆盖 + 人工复审',
],
],
'数据质量监控' => [
[
'code' => 'R7',
'object' => '爬虫更新检测',
'logic' => '最新抓取日期距今超过 1 天,或数据更新时间停止前进时生成预警。',
'source' => 'crawl_shop_company / crawl_shop_trademark 的 crawl_date、update_time',
'priority' => '每天检查',
],
[
'code' => 'R8',
'object' => '主体名称一致性',
'logic' => '爬虫公司名与 tax_no 对应工商主体名不一致时生成预警,保留人工核对。',
'source' => 'crawl_shop_company + fys_companyInfo',
'priority' => '每天检查',
],
[
'code' => 'R9',
'object' => '关联完整性',
'logic' => '主体期间重复存续、期间断档或重叠,以及爬虫主体/商标在财务系统查不到对应资料时生成预警。',
'source' => 'store_report_lifecycle_rows + fys_companyInfo + fys_TrademarkInfo',
'priority' => '每天检查',
],
],
];
}
/**
* 扁平规则行,供页面表格渲染。
*
* @return list<array<string, string>>
*/
public static function rows(): array
{
$rows = [];
foreach (self::rules() as $category => $rules) {
foreach ($rules as $rule) {
$rows[] = [
'rule_code' => $rule['code'],
'rule_category' => $category,
'rule_object' => $rule['object'],
'rule_logic' => $rule['logic'],
'data_source' => $rule['source'],
'priority_note' => $rule['priority'],
];
}
}
return $rows;
}
}

View File

@ -0,0 +1,234 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 「店铺维度报表逻辑配置」里可维护的期初主体数据。
*
* 首次读取时把 config/store_report_initial_periods.php 的内容复制成设置值,
* 之后页面的新增、修改、删除都写设置,不再依赖代码文件。
*/
class StoreReportInitialPeriods
{
private const PREFIX = 'store_report.initial_periods';
private const EFFECTIVE_AT_KEY = self::PREFIX . '.effective_at';
public const DEFAULT_EFFECTIVE_AT = '2026-07-01';
/** @return array{initial_effective_at:string,shops:array<string,array{previous_company:string,company:string,changed_at:string}>} */
public static function current(): array
{
if (! self::seeded()) {
return self::fromConfig();
}
$shops = [];
foreach (self::storedShopNames() as $name) {
$rule = self::storedRule($name);
if ($rule !== null) {
$shops[$name] = $rule;
}
}
return [
'initial_effective_at' => self::cleanDate(setting(self::EFFECTIVE_AT_KEY, null)) ?? self::DEFAULT_EFFECTIVE_AT,
'shops' => self::normalizeShops($shops),
];
}
public static function effectiveAt(): string
{
return self::current()['initial_effective_at'];
}
/** @return array<string,array{previous_company:string,company:string,changed_at:string}> */
public static function shops(): array
{
return self::current()['shops'];
}
public static function seeded(): bool
{
return setting(self::signatureKey(), null) !== null;
}
/** 把代码文件里的期初数据固化成可编辑的设置值。 */
public static function seedFromConfig(): void
{
if (self::seeded()) {
return;
}
$config = self::fromConfig();
$payload = [self::EFFECTIVE_AT_KEY => $config['initial_effective_at']];
foreach ($config['shops'] as $name => $rule) {
$payload += self::rulePayload($name, $rule);
}
$payload[self::shopListKey()] = implode("\n", array_keys($config['shops']));
$payload[self::signatureKey()] = (string) count($config['shops']);
setting($payload)->save();
}
/** @param array<string,mixed> $shops */
public static function saveShops(array $shops, ?string $effectiveAt = null): void
{
$current = self::current();
$normalized = self::normalizeShops($shops);
$payload = [
self::EFFECTIVE_AT_KEY => self::cleanDate($effectiveAt) ?? $current['initial_effective_at'],
self::shopListKey() => implode("\n", array_keys($normalized)),
self::signatureKey() => (string) count($normalized),
];
foreach ($normalized as $name => $rule) {
$payload += self::rulePayload($name, $rule);
}
setting($payload)->save();
// 删除本次保存后不再存在的店铺键。
foreach ($current['shops'] as $name => $rule) {
if (isset($normalized[$name])) {
continue;
}
self::forgetKeys($name);
}
}
/** @return array{previous_company:string,company:string,changed_at:string}|null */
public static function shop(string $storeName): ?array
{
return self::shops()[trim($storeName)] ?? null;
}
/** @param array{previous_company:string,company:string,changed_at:string} $rule */
public static function putShop(string $storeName, array $rule): void
{
$shops = self::shops();
$shops[trim($storeName)] = $rule;
self::saveShops($shops);
}
public static function forgetShop(string $storeName): void
{
$shops = self::shops();
$name = trim($storeName);
unset($shops[$name]);
self::saveShops($shops);
self::forgetKeys($name);
}
/**
* @return array{initial_effective_at:string,shops:array<string,array{previous_company:string,company:string,changed_at:string}>}
*/
private static function fromConfig(): array
{
return [
'initial_effective_at' => self::cleanDate(config('store_report_initial_periods.initial_effective_at'))
?? self::DEFAULT_EFFECTIVE_AT,
'shops' => self::normalizeShops((array) config('store_report_initial_periods.shops', [])),
];
}
/** @param array<string,mixed> $shops */
private static function normalizeShops(array $shops): array
{
$clean = [];
foreach ($shops as $storeName => $rule) {
$name = trim((string) $storeName);
if ($name === '' || ! is_array($rule)) {
continue;
}
$changedAt = self::cleanDate($rule['changed_at'] ?? null);
if ($changedAt === null) {
continue;
}
$clean[$name] = [
'previous_company' => trim((string) ($rule['previous_company'] ?? '')),
'company' => trim((string) ($rule['company'] ?? '')),
'changed_at' => $changedAt,
];
}
uasort($clean, static fn (array $left, array $right): int => strcmp($left['changed_at'], $right['changed_at']));
return $clean;
}
private static function cleanDate(mixed $value): ?string
{
$value = trim((string) ($value ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value) !== 1) {
return null;
}
return $value;
}
private static function shopKey(string $storeName): string
{
return sha1(mb_strtolower(trim($storeName)));
}
private static function shopListKey(): string
{
return self::PREFIX . '.shop_list';
}
private static function signatureKey(): string
{
return self::PREFIX . '.seeded';
}
private static function ruleKey(string $storeName, string $field): string
{
return self::PREFIX . '.shop.' . self::shopKey($storeName) . '.' . $field;
}
/** @return list<string> */
private static function storedShopNames(): array
{
$list = setting(self::shopListKey(), '');
$list = is_array($list) ? implode("\n", $list) : (string) $list;
return array_values(array_filter(array_map('trim', explode("\n", $list))));
}
/** @return array{previous_company:string,company:string,changed_at:string}|null */
private static function storedRule(string $storeName): ?array
{
$changedAt = self::cleanDate(setting(self::ruleKey($storeName, 'changed_at'), null));
if ($changedAt === null) {
return null;
}
return [
'previous_company' => trim((string) setting(self::ruleKey($storeName, 'previous_company'), '')),
'company' => trim((string) setting(self::ruleKey($storeName, 'company'), '')),
'changed_at' => $changedAt,
];
}
/**
* @param array{previous_company:string,company:string,changed_at:string} $rule
* @return array<string,string>
*/
private static function rulePayload(string $storeName, array $rule): array
{
return [
self::ruleKey($storeName, 'previous_company') => $rule['previous_company'],
self::ruleKey($storeName, 'company') => $rule['company'],
self::ruleKey($storeName, 'changed_at') => $rule['changed_at'],
self::ruleKey($storeName, 'name') => trim($storeName),
];
}
private static function forgetKeys(string $storeName): void
{
foreach (['previous_company', 'company', 'changed_at', 'name'] as $field) {
setting()->forget(self::ruleKey($storeName, $field));
}
setting()->save();
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 「店铺维度报表逻辑配置」页面展示的规则目录。
*
* 页面只负责渲染,运行逻辑以 StoreReportPeriodBuilder、StoreReportRepository
* 和 StoreReportMirrorSynchronizer 的实际实现为准;调整规则时同步修改两处。
*/
class StoreReportLogic
{
public const INITIAL_PERIODS_CONFIG = 'config/store_report_initial_periods.php';
/**
* @return array<string, list<array<string, string>>>
*/
public static function rules(): array
{
return [
'主体期间来源' => [
[
'code' => 'R1',
'object' => '期初数据(已变更店铺)',
'logic' => '变更店铺由负责人提供的期初数据锁定:老主体 2026-07-01 生效、失效时间为店铺变更到新主体的前一天;新主体从变更当天生效,失效时间显示「存续中」。',
'source' => '店铺变更初始数据(' . self::INITIAL_PERIODS_CONFIG . ')',
'priority' => '期初数据优先,覆盖流程历史',
],
[
'code' => 'R2',
'object' => '其他店铺的期初期间',
'logic' => '未列入期初数据的店铺自 2026-07-01 起按财务系统登记主体生成一条期间,失效时间显示「存续中」;系统主体缺失时退回店铺基础信息页展示的主体。',
'source' => 'ops_fys_shop_info + ops_fys_company_info',
'priority' => '一条期间,从 2026-07-01 起',
],
[
'code' => 'R3',
'object' => '爬虫抓到的新主体',
'logic' => '爬虫主体先按统一社会信用代码归一到工商主体名称;与当前期间最后一个主体不同时生成新期间,生效时间为首次抓到日期,老期间失效时间改为前一天,新期间失效时间显示「存续中」。',
'source' => 'crawl_shop_company + store_report_shop_period_changes',
'priority' => '爬虫驱动新增期间,日期一经记录不再前移',
],
[
'code' => 'R4',
'object' => '主体名称笔误',
'logic' => '爬虫名称与系统名称不同、但税号归一到同一工商主体时不算主体变更,只保留名称不一致预警,不新增期间。',
'source' => 'crawl_shop_company + fys_companyInfo',
'priority' => '同税号视为同一主体',
],
],
'期间切割与展示' => [
[
'code' => 'R5',
'object' => '一行一期间',
'logic' => '同一店铺的每个主体期间在报表中只显示一行,不再展开流程表历史记录;期间按日期严格衔接,不重复、不留空档。',
'source' => 'StoreReportPeriodBuilder',
'priority' => '去重后按主体期间展示',
],
[
'code' => 'R6',
'object' => '流程历史行的处理',
'logic' => '报表期间不再直接读取 fys_shopInfo_flow 的原始行(历史重复行来源);流程数据仅作备查和同日冲突预警,不参与期间生成。',
'source' => 'store_report_lifecycle_rows(备查)',
'priority' => '不作为期间来源',
],
[
'code' => 'R7',
'object' => '关店与非正常状态',
'logic' => '店铺状态为关店、关店中或其他非正常状态时,生效时间和失效时间都显示「-」;只有正常在营店铺展示期间日期。',
'source' => 'ops_fys_shop_info.shopState',
'priority' => '状态优先于期间展示',
],
[
'code' => 'R8',
'object' => '收入归属',
'logic' => '期间销量按日汇总的实际发生日期归属:变更日当天归新主体,老主体统计到变更日前一天,主体切换不重复统计、不少计收入。',
'source' => 'store_report_daily_rows + 主体期间',
'priority' => '按日归属,区间不重叠',
],
],
];
}
}

View File

@ -24,7 +24,7 @@ class StoreReportMirrorSynchronizer
);
}
/** @return array{lifecycles:array<string,mixed>,bills:array<string,mixed>,daily:array<string,mixed>} */
/** @return array{lifecycles:array<string,mixed>,period_changes:array<string,mixed>,bills:array<string,mixed>,daily:array<string,mixed>} */
public function sync(
?int $batchSize = null,
?int $maxBatches = null,
@ -47,6 +47,7 @@ class StoreReportMirrorSynchronizer
fn (int $cursor, int $limit): array => $this->upstream->lifecycleBatch($cursor, $limit),
fn (array $rows, string $generation): array => $this->mapLifecycles($rows, $generation),
);
$periodChanges = $this->syncShopPeriodChanges();
$bills = $this->syncDataset(
StoreReportRepository::BILLS_DATASET,
'store_report_bill_rows',
@ -58,11 +59,98 @@ class StoreReportMirrorSynchronizer
return [
'lifecycles' => $lifecycles,
'period_changes' => $periodChanges,
'bills' => $bills,
'daily' => $this->syncDailySummary($dailyBatchSize, $dailyMaxBatches),
];
}
/**
* 把爬虫首次抓到的新主体写入店铺主体期间变更记录。
*
* 爬虫主体名称先按税号归一到工商主体资料,避免同一主体的名称笔误被当成变更;
* 只有与当前期间最后一个主体不同才新增记录,重复执行不会重复插入,
* 记录下来的首次抓到日期会固定新主体的生效时间,后续爬虫更新不再前移。
*
* @return array{status:string,checked:int,created:int,reason?:string}
*/
private function syncShopPeriodChanges(): array
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('ops_crawl_shop_company')) {
return ['status' => 'skipped', 'checked' => 0, 'created' => 0, 'reason' => 'crawl_mirror_not_ready'];
}
if (! $schema->hasTable(StoreReportPeriodBuilder::CHANGES_TABLE)) {
return ['status' => 'skipped', 'checked' => 0, 'created' => 0, 'reason' => 'change_table_missing'];
}
$ranked = $this->connection->table('ops_crawl_shop_company')
->selectRaw('id, shop_name, company_name, tax_no, crawl_date, row_number() over (partition by shop_name order by crawl_date desc, id desc) AS row_rank');
$latest = $this->connection->table($ranked, 'crawler_latest')
->where('row_rank', 1)
->get(['shop_name', 'company_name', 'tax_no', 'crawl_date']);
if ($latest->isEmpty()) {
return ['status' => 'succeeded', 'checked' => 0, 'created' => 0];
}
$masterNames = [];
if ($schema->hasTable('ops_fys_company_info')) {
$rankedCompanies = $this->connection->table('ops_fys_company_info')
->selectRaw('unifiedSocialCreditCode, businessLicenseName, row_number() over (partition by unifiedSocialCreditCode order by Id desc) AS row_rank');
foreach ($this->connection->table($rankedCompanies, 'company_latest')
->where('row_rank', 1)
->get(['unifiedSocialCreditCode', 'businessLicenseName']) as $row) {
$code = trim((string) ($row->unifiedSocialCreditCode ?? ''));
$name = trim((string) ($row->businessLicenseName ?? ''));
if ($code !== '' && $name !== '') {
$masterNames[$code] = $name;
}
}
}
$changes = [];
foreach ($latest as $row) {
$name = trim((string) ($row->shop_name ?? ''));
$taxNo = trim((string) ($row->tax_no ?? ''));
$company = $masterNames[$taxNo] ?? trim((string) ($row->company_name ?? ''));
$observed = substr(trim((string) ($row->crawl_date ?? '')), 0, 10);
if ($name === '' || $company === '' || $observed === '') {
continue;
}
$changes[$name] = ['company' => $company, 'tax_no' => $taxNo, 'observed_on' => $observed];
}
if ($changes === []) {
return ['status' => 'succeeded', 'checked' => 0, 'created' => 0];
}
$periods = app(StoreReportPeriodBuilder::class)->build(array_map(
static fn (string $name): array => ['erp_store_name' => $name],
array_keys($changes),
));
$created = 0;
foreach ($changes as $name => $change) {
$list = $periods[$name] ?? [];
$last = $list === [] ? null : $list[count($list) - 1];
if ($this->sameCompany($last['company_name'] ?? null, $change['company'])) {
continue;
}
$created += (int) $this->connection->table(StoreReportPeriodBuilder::CHANGES_TABLE)->insertOrIgnore([
'erp_store_name' => $name,
'company_name' => $change['company'],
'unified_social_credit_code' => $change['tax_no'] === '' ? null : $change['tax_no'],
'observed_on' => $change['observed_on'],
'source' => 'crawl_shop_company',
'business_key' => hash('sha256', implode('|', [$name, $change['company'], $change['observed_on']])),
'created_at' => now(),
'updated_at' => now(),
]);
}
return ['status' => 'succeeded', 'checked' => count($changes), 'created' => $created];
}
/**
* @param callable(int,int):array<int,array<string,mixed>> $fetch
* @param callable(array<int,array<string,mixed>>,string):array{upserts:array<int,array<string,mixed>>,deletes:array<int,int>} $map
@ -518,6 +606,14 @@ class StoreReportMirrorSynchronizer
return mb_substr($value, 0, $length);
}
private function sameCompany(mixed $left, mixed $right): bool
{
$left = mb_strtolower(trim((string) ($left ?? '')));
$right = mb_strtolower(trim((string) ($right ?? '')));
return $left !== '' && $left === $right;
}
private function dateTime(mixed $value): ?string
{
$value = trim((string) ($value ?? ''));

View File

@ -0,0 +1,258 @@
<?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');
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 「税金预测表逻辑配置」的可维护参数与规则目录。
*
* 税金预测表页面只展示预测结果;税率、免税额度、减半开关、核定默认值
* 等运行参数由负责人在这里维护,读取时设置值优先、代码默认值兜底。
*/
class TaxForecastLogic
{
private const PREFIX = 'business_tax_forecast';
/** 扁平键:设置组件会按点号展开存储,不能嵌套数组。 */
public const KEYS = [
'vat_small_rate' => self::PREFIX . '.vat_small_rate',
'vat_free_threshold' => self::PREFIX . '.vat_free_threshold',
'vat_general_enabled' => self::PREFIX . '.vat_general_enabled',
'surtax_city_rate' => self::PREFIX . '.surtax_city_rate',
'surtax_education_rate' => self::PREFIX . '.surtax_education_rate',
'surtax_local_education_rate' => self::PREFIX . '.surtax_local_education_rate',
'surtax_halved' => self::PREFIX . '.surtax_halved',
'stamp_duty_rate' => self::PREFIX . '.stamp_duty_rate',
'stamp_duty_halved' => self::PREFIX . '.stamp_duty_halved',
'cit_effective_rate' => self::PREFIX . '.cit_effective_rate',
'cit_threshold' => self::PREFIX . '.cit_threshold',
'individual_halved_threshold' => self::PREFIX . '.individual_halved_threshold',
'assessed_income_rate' => self::PREFIX . '.assessed_income_rate',
'income_forecast_source' => self::PREFIX . '.income_forecast_source',
];
public const INCOME_FORECAST_SOURCES = [
'last_month_actual' => '上月实际收入(bills.income)',
'cost_arrangement' => '公司成本安排的当月收入预测',
'manual' => '手工录入',
];
/** @return array<string,string> */
public static function defaults(): array
{
return [
'vat_small_rate' => '1',
'vat_free_threshold' => '300000',
'vat_general_enabled' => '1',
'surtax_city_rate' => '7',
'surtax_education_rate' => '3',
'surtax_local_education_rate' => '2',
'surtax_halved' => '1',
'stamp_duty_rate' => '0.03',
'stamp_duty_halved' => '1',
'cit_effective_rate' => '5',
'cit_threshold' => '3000000',
'individual_halved_threshold' => '2000000',
'assessed_income_rate' => '10',
'income_forecast_source' => 'last_month_actual',
];
}
/** @return array<string,string> */
public static function current(): array
{
$values = [];
foreach (self::KEYS as $key => $settingKey) {
$value = setting($settingKey, null);
$values[$key] = is_array($value) ? implode('', $value) : (string) ($value ?? '');
}
return array_merge(self::defaults(), array_filter($values, static fn (string $value): bool => $value !== ''));
}
public static function value(string $key, mixed $default = null): mixed
{
$current = self::current();
return $current[$key] ?? ($default ?? (self::defaults()[$key] ?? null));
}
public static function rate(string $key): float
{
return max((float) self::value($key, 0), 0);
}
public static function amount(string $key): float
{
return max((float) self::value($key, 0), 0);
}
public static function toggled(string $key): bool
{
return (string) self::value($key, '1') === '1';
}
/** @param array<string,mixed> $values */
public static function save(array $values): void
{
$payload = [];
foreach (self::defaults() as $key => $default) {
if (! array_key_exists($key, $values) || ! isset(self::KEYS[$key])) {
continue;
}
$payload[self::KEYS[$key]] = mb_substr(trim((string) $values[$key]), 0, 191);
}
if ($payload !== []) {
setting($payload)->save();
}
}
/**
* 规则目录:页面上的规则清单以这里为准,和取数公式文档
* docs/税金预测表需求说明.md 保持一致。
*
* @return array<string,array<int,array{code:string,object:string,logic:string,source:string,priority:string}>>
*/
public static function rules(): array
{
return [
'主体识别' => [
['code' => 'R1', 'object' => '公司名称、公司税务类型', 'logic' => '读取公司基础信息镜像的营业执照名称和纳税类别及类型,作为增值税口径判断依据。', 'source' => 'rpt.fys_companyInfo(只读镜像)', 'priority' => '税务类型空值时按“无税务登记”处理并提示补充。'],
['code' => 'R2', 'object' => '工商类型', 'logic' => '区分有限责任公司、个人独资企业、合伙企业、个体工商户,决定所得税种。', 'source' => '待接入:工商登记信息或人工维护', 'priority' => '缺失时所得税列留空,不按企业所得税估算。'],
['code' => 'R3', 'object' => '征收方式、核定应税所得率', 'logic' => '查账征收按利润计算;核定征收按 收入 × 核定应税所得率 计算;定期定额按核定定额折算。', 'source' => '待接入:电子税务局数据或人工维护', 'priority' => '缺失时按查账征收展示,并在备注列标记待确认。'],
],
'增值税' => [
['code' => 'R4', 'object' => '小规模纳税人免税判断', 'logic' => '季度不含税销售额不超过免税额度时,预计增值税为 0。', 'source' => 'bills.income 按主体期间归集', 'priority' => '免税额度按配置参数执行,默认 30 万元/季。'],
['code' => 'R5', 'object' => '小规模纳税人征收率', 'logic' => '超过免税额度时,预计增值税 = 不含税销售额 × 征收率。', 'source' => '配置参数', 'priority' => '默认 1%(3% 减按 1%)。'],
['code' => 'R6', 'object' => '一般纳税人计税', 'logic' => '预计增值税 = 销项税额 − 进项税额;进项缺失时按销项全额提示。', 'source' => 'bills.income 与进项数据', 'priority' => '默认开启,可在配置中关闭改为简易口径。'],
['code' => 'R7', 'object' => '个体工商户、个人独资企业、合伙企业', 'logic' => '增值税按小规模纳税人口径计算。', 'source' => '同上', 'priority' => '不因工商类型改为一般纳税人口径。'],
],
'附加税费' => [
['code' => 'R8', 'object' => '城建税', 'logic' => '城建税 = 预计增值税 × 城建税税率。', 'source' => '配置参数', 'priority' => '默认市区 7%,县城/镇 5%,其他 1%。'],
['code' => 'R9', 'object' => '教育费附加、地方教育附加', 'logic' => '教育费附加 = 增值税 × 3%;地方教育附加 = 增值税 × 2%。', 'source' => '配置参数', 'priority' => '税率可调。'],
['code' => 'R10', 'object' => '六税两费减半', 'logic' => '小规模纳税人、小型微利企业、个体工商户的附加税费减半征收。', 'source' => '配置开关', 'priority' => '默认开启;关闭后按全额计算。'],
],
'印花税' => [
['code' => 'R11', 'object' => '购销合同印花税', 'logic' => '印花税 = 购销合同金额 × 印花税税率(减半开关生效时再乘 50%)。', 'source' => '配置参数与成本安排金额', 'priority' => '默认 0.03% 并减半。'],
],
'所得税' => [
['code' => 'R12', 'object' => '小型微利企业所得税', 'logic' => '应纳税所得额 × 25% × 20%,年不超过上限部分实际税负 5%。', 'source' => '配置参数 + 预计利润', 'priority' => '默认上限 300 万元,超过部分提示分段计算。'],
['code' => 'R13', 'object' => '经营所得个人所得税', 'logic' => '个人独资企业、合伙企业、个体工商户按经营所得五级超额累进计算;核定征收按 收入 × 核定应税所得率 计算。', 'source' => '配置参数 + 核定通知书', 'priority' => '核定应税所得率默认 10%。'],
['code' => 'R14', 'object' => '个体工商户减半', 'logic' => '年应纳税所得额不超过上限部分,经营所得个人所得税减半征收。', 'source' => '配置参数', 'priority' => '默认上限 200 万元。'],
],
'数据来源' => [
['code' => 'R15', 'object' => '收入预测', 'logic' => '按主体生效/失效期间把 bills.income 归集到预测月份;未出账时按配置的收入预测口径取值。', 'source' => 'runtime/bills.sqlite', 'priority' => '口径可在配置中切换。'],
['code' => 'R16', 'object' => '成本费用预测', 'logic' => '供应商成本安排 + 快递成本安排 + 人工成本预测 + 其他费用预测。', 'source' => '公司成本安排页', 'priority' => '成本安排未接入时所得税列留空。'],
],
];
}
/**
* 数据源现状:只读共享业务镜像,不访问上游。
*
* @return array<int,array{name:string,object:string,status:string,detail:string}>
*/
public static function health(): array
{
$health = [
[
'name' => '公司基础信息(含税务类型)',
'object' => 'ops_fys_company_info',
'status' => '尚未同步',
'detail' => '',
],
[
'name' => '工商类型',
'object' => '待接入',
'status' => '未接入',
'detail' => '现有镜像无法区分个人独资企业、合伙企业;确认数据来源后接入。',
],
[
'name' => '征收方式与核定率',
'object' => '待接入',
'status' => '未接入',
'detail' => '查账/核定、核定应税所得率需电子税务局数据或人工维护。',
],
];
try {
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if ($connection->getSchemaBuilder()->hasTable('ops_fys_company_info')) {
$row = $connection->table('ops_fys_company_info')
->selectRaw("COUNT(*) AS total, SUM(CASE WHEN taxCategoryAndType = '小规模纳税人' THEN 1 ELSE 0 END) AS small, SUM(CASE WHEN taxCategoryAndType = '一般纳税人' THEN 1 ELSE 0 END) AS general")
->first();
$health[0]['status'] = '已同步';
$health[0]['detail'] = sprintf(
'主体 %d 家:小规模 %d 家、一般纳税人 %d 家',
(int) ($row->total ?? 0),
(int) ($row->small ?? 0),
(int) ($row->general ?? 0),
);
}
} catch (Throwable) {
$health[0]['detail'] = '读取共享镜像失败,请检查连接与同步状态。';
}
return $health;
}
}

View File

@ -0,0 +1,211 @@
@php
use Modules\EcommerceFinance\Services\TaxForecastLogic;
$rules = TaxForecastLogic::rules();
@endphp
<style>
.logic-edit-page { max-width: 1100px; margin-top: 1.5rem; }
.logic-edit-card {
margin-bottom: 1.25rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.logic-edit-card h3 { margin: 0 0 0.35rem; font-size: 1rem; font-weight: 600; }
.logic-edit-card p.hint { margin: 0 0 1rem; color: #7c7565; font-size: 0.8125rem; line-height: 1.35rem; }
.logic-edit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 0.9rem 1.25rem; }
.logic-edit-field label { display: block; margin-bottom: 0.3rem; color: #4b4638; font-size: 0.8125rem; font-weight: 600; }
.logic-edit-field input[type="text"],
.logic-edit-field input[type="number"],
.logic-edit-field select {
width: 100%;
padding: 0.45rem 0.65rem;
border: 1px solid #d8d2c7;
border-radius: 0.45rem;
background: #ffffff;
color: #33302a;
font-size: 0.8125rem;
}
.logic-edit-field .field-hint { margin-top: 0.25rem; color: #8b8577; font-size: 0.75rem; }
.logic-edit-actions { display: flex; gap: 0.6rem; align-items: center; }
.logic-edit-actions button {
padding: 0.5rem 1.2rem;
border: 0;
border-radius: 0.45rem;
background: #2a2927;
color: #f2e7c5;
font-size: 0.875rem;
cursor: pointer;
}
.logic-edit-actions a { color: #8a6d1f; font-size: 0.875rem; text-decoration: underline; }
.logic-edit-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.logic-edit-table th {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
text-align: left;
white-space: nowrap;
}
.logic-edit-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f0ece4; color: #33302a; vertical-align: top; }
</style>
<div class="logic-edit-page">
<form method="POST" action="{{ route('business.tax-forecast-logic.update') }}">
@csrf
<div class="logic-edit-card">
<h3>增值税</h3>
<p class="hint">小规模纳税人按季度销售额判断免税;一般纳税人的进销项抵扣可单独关闭。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>小规模增值税征收率(%)</label>
<input type="number" name="vat_small_rate" step="0.01" min="0" max="100" value="{{ $settings['vat_small_rate'] ?? '1' }}">
<div class="field-hint">对应规则 R5,默认 1%(3% 减按 1%)。</div>
</div>
<div class="logic-edit-field">
<label>小规模季度免税额度(元)</label>
<input type="number" name="vat_free_threshold" step="1000" min="0" value="{{ $settings['vat_free_threshold'] ?? '300000' }}">
<div class="field-hint">对应规则 R4,默认 300000 元/季。</div>
</div>
<div class="logic-edit-field">
<label>一般纳税人进销项抵扣</label>
<select name="vat_general_enabled">
<option value="1" @selected(($settings['vat_general_enabled'] ?? '1') === '1')>开启(销项 − 进项)</option>
<option value="0" @selected(($settings['vat_general_enabled'] ?? '') === '0')>关闭(按销项全额提示)</option>
</select>
<div class="field-hint">对应规则 R6。</div>
</div>
</div>
</div>
<div class="logic-edit-card">
<h3>附加税费</h3>
<p class="hint">附加税费以预计增值税为计税依据,六税两费减半开关对附加税费统一生效。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>城建税税率(%)</label>
<input type="number" name="surtax_city_rate" step="0.01" min="0" max="100" value="{{ $settings['surtax_city_rate'] ?? '7' }}">
<div class="field-hint">对应规则 R8,市区 7%、县城/镇 5%、其他 1%。</div>
</div>
<div class="logic-edit-field">
<label>教育费附加(%)</label>
<input type="number" name="surtax_education_rate" step="0.01" min="0" max="100" value="{{ $settings['surtax_education_rate'] ?? '3' }}">
<div class="field-hint">对应规则 R9,默认 3%。</div>
</div>
<div class="logic-edit-field">
<label>地方教育附加(%)</label>
<input type="number" name="surtax_local_education_rate" step="0.01" min="0" max="100" value="{{ $settings['surtax_local_education_rate'] ?? '2' }}">
<div class="field-hint">对应规则 R9,默认 2%。</div>
</div>
<div class="logic-edit-field">
<label>六税两费减半</label>
<select name="surtax_halved">
<option value="1" @selected(($settings['surtax_halved'] ?? '1') === '1')>开启</option>
<option value="0" @selected(($settings['surtax_halved'] ?? '') === '0')>关闭</option>
</select>
<div class="field-hint">对应规则 R10。</div>
</div>
</div>
</div>
<div class="logic-edit-card">
<h3>印花税</h3>
<p class="hint">印花税按购销合同金额计算,减半开关单独维护。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>印花税税率(%)</label>
<input type="number" name="stamp_duty_rate" step="0.001" min="0" max="100" value="{{ $settings['stamp_duty_rate'] ?? '0.03' }}">
<div class="field-hint">对应规则 R11,默认 0.03%。</div>
</div>
<div class="logic-edit-field">
<label>印花税减半</label>
<select name="stamp_duty_halved">
<option value="1" @selected(($settings['stamp_duty_halved'] ?? '1') === '1')>开启</option>
<option value="0" @selected(($settings['stamp_duty_halved'] ?? '') === '0')>关闭</option>
</select>
<div class="field-hint">对应规则 R11。</div>
</div>
</div>
</div>
<div class="logic-edit-card">
<h3>所得税</h3>
<p class="hint">有限公司按小型微利企业口径,个人独资企业、合伙企业、个体工商户按经营所得口径。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>小型微利企业所得税实际税负(%)</label>
<input type="number" name="cit_effective_rate" step="0.01" min="0" max="100" value="{{ $settings['cit_effective_rate'] ?? '5' }}">
<div class="field-hint">对应规则 R12,默认 5%(25% × 20%)。</div>
</div>
<div class="logic-edit-field">
<label>年应纳税所得额上限(元)</label>
<input type="number" name="cit_threshold" step="10000" min="0" value="{{ $settings['cit_threshold'] ?? '3000000' }}">
<div class="field-hint">超过上限部分提示分段计算。</div>
</div>
<div class="logic-edit-field">
<label>个体工商户减半上限(元)</label>
<input type="number" name="individual_halved_threshold" step="10000" min="0" value="{{ $settings['individual_halved_threshold'] ?? '2000000' }}">
<div class="field-hint">对应规则 R14,默认 2000000 元。</div>
</div>
<div class="logic-edit-field">
<label>核定应税所得率默认值(%)</label>
<input type="number" name="assessed_income_rate" step="0.01" min="0" max="100" value="{{ $settings['assessed_income_rate'] ?? '10' }}">
<div class="field-hint">核定征收主体未单独维护时按此默认值。</div>
</div>
</div>
</div>
<div class="logic-edit-card">
<h3>预测口径</h3>
<p class="hint">预测月份尚未出账时,收入取值方式。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>收入预测口径</label>
<select name="income_forecast_source">
@foreach ($incomeForecastSources as $value => $label)
<option value="{{ $value }}" @selected(($settings['income_forecast_source'] ?? 'last_month_actual') === $value)>{{ $label }}</option>
@endforeach
</select>
<div class="field-hint">对应规则 R15。</div>
</div>
</div>
</div>
<div class="logic-edit-actions">
<button type="submit">保存配置</button>
<a href="{{ route('business.pages.show', ['section' => 'trademark-management', 'page' => 'tax-forecast-data-logic']) }}">返回规则页</a>
</div>
</form>
<div class="logic-edit-card" style="margin-top: 1.25rem;">
<h3>规则目录(只读)</h3>
<p class="hint">规则编号和逻辑说明与取数公式文档保持一致,用于核对上面参数的实际含义。</p>
<table class="logic-edit-table">
<thead>
<tr>
<th>规则编号</th>
<th>规则类别</th>
<th>规则对象</th>
<th>规则逻辑</th>
<th>优先级说明</th>
</tr>
</thead>
<tbody>
@foreach ($rules as $category => $categoryRules)
@foreach ($categoryRules as $rule)
<tr>
<td>{{ $rule['code'] }}</td>
<td>{{ $category }}</td>
<td>{{ $rule['object'] }}</td>
<td>{{ $rule['logic'] }}</td>
<td>{{ $rule['priority'] }}</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,183 @@
@php
use Modules\EcommerceFinance\Services\TaxForecastLogic;
$parameterRows = [
['label' => '小规模增值税征收率', 'value' => ($taxSettings['vat_small_rate'] ?? '1') . '%', 'hint' => '超过免税额度时按不含税销售额计算'],
['label' => '小规模季度免税额度', 'value' => number_format((float) ($taxSettings['vat_free_threshold'] ?? 0)) . ' 元/季', 'hint' => '季度不含税销售额不超过该额度时增值税为 0'],
['label' => '一般纳税人进销项抵扣', 'value' => ($taxSettings['vat_general_enabled'] ?? '1') === '1' ? '开启' : '关闭', 'hint' => '关闭后一般纳税人按销项税额全额提示'],
['label' => '城建税税率', 'value' => ($taxSettings['surtax_city_rate'] ?? '7') . '%', 'hint' => '市区 7%、县城/镇 5%、其他 1%'],
['label' => '教育费附加', 'value' => ($taxSettings['surtax_education_rate'] ?? '3') . '%', 'hint' => '以预计增值税为计税依据'],
['label' => '地方教育附加', 'value' => ($taxSettings['surtax_local_education_rate'] ?? '2') . '%', 'hint' => '以预计增值税为计税依据'],
['label' => '六税两费减半', 'value' => ($taxSettings['surtax_halved'] ?? '1') === '1' ? '开启' : '关闭', 'hint' => '附加税费减半征收'],
['label' => '印花税税率(购销合同)', 'value' => ($taxSettings['stamp_duty_rate'] ?? '0.03') . '%', 'hint' => '按购销合同金额计算'],
['label' => '印花税减半', 'value' => ($taxSettings['stamp_duty_halved'] ?? '1') === '1' ? '开启' : '关闭', 'hint' => '小规模、小微减半征收'],
['label' => '小型微利企业所得税实际税负', 'value' => ($taxSettings['cit_effective_rate'] ?? '5') . '%', 'hint' => '应纳税所得额 × 25% × 20%'],
['label' => '年应纳税所得额上限', 'value' => number_format((float) ($taxSettings['cit_threshold'] ?? 0)) . ' 元', 'hint' => '超过上限部分提示分段计算'],
['label' => '个体工商户减半上限', 'value' => number_format((float) ($taxSettings['individual_halved_threshold'] ?? 0)) . ' 元', 'hint' => '经营所得个人所得税减半'],
['label' => '核定应税所得率默认值', 'value' => ($taxSettings['assessed_income_rate'] ?? '10') . '%', 'hint' => '核定征收主体未单独维护时按此默认值'],
['label' => '收入预测口径', 'value' => TaxForecastLogic::INCOME_FORECAST_SOURCES[$taxSettings['income_forecast_source'] ?? ''] ?? '上月实际收入(bills.income)', 'hint' => '预测月份未出账时取值方式'],
];
@endphp
<style>
.data-logic-page { margin-top: 1.5rem; }
.data-logic-note {
padding: 1rem 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
line-height: 1.5rem;
}
.data-logic-card {
margin-top: 1.25rem;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.data-logic-card h3 {
margin: 0;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
font-size: 0.9375rem;
font-weight: 600;
}
.data-logic-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.data-logic-table th {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.data-logic-table td {
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #f0ece4;
color: #33302a;
vertical-align: top;
line-height: 1.4rem;
}
.data-logic-table tr:last-child td { border-bottom: 0; }
.data-logic-code { font-weight: 600; color: #8a6d1f; white-space: nowrap; }
.data-logic-category { white-space: nowrap; }
.data-logic-tag {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.4rem;
background: #f4efe3;
color: #7a6428;
font-size: 0.75rem;
white-space: nowrap;
}
.data-logic-tag.is-pending { background: #fdeeee; color: #9c2f2f; }
.data-logic-value { font-weight: 600; color: #2a2927; white-space: nowrap; }
.data-logic-health { margin-top: 0.35rem; color: #7c7565; font-size: 0.75rem; line-height: 1.15rem; }
.data-logic-edit-button {
padding: 0.45rem 1.1rem;
border-radius: 0.45rem;
background: #2a2927;
color: #f2e7c5 !important;
font-size: 0.8125rem;
text-decoration: none;
white-space: nowrap;
}
</style>
<div class="data-logic-page">
<div class="data-logic-note" style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem;">
<span>规则参数由负责人维护,保存后下次预测按新参数计算。</span>
<a class="data-logic-edit-button" href="{{ route('business.tax-forecast-logic.edit') }}">编辑配置</a>
</div>
<div class="data-logic-note">
本页是税金预测表当前实际使用的计算口径,与 <code>docs/税金预测表需求说明.md</code> 保持一致;
工商类型、征收方式(查账/核定)接入前,涉及所得税的列在预测表中留空,不做推测。
<a href="{{ route('business.pages.show', ['section' => 'company-management', 'page' => 'tax-forecast']) }}" style="color: #8a6d1f;">返回税金预测表</a>
</div>
<div class="data-logic-card">
<h3>当前运行参数</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>参数</th>
<th>当前值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
@foreach ($parameterRows as $row)
<tr>
<td>{{ $row['label'] }}</td>
<td class="data-logic-value">{{ $row['value'] }}</td>
<td>{{ $row['hint'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="data-logic-card">
<h3>数据来源</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>来源</th>
<th>对象</th>
<th>状态</th>
<th>说明</th>
</tr>
</thead>
<tbody>
@foreach ($taxHealth as $source)
<tr>
<td>{{ $source['name'] }}</td>
<td><code>{{ $source['object'] }}</code></td>
<td>
<span class="data-logic-tag {{ $source['status'] === '已同步' ? '' : 'is-pending' }}">{{ $source['status'] }}</span>
</td>
<td>
{{ $source['detail'] !== '' ? $source['detail'] : '—' }}
<div class="data-logic-health">收入口径:主体维度经营报表同源(bills.income);成本口径:公司成本安排页。</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="data-logic-card">
<h3>规则清单</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>规则编号</th>
<th>规则类别</th>
<th>规则对象</th>
<th>规则逻辑</th>
<th>数据来源</th>
<th>优先级说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@foreach ($taxRules as $category => $categoryRules)
@foreach ($categoryRules as $rule)
<tr>
<td class="data-logic-code">{{ $rule['code'] }}</td>
<td class="data-logic-category">{{ $category }}</td>
<td>{{ $rule['object'] }}</td>
<td>{{ $rule['logic'] }}</td>
<td>{{ $rule['source'] }}</td>
<td>{{ $rule['priority'] }}</td>
<td><span class="data-logic-tag">已生效</span></td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>

View File

@ -23,6 +23,34 @@
$directoryCurrentPage = $directoryIsPaginated ? $directoryRows->currentPage() : 1;
$directoryLastPage = $directoryIsPaginated ? $directoryRows->lastPage() : 1;
$directoryPerPage = $directoryIsPaginated ? $directoryRows->perPage() : 0;
// 页面配置在部分导航上下文中会省略父级 slug,优先使用当前路由参数避免功能开关失配。
$directoryRouteSection = request()->route('section') ?: request()->segment(3) ?: ($section['slug'] ?? '');
$directoryRoutePage = request()->route('page') ?: request()->segment(4) ?: ($page['slug'] ?? '');
$directoryPageKey = (string) $directoryRouteSection . '/' . (string) $directoryRoutePage;
$directoryUsesFieldQuery = in_array($directoryPageKey, [
'company-management/basic-information',
'store-management/basic-information',
'trademark-management/information',
'store-management/store-accounts',
'store-management/store-report',
'company-management/subject-operating-report',
'company-management/company-cost-arrangement',
'company-management/tax-forecast',
'company-management/personnel-inventory',
'store-management/internal-flow-report',
], true);
$directoryQueryPageDescriptions = [
'company-management/basic-information' => '公司主体、状态和登记资料的查询与导出',
'store-management/basic-information' => '店铺主体、平台和经营资料的查询与导出',
'trademark-management/information' => '商标主体、状态和登记资料的查询与导出',
'store-management/store-accounts' => '店铺账号资料的查询与安全状态导出',
'store-management/store-report' => '按店铺、主体和月份销量查询经营数据并导出',
'company-management/subject-operating-report' => '按主体和收入月份查询经营汇总并导出',
'company-management/company-cost-arrangement' => '按公司类型和发票类型查询成本安排字段',
'company-management/tax-forecast' => '按税务类型、工商类型和征收方式查询税金预测口径;规则参数在「规则信息 → 税金预测表逻辑配置」维护',
'company-management/personnel-inventory' => '按人员、职位和公司类型查询人员盘点字段',
'store-management/internal-flow-report' => '按抬头、标签查询内部流转明细并导出',
];
@endphp
<style>
@ -35,6 +63,37 @@
color: var(--vip-content-ink, #171717);
}
.directory-table-page.has-field-query {
margin-top: 1.5rem;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.directory-query-page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.directory-query-page-head strong {
display: block;
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.directory-query-page-head p {
margin: 0.4rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
line-height: 1.5;
}
.directory-table-head {
display: flex;
align-items: flex-start;
@ -115,6 +174,64 @@
background: var(--vip-content-bg, #f8f6f1);
}
.directory-query-bar {
display: grid;
grid-template-columns: repeat(4, minmax(12rem, 1fr));
gap: 1rem 1.25rem;
margin: 0;
padding: 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: #fff;
}
/* The form is intentionally kept empty because the controls below use its
`form` attribute. Do not let that form become an empty grid item and
leave a blank cell before the first query field. */
.directory-query-bar > form { display: none; }
.directory-query-heading { display: flex; grid-column: 1 / -1; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 0.125rem; color: var(--vip-content-accent-dark, #856632); }
.directory-query-heading strong { font-size: 0.8125rem; }
.directory-query-heading span { color: var(--vip-content-muted, #595959); font-size: 0.6875rem; font-weight: 400; }
.directory-query-field { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: stretch; gap: 0.45rem; color: var(--vip-content-muted, #595959); font-size: 0.75rem; }
.directory-query-field > span:first-child { overflow: hidden; color: var(--vip-content-ink, #171717); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.directory-query-field input, .directory-query-field select { width: 100%; min-width: 0; height: 2.125rem; padding: 0 0.625rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.375rem; background: #fff; color: var(--vip-content-ink, #171717); font-size: 0.75rem; }
.directory-query-field input:focus, .directory-query-field select:focus { border-color: var(--vip-content-accent, #b9954f); outline: 2px solid rgba(185, 149, 79, 0.16); }
.directory-query-dates { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 0.25rem; width: 100%; min-width: 0; }
.directory-query-dates input { min-width: 0; }
.directory-query-suggestions { position: absolute; top: calc(100% + 0.25rem); right: 0; left: 0; z-index: 6; margin: 0; padding: 0.25rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.375rem; background: #fff; box-shadow: 0 5px 12px rgba(23,23,23,0.12); list-style: none; }
.directory-query-suggestions[hidden] { display: none; }
.directory-query-suggestions button { display: block; width: 100%; overflow: hidden; padding: 0.4rem 0.5rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--vip-content-ink, #171717); font: inherit; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.directory-query-suggestions button:hover, .directory-query-suggestions button:focus { background: var(--vip-content-accent-soft, #f7f0de); outline: 0; }
.directory-query-actions { display: flex; grid-column: 1 / -1; flex-wrap: wrap; align-items: flex-start; justify-content: flex-end; gap: 0.5rem; padding-top: 0.125rem; }
.directory-query-actions button { height: 2.1rem; padding: 0 0.8rem; border: 1px solid var(--vip-content-accent, #b9954f); border-radius: 0.375rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; }
.directory-query-actions .is-primary { background: var(--vip-content-accent-dark, #856632); color: #fff; }
.directory-query-picker, .directory-query-export { position: relative; }
.directory-query-picker summary, .directory-query-export summary { display: flex; height: 2.1rem; align-items: center; padding: 0 0.8rem; border: 1px solid var(--vip-content-accent, #b9954f); border-radius: 0.375rem; background: #fff; color: var(--vip-content-accent-dark, #856632); cursor: pointer; list-style: none; }
.directory-query-export summary { gap: 0.35rem; }
.directory-query-export summary .material-icons-outlined { font-size: 1rem; }
.directory-query-picker summary::-webkit-details-marker, .directory-query-export summary::-webkit-details-marker { display: none; }
.directory-query-picker__panel, .directory-query-export__panel { position: absolute; top: calc(100% + 0.35rem); right: 0; z-index: 8; display: grid; grid-template-columns: repeat(2, minmax(10rem, 1fr)); gap: 0.35rem 0.75rem; width: max-content; min-width: 24rem; max-width: min(34rem, calc(100vw - 2rem)); max-height: 22rem; overflow: auto; padding: 0.75rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.5rem; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); }
.directory-query-picker__hint { grid-column: 1 / -1; color: var(--vip-content-muted, #595959); font-size: 0.6875rem; }
.directory-query-picker__option-form { min-width: 0; }
.directory-query-picker__option { display: flex; align-items: center; gap: 0.4rem; width: 100%; min-width: 0; padding: 0.25rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--vip-content-ink, #171717); font: inherit; font-size: 0.75rem; text-align: left; cursor: pointer; }
.directory-query-picker__option:hover, .directory-query-picker__option:focus { background: var(--vip-content-accent-soft, #f7f0de); outline: 0; }
.directory-query-picker__option input { flex: 0 0 auto; width: 1rem; height: 1rem; margin: 0; accent-color: var(--vip-content-accent, #b9954f); }
.directory-query-export__option { display: flex; align-items: center; gap: 0.4rem; min-width: 0; color: var(--vip-content-ink, #171717); font-size: 0.75rem; cursor: pointer; }
.directory-query-export__option input { flex: 0 0 auto; width: 1rem; height: 1rem; margin: 0; accent-color: var(--vip-content-accent, #b9954f); }
.directory-query-export__button { grid-column: 1 / -1; justify-self: end; }
@media (max-width: 1100px) { .directory-query-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); } }
@media (max-width: 650px) {
.directory-query-page-head { margin-bottom: 1rem; }
.directory-query-bar { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.directory-query-heading { align-items: flex-start; flex-direction: column; gap: 0.3rem; }
.directory-query-actions { justify-content: flex-start; }
.directory-query-actions > * { flex: 1 1 calc(50% - 0.25rem); }
.directory-query-actions button,
.directory-query-picker summary,
.directory-query-export summary { justify-content: center; width: 100%; }
.directory-query-picker__panel, .directory-query-export__panel { right: auto; left: 0; grid-template-columns: minmax(0, 1fr); width: min(18rem, calc(100vw - 2rem)); min-width: 0; }
}
.directory-table-tools > [data-directory-search] {
flex: 1 1 16rem;
min-width: 12rem;
@ -175,6 +292,11 @@
line-height: 1.35;
cursor: pointer;
}
.directory-field-picker__option[draggable="true"] { cursor: grab; }
.directory-field-picker__option.is-dragging { opacity: 0.55; }
.directory-field-picker__option.is-drop-target { outline: 1px solid var(--vip-content-accent, #b9954f); background: var(--vip-content-accent-soft, #f7f0de); }
.directory-field-picker__hint { grid-column: 1 / -1; color: var(--vip-content-muted, #595959); font-size: 0.6875rem; }
.directory-table-tools .directory-export-group { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.directory-field-picker__option input[type="checkbox"] {
flex: 0 0 auto;
width: 1rem;
@ -188,6 +310,7 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.directory-field-picker__option label { display: block; flex: 1 1 auto; min-width: 0; cursor: pointer; }
.directory-table [data-column-key="sales_01"],
.directory-table [data-column-key="sales_02"],
.directory-table [data-column-key="sales_03"],
@ -211,6 +334,18 @@
scrollbar-width: thin;
}
.directory-table-page.has-field-query .directory-table-scroll {
margin-top: 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
background: var(--vip-surface, #fff);
}
.directory-table-page.has-field-query .directory-table-pagination {
border: 1px solid var(--vip-border, #d8d2c7);
border-top: 0;
background: var(--vip-surface, #fff);
}
.directory-table {
width: 100%;
min-width: 68rem;
@ -252,6 +387,16 @@
font-weight: 600;
box-shadow: 0 1px 0 var(--vip-border, #d8d2c7);
}
.directory-sort-link { display: inline-flex; align-items: center; gap: 0.25rem; color: inherit; text-decoration: none; }
.directory-sort-link:hover { color: var(--vip-content-accent-dark, #856632); }
.directory-sort-icon { width: 1rem; height: 1rem; color: var(--vip-content-muted, #595959); font-size: 1rem; line-height: 1; }
.directory-sort-link[aria-current="true"] .directory-sort-icon { color: var(--vip-content-accent-dark, #856632); }
.directory-header-menu { position: relative; display: inline-flex; align-items: center; gap: 0.15rem; }
.directory-header-menu summary { display: inline-flex; align-items: center; cursor: pointer; list-style: none; }
.directory-header-menu summary::-webkit-details-marker { display: none; }
.directory-header-menu__panel { position: absolute; top: calc(100% + 0.35rem); left: 0; z-index: 9; display: grid; min-width: 8rem; gap: 0.2rem; padding: 0.35rem; border: 1px solid var(--vip-border, #d8d2c7); border-radius: 0.375rem; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10); }
.directory-header-menu__panel a, .directory-header-menu__panel button { display: block; width: 100%; padding: 0.35rem 0.5rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--vip-content-ink, #171717); font: inherit; font-size: 0.6875rem; text-align: left; text-decoration: none; cursor: pointer; white-space: nowrap; }
.directory-header-menu__panel a:hover, .directory-header-menu__panel button:hover, .directory-header-menu__panel a:focus, .directory-header-menu__panel button:focus { background: var(--vip-content-accent-soft, #f7f0de); outline: 0; }
.directory-table tbody tr:hover {
background: #fbf8f0;
@ -342,7 +487,27 @@
}
</style>
<section class="directory-table-page" data-directory-page="{{ $page['slug'] ?? '' }}" data-directory-last-page="{{ $directoryLastPage }}" aria-label="{{ $page['title'] ?? $section['title'] }}">
<section class="directory-table-page {{ $directoryUsesFieldQuery ? 'has-field-query' : '' }}" data-directory-page="{{ $page['slug'] ?? '' }}" data-directory-last-page="{{ $directoryLastPage }}" aria-label="{{ $page['title'] ?? $section['title'] }}">
@if ($directoryUsesFieldQuery)
<header class="directory-query-page-head">
<div>
<strong>{{ $page['title'] ?? $section['title'] }}</strong>
<p>{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '基础资料的查询与导出' }}</p>
</div>
<span class="sr-only" data-directory-status>{{ $directory['status'] ?? '尚未同步' }}</span>
</header>
@if (!empty($upstreamError))
<div class="directory-table-notice" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $upstreamError }}</span>
</div>
@elseif ($directoryTotal > 0 && $directoryPageKey !== 'company-management/subject-operating-report')
<div class="directory-table-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">cloud_done</span>
<span>已从共享业务镜像读取 {{ $directoryTotal }} 条数据,当前页面为只读展示。</span>
</div>
@endif
@else
<header class="directory-table-head">
<div class="directory-table-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
@ -361,6 +526,7 @@
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $upstreamError }}</span>
</div>
@elseif ($directoryPageKey === 'company-management/subject-operating-report')
@elseif ($directoryTotal > 0)
<div class="directory-table-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">cloud_done</span>
@ -382,9 +548,165 @@
<span>{{ $directory['empty'] ?? '数据尚未同步,当前只展示已确认的字段结构。' }}</span>
</div>
@endif
@endif
@if (!empty($directoryFields))
@if (($page['slug'] ?? '') === 'store-report')
@php
$directoryQueryFields = match ($directoryPageKey) {
'company-management/basic-information' => [
['name' => 'unified_social_credit_code', 'label' => '统一社会信用代码', 'placeholder' => '输入完整或部分代码'],
['name' => 'company_name', 'label' => '公司名称', 'placeholder' => '输入公司名称关键词'],
['name' => 'taxpayer_type', 'label' => '纳税人类型'],
['name' => 'company_status', 'label' => '公司状态'],
['name' => 'legal_representative', 'label' => '法人', 'placeholder' => '按姓名查询'],
['name' => 'city', 'label' => '城市', 'placeholder' => '城市较多,使用搜索'],
['name' => 'related_people', 'label' => '相关人员', 'placeholder' => '输入人员姓名,模糊匹配'],
['name' => 'company_type', 'label' => '公司类型'],
],
'store-management/internal-flow-report' => [
['name' => 'company_name', 'label' => '抬头', 'placeholder' => '输入公司名称关键词'],
['name' => 'tag', 'label' => '标签'],
],
'company-management/tax-forecast' => [
['name' => 'company_name', 'label' => '公司名称', 'placeholder' => '输入公司名称关键词'],
['name' => 'taxpayer_type', 'label' => '公司税务类型'],
['name' => 'business_registration_type', 'label' => '工商类型'],
['name' => 'collection_method', 'label' => '征收方式'],
['name' => 'forecast_month', 'label' => '预测月份', 'placeholder' => '例如 2026-10'],
],
default => $directoryFields,
};
$directoryExcludedQueryFields = match ($directoryPageKey) {
'store-management/basic-information' => ['opened_at', 'renewed_at'],
'trademark-management/information' => ['registration_date', 'expiry_date'],
default => [],
};
if (!empty($directoryExcludedQueryFields)) {
$directoryQueryFields = collect($directoryQueryFields)
->reject(fn (array $field): bool => in_array(
$field['name'],
$directoryExcludedQueryFields,
true,
))
->values()
->all();
}
$directorySelectFields = match (($section['slug'] ?? '') . '/' . ($page['slug'] ?? '')) {
'company-management/basic-information' => ['taxpayer_type', 'company_status', 'company_type'],
'store-management/basic-information' => ['platform', 'store_status', 'withdrawal_account'],
'trademark-management/information' => ['trademark_status'],
'store-management/store-accounts' => ['account_type', 'password_configured'],
'store-management/store-report' => ['status', 'can_change', 'company_type'],
'company-management/subject-operating-report' => ['company_type', 'can_change_entity'],
'company-management/company-cost-arrangement' => ['company_type', 'invoice_type'],
'company-management/tax-forecast' => ['taxpayer_type', 'business_registration_type', 'collection_method'],
'company-management/personnel-inventory' => ['company_type'],
'store-management/internal-flow-report' => ['tag'],
default => [],
};
$directoryColumnNames = collect($directoryFields)->pluck('name')->all();
$requestedDirectoryColumns = array_values(array_intersect(
array_filter(explode(',', (string) request('columns', ''))),
$directoryColumnNames,
));
$visibleDirectoryColumns = request()->has('columns') ? $requestedDirectoryColumns : $directoryColumnNames;
$directoryQueryFormId = 'directory-query-form-' . ($page['slug'] ?? 'page');
@endphp
@if ($directoryUsesFieldQuery)
<div class="directory-query-bar" data-directory-query aria-label="{{ $page['title'] ?? '' }}查询条件">
<div class="directory-query-heading">
<strong>查询条件</strong>
<span>类型较少的字段直接选择,内容较多的字段支持关键词搜索</span>
</div>
<form id="{{ $directoryQueryFormId }}" method="GET" action="{{ request()->url() }}"></form>
@if (request()->has('columns'))
<input type="hidden" form="{{ $directoryQueryFormId }}" name="columns" value="{{ implode(',', $visibleDirectoryColumns) }}">
@endif
@foreach ($directoryQueryFields as $field)
@php $kind = $field['kind'] ?? 'text'; @endphp
@continue($kind === 'secret' && $field['name'] !== 'withdrawal_account')
@continue(($page['slug'] ?? '') === 'store-accounts' && in_array($field['name'], [
'account',
'password_configured',
'bound_phone',
'verification_contact',
'account_group_name',
'created_at',
'updated_at',
], true))
<label class="directory-query-field" data-directory-query-field="{{ $field['name'] }}">
<span title="{{ $field['label'] }}">{{ $field['label'] }}</span>
@if ($kind === 'date')
<span class="directory-query-dates">
<input type="date" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}_from" value="{{ request($field['name'] . '_from') }}" data-directory-query-from="{{ $field['name'] }}" aria-label="{{ $field['label'] }}开始">
<em>至</em>
<input type="date" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}_to" value="{{ request($field['name'] . '_to') }}" data-directory-query-to="{{ $field['name'] }}" aria-label="{{ $field['label'] }}结束">
</span>
@elseif (in_array($field['name'], $directorySelectFields, true))
<select form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}" data-directory-query-select="{{ $field['name'] }}" aria-label="{{ $field['label'] }}">
<option value="">全部</option>
@php
$enumOptions = match ($field['name']) {
'platform' => ['淘宝', '淘宝分销', '阿里巴巴', '天猫', '唯品会', '京东', '京喜', '抖音', '拼多多', '淘工厂', '得物'],
'store_status' => ['正常', '关闭中', '已关闭'],
'withdrawal_account' => ['已配置', '未配置'],
'trademark_status' => ['未启用', '有效'],
'account_type' => ['主账号', '子账号'],
'password_configured' => ['已设置', '未设置', '状态未同步'],
'taxpayer_type' => ['一般纳税人', '小规模纳税人', '个体工商户', '无税务登记', 'CORP'],
'company_status' => ['正常', '已注销'],
'company_type' => ['个体工商户', '个体户', '二级公司', '人力资源公司', '仓储公司', '其他', '合同公司', '品牌公司', '店铺-国标公司', '店铺-国标商标', '店铺-大店公司', '店铺-普通公司', '母公司', '资产型'],
'business_registration_type' => ['有限责任公司', '有限责任公司(自然人独资)', '个人独资企业', '合伙企业', '个体工商户', '其他'],
'collection_method' => ['查账征收', '核定征收', '定期定额'],
'status' => ['0' => '开店中', '3' => '关店中', '6' => '关店'],
'can_change', 'can_change_entity' => ['是', '否'],
'invoice_type' => ['普票', '专票'],
'tag' => ['1', '2', '3', '4'],
default => [],
};
@endphp
@foreach ($enumOptions as $option)
<option value="{{ $option }}" @selected((string) request($field['name']) === $option)>{{ $option }}</option>
@endforeach
</select>
@else
<input type="search" form="{{ $directoryQueryFormId }}" name="{{ $field['name'] }}" value="{{ request($field['name']) }}" autocomplete="off" placeholder="{{ $field['placeholder'] ?? ('搜索' . $field['label']) }}" data-directory-query-input="{{ $field['name'] }}" aria-label="搜索{{ $field['label'] }}">
<ul class="directory-query-suggestions" data-directory-query-suggestions="{{ $field['name'] }}" hidden></ul>
@endif
</label>
@endforeach
<div class="directory-query-actions">
<details class="directory-query-picker" data-directory-query-picker>
<summary>筛选字段</summary>
<div class="directory-query-picker__panel" role="group" aria-label="勾选展示字段">
<span class="directory-query-picker__hint">勾选需要显示的表头,可重复调整。</span>
@foreach ($directoryFields as $field)
<label class="directory-query-picker__option">
<input type="checkbox" value="{{ $field['name'] }}" data-directory-query-column @checked(in_array($field['name'], $visibleDirectoryColumns, true))>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
</div>
</details>
<button type="button" data-directory-query-reset>重置</button>
<button type="submit" form="{{ $directoryQueryFormId }}" class="is-primary">查询</button>
<details class="directory-query-export" data-directory-query-export-picker>
<summary><span class="material-icons-outlined" aria-hidden="true">file_download</span>导出 Excel</summary>
<div class="directory-query-export__panel" role="group" aria-label="选择导出字段">
<span class="directory-query-picker__hint">默认全选,可取消不需要导出的表头。</span>
@foreach ($directoryFields as $field)
<label class="directory-query-export__option">
<input type="checkbox" value="{{ $field['name'] }}" checked data-directory-query-export-field>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
<button type="button" class="directory-query-export__button" data-directory-query-export>导出选中字段</button>
</div>
</details>
</div>
</div>
@endif
@if (($page['slug'] ?? '') === 'store-report' && ! $directoryUsesFieldQuery)
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="店铺维度报表筛选查询栏">
@php
$storeFilterTextFields = [
@ -440,31 +762,60 @@
</div>
</form>
@endif
@if (! $directoryUsesFieldQuery)
<div class="directory-table-tools" data-directory-tools>
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
<input id="directory-search-{{ $page['slug'] ?? 'page' }}" type="search" placeholder="搜索当前页数据..." data-directory-search>
<div class="directory-field-picker" data-directory-field-picker>
<button type="button" aria-expanded="false" aria-controls="directory-fields-{{ $page['slug'] ?? 'page' }}" data-directory-field-toggle>选择字段</button>
<div class="directory-field-picker__panel" id="directory-fields-{{ $page['slug'] ?? 'page' }}" role="group" aria-label="选择展示字段" hidden>
<span class="directory-field-picker__hint">拖动字段调整顺序;固定序号列不可隐藏。</span>
@foreach ($directoryFields as $field)
<label class="directory-field-picker__option">
<label class="directory-field-picker__option" draggable="true" data-directory-field-option>
<input type="checkbox" value="{{ $field['name'] }}" data-directory-field-check @checked(!in_array($field['name'], ['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06'], true))>
<span>{{ $field['label'] }}</span>
</label>
@endforeach
</div>
</div>
<button type="button" data-directory-export>导出当前页</button>
<button type="button" data-directory-export-all>导出全部数据</button>
<div class="directory-export-group" aria-label="导出范围">
<button type="button" data-directory-export="visible">当前页·显示列</button>
<button type="button" data-directory-export="all-fields">当前页·全部字段</button>
<button type="button" data-directory-export-all="visible">全部结果·显示列</button>
<button type="button" data-directory-export-all="all-fields">全部结果·全部字段</button>
</div>
<button type="button" data-directory-reset>恢复默认</button>
<button type="button" data-directory-template>下载表头模板</button>
</div>
@endif
<div class="directory-table-scroll">
<table class="directory-table">
<table class="directory-table" @if($directoryUsesFieldQuery) data-columns-ready="true" @endif>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{{ $directory['index_label'] ?? '#' }}</th>
@foreach ($directoryFields as $field)
<th scope="col" data-column-key="{{ $field['name'] }}" class="{{ ($field['kind'] ?? null) === 'number' ? 'is-number' : '' }}">{{ $field['label'] }}</th>
<th scope="col" data-column-key="{{ $field['name'] }}" class="{{ ($field['kind'] ?? null) === 'number' ? 'is-number' : '' }} {{ !in_array($field['name'], $visibleDirectoryColumns, true) ? 'is-column-hidden' : '' }}">
@if ($directoryUsesFieldQuery)
@php
$isSorted = request('sort') === $field['name'];
$direction = $isSorted ? request('direction', 'asc') : null;
$nextDirection = $isSorted && $direction === 'asc' ? 'desc' : 'asc';
@endphp
<div class="directory-header-menu" data-directory-header-menu="{{ $field['name'] }}">
<span data-directory-header-label>{{ $field['label'] }}</span>
<details>
<summary aria-label="{{ $field['label'] }}排序和字段操作"><span class="material-icons-outlined directory-sort-icon" aria-hidden="true">{{ !$isSorted ? 'unfold_more' : ($direction === 'asc' ? 'arrow_upward' : 'arrow_downward') }}</span></summary>
<div class="directory-header-menu__panel">
<a href="{{ request()->fullUrlWithQuery(['sort' => $field['name'], 'direction' => 'asc', 'page' => 1]) }}">升序</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => $field['name'], 'direction' => 'desc', 'page' => 1]) }}">降序</a>
<button type="button" data-directory-header-hide="{{ $field['name'] }}">隐藏字段</button>
</div>
</details>
</div>
@else
{{ $field['label'] }}
@endif
</th>
@endforeach
</tr>
</thead>
@ -492,7 +843,7 @@
$display = in_array($value, [true, 1, '1', '是', '✔'], true) ? '是' : '否';
}
@endphp
<td data-column-key="{{ $field['name'] }}" class="{{ $kind === 'number' ? 'is-number' : '' }}">
<td data-column-key="{{ $field['name'] }}" class="{{ $kind === 'number' ? 'is-number' : '' }} {{ !in_array($field['name'], $visibleDirectoryColumns, true) ? 'is-column-hidden' : '' }}">
@if (($page['slug'] ?? '') === 'store-accounts' && $field['name'] === 'password_configured')
<span data-password-status>{{ filled($display) ? $display : '—' }}</span>
@if (\App\Utilities\SystemAccess::canOpenSettings() && $value !== false)
@ -562,6 +913,136 @@
<script>
(() => {
const csvEscape = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
document.querySelectorAll('[data-directory-query]').forEach((queryBar) => {
const section = queryBar.closest('[data-directory-page]');
const table = section?.querySelector('.directory-table');
if (!table) return;
const fields = [...queryBar.querySelectorAll('[data-directory-query-field]')];
const rows = () => [...table.querySelectorAll('tbody tr:not(.is-empty)')];
const fieldValues = (row, key) => {
if (key === 'related_people') {
return ['legal_representative', 'shareholders', 'supervisor', 'billing_officer']
.map((column) => (row.querySelector(`[data-column-key="${column}"]`)?.textContent || '').trim())
.filter((value) => value && value !== '—')
.join(' / ');
}
const cell = row.querySelector(`[data-column-key="${CSS.escape(key)}"]`);
return (cell?.textContent || '').trim();
};
const refreshSuggestions = (field, value) => {
const list = field.querySelector('[data-directory-query-suggestions]');
if (!list) return;
const key = field.dataset.directoryQueryField;
const needle = value.trim().toLocaleLowerCase();
const candidates = key === 'related_people'
? rows().flatMap((row) => ['legal_representative', 'shareholders', 'supervisor', 'billing_officer']
.map((column) => (row.querySelector(`[data-column-key="${column}"]`)?.textContent || '').trim()))
: rows().map((row) => fieldValues(row, key));
const values = [...new Set(candidates.filter((item) => item && item !== '—' && (!needle || item.toLocaleLowerCase().includes(needle))))].slice(0, 8);
list.innerHTML = values.map((item) => `<li><button type="button" data-directory-suggestion-value="${item.replaceAll('&','&amp;').replaceAll('\"','&quot;').replaceAll('<','&lt;').replaceAll('>','&gt;')}">${item.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')}</button></li>`).join('');
list.hidden = values.length === 0;
list.querySelectorAll('button').forEach((button) => button.addEventListener('click', () => {
const input = field.querySelector('[data-directory-query-input]');
if (input) { input.value = button.dataset.directorySuggestionValue || ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
list.hidden = true;
}));
};
fields.forEach((field) => {
const input = field.querySelector('[data-directory-query-input]');
const select = field.querySelector('[data-directory-query-select]');
const key = field.dataset.directoryQueryField;
const values = [...new Set(rows().map((row) => fieldValues(row, key)).filter((item) => item && item !== '—'))].sort((a,b) => a.localeCompare(b, 'zh-Hans-CN'));
if (select && select.options.length <= 1) { values.forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value; select.appendChild(option); }); }
input?.addEventListener('input', () => refreshSuggestions(field, input.value));
input?.addEventListener('focus', () => refreshSuggestions(field, input.value));
input?.addEventListener('blur', () => window.setTimeout(() => { const list = field.querySelector('[data-directory-query-suggestions]'); if (list) list.hidden = true; }, 150));
});
const columnChecks = [...queryBar.querySelectorAll('[data-directory-query-column]')];
const pickerSummary = queryBar.querySelector('[data-directory-query-picker] summary');
const applyVisibleColumns = () => {
const selected = columnChecks.filter((check) => check.checked).map((check) => check.value);
table.querySelectorAll('[data-column-key]').forEach((cell) => {
cell.classList.toggle('is-column-hidden', !selected.includes(cell.dataset.columnKey));
});
if (pickerSummary) pickerSummary.textContent = `筛选字段 ${selected.length}/${columnChecks.length}`;
let hiddenColumns = queryBar.querySelector(`input[form="${CSS.escape(queryBar.querySelector('form[id]')?.id || '')}"][name="columns"]`);
if (!hiddenColumns) {
hiddenColumns = document.createElement('input');
hiddenColumns.type = 'hidden';
hiddenColumns.name = 'columns';
hiddenColumns.setAttribute('form', queryBar.querySelector('form[id]')?.id || '');
queryBar.appendChild(hiddenColumns);
}
hiddenColumns.value = selected.join(',');
const url = new URL(window.location.href);
url.searchParams.set('columns', selected.join(','));
url.searchParams.delete('page');
window.history.replaceState({}, '', url);
};
columnChecks.forEach((check) => check.addEventListener('change', applyVisibleColumns));
section.querySelectorAll('[data-directory-header-hide]').forEach((button) => button.addEventListener('click', () => {
const key = button.dataset.directoryHeaderHide;
const check = columnChecks.find((item) => item.value === key);
if (!check) return;
check.checked = false;
check.dispatchEvent(new Event('change', { bubbles: true }));
button.closest('details')?.removeAttribute('open');
}));
queryBar.querySelector('[data-directory-query-reset]')?.addEventListener('click', () => {
const url = new URL(window.location.href);
fields.forEach((field) => {
const key = field.dataset.directoryQueryField;
url.searchParams.delete(key); url.searchParams.delete(`${key}_from`); url.searchParams.delete(`${key}_to`);
});
url.searchParams.delete('page');
window.location.assign(url);
});
queryBar.querySelector('[data-directory-query-export]')?.addEventListener('click', async (event) => {
const button = event.currentTarget;
const picker = button.closest('[data-directory-query-export-picker]');
const selected = new Set([...picker.querySelectorAll('[data-directory-query-export-field]:checked')].map((input) => input.value));
if (selected.size === 0) {
window.alert('请至少勾选一个导出字段');
return;
}
const headerCells = [...table.tHead.rows[0].querySelectorAll('[data-column-key]')]
.filter((cell) => selected.has(cell.dataset.columnKey));
const header = headerCells.map((cell) => csvEscape((cell.querySelector('[data-directory-header-label]') || cell).textContent.trim())).join(',');
const lines = [];
const lastPage = Number(section.dataset.directoryLastPage || 1);
button.disabled = true;
const originalLabel = button.textContent;
try {
for (let page = 1; page <= lastPage; page += 1) {
button.textContent = `导出中 ${page}/${lastPage}`;
const url = new URL(window.location.href);
url.searchParams.set('page', String(page));
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error(`第 ${page} 页读取失败`);
const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
parsed.querySelectorAll('.directory-table tbody tr:not(.is-empty)').forEach((row) => {
const values = headerCells.map((headerCell) => {
const cell = row.querySelector(`[data-column-key="${CSS.escape(headerCell.dataset.columnKey)}"]`);
return csvEscape((cell?.querySelector('[data-password-status]') || cell)?.textContent?.trim() || '');
});
lines.push(values.join(','));
});
}
const blob = new Blob([`\ufeff${[header, ...lines].join('\n')}`], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${section.dataset.directoryPage || 'directory'}-export.csv`;
link.click();
URL.revokeObjectURL(link.href);
picker.open = false;
} catch (error) {
window.alert(error.message || '导出失败,请稍后重试');
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});
});
document.querySelectorAll('[data-directory-tools]').forEach((tools) => {
const section = tools.closest('[data-directory-page]');
const table = section?.querySelector('.directory-table');
@ -571,18 +1052,40 @@
const toggle = picker?.querySelector('[data-directory-field-toggle]');
const panel = picker?.querySelector('.directory-field-picker__panel');
const checks = [...(picker?.querySelectorAll('[data-directory-field-check]') || [])];
const storageKey = `directory-fields:${section.dataset.directoryPage || 'page'}`;
const userKey = @json((string) (auth()->id() ?? 'guest'));
const tabKey = new URL(window.location.href).searchParams.get('tab') || 'default';
const storageKey = `directory-fields:${userKey}:${section.dataset.directoryPage || 'page'}:${tabKey}`;
const defaultOrder = checks.map((check) => check.value);
const orderedChecks = () => [...(picker?.querySelectorAll('[data-directory-field-option]') || [])]
.map((option) => option.querySelector('[data-directory-field-check]'))
.filter(Boolean);
const applyColumns = (save = true) => {
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
const ordered = orderedChecks();
const selected = new Set(ordered.filter((check) => check.checked).map((check) => check.value));
const rank = new Map(ordered.map((check, index) => [check.value, index]));
table.dataset.columnsReady = 'true';
table.querySelectorAll('[data-column-key]').forEach((cell) => {
cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.columnKey));
});
if (save) localStorage.setItem(storageKey, JSON.stringify([...selected]));
[...table.tHead.rows].forEach((row) => {
[...row.children].sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1))
.forEach((cell) => row.appendChild(cell));
});
table.tBodies[0]?.querySelectorAll('tr').forEach((row) => {
[...row.children].slice(1).sort((a, b) => (rank.get(a.dataset.columnKey) ?? -1) - (rank.get(b.dataset.columnKey) ?? -1))
.forEach((cell) => row.appendChild(cell));
});
if (save) localStorage.setItem(storageKey, JSON.stringify({ order: ordered.map((check) => check.value), selected: [...selected] }));
};
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (Array.isArray(saved)) checks.forEach((check) => { check.checked = saved.includes(check.value); });
const savedOrder = Array.isArray(saved) ? saved : saved?.order;
const savedSelected = Array.isArray(saved) ? saved : saved?.selected;
if (Array.isArray(savedSelected)) checks.forEach((check) => { check.checked = savedSelected.includes(check.value); });
if (Array.isArray(savedOrder)) savedOrder.forEach((key) => {
const option = picker.querySelector(`[data-directory-field-option] input[value="${CSS.escape(key)}"]`)?.closest('[data-directory-field-option]');
if (option) picker.querySelector('.directory-field-picker__panel').appendChild(option);
});
} catch (_) {}
applyColumns(false);
toggle?.addEventListener('click', () => {
@ -591,28 +1094,47 @@
toggle.setAttribute('aria-expanded', String(isHidden));
});
checks.forEach((check) => check.addEventListener('change', () => applyColumns()));
let dragging = null;
picker?.querySelectorAll('[data-directory-field-option]').forEach((option) => {
option.addEventListener('dragstart', () => { dragging = option; option.classList.add('is-dragging'); });
option.addEventListener('dragend', () => { dragging = null; option.classList.remove('is-dragging'); picker.querySelectorAll('.is-drop-target').forEach((item) => item.classList.remove('is-drop-target')); applyColumns(); });
option.addEventListener('dragover', (event) => { event.preventDefault(); if (dragging && dragging !== option) option.classList.add('is-drop-target'); });
option.addEventListener('dragleave', () => option.classList.remove('is-drop-target'));
option.addEventListener('drop', (event) => { event.preventDefault(); if (!dragging || dragging === option) return; option.parentNode.insertBefore(dragging, option); option.classList.remove('is-drop-target'); applyColumns(); });
});
const resetColumns = () => {
const options = [...picker.querySelectorAll('[data-directory-field-option]')];
const byKey = new Map(options.map((option) => [option.querySelector('[data-directory-field-check]').value, option]));
defaultOrder.forEach((key) => picker.querySelector('.directory-field-picker__panel').appendChild(byKey.get(key)));
checks.forEach((check) => { check.checked = !['sales_01', 'sales_02', 'sales_03', 'sales_04', 'sales_05', 'sales_06'].includes(check.value); });
applyColumns();
};
const resetButton = tools.querySelector('[data-directory-reset]');
if (resetButton) resetButton.onclick = resetColumns;
search.addEventListener('input', () => {
const query = search.value.trim().toLocaleLowerCase();
table.querySelectorAll('tbody tr').forEach((row) => {
row.hidden = Boolean(query) && !row.textContent.toLocaleLowerCase().includes(query);
});
});
tools.querySelector('[data-directory-export]')?.addEventListener('click', () => {
tools.querySelectorAll('[data-directory-export]').forEach((button) => button.addEventListener('click', () => {
const selected = button.dataset.directoryExport === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value));
const rows = [...table.querySelectorAll('tr')].filter((row) => !row.hidden);
const csv = rows.map((row) => [...row.children].map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
const cellsForExport = (row) => [...row.children].filter((cell) => !cell.dataset.columnKey || !selected || selected.has(cell.dataset.columnKey));
const csv = rows.map((row) => cellsForExport(row).map((cell) => csvEscape((cell.querySelector('[data-password-status]') || cell).textContent.trim())).join(',')).join('\n');
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${section.dataset.directoryPage || 'directory'}-export.csv`;
link.download = `${section.dataset.directoryPage || 'directory'}-current-export.csv`;
link.click();
URL.revokeObjectURL(link.href);
});
tools.querySelector('[data-directory-export-all]')?.addEventListener('click', async (event) => {
}));
tools.querySelectorAll('[data-directory-export-all]').forEach((button) => button.addEventListener('click', async (event) => {
const button = event.currentTarget;
const lastPage = Number(section.dataset.directoryLastPage || 1);
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
const selected = button.dataset.directoryExportAll === 'all-fields' ? null : new Set(orderedChecks().filter((check) => check.checked).map((check) => check.value));
const cellsForExport = (row) => [...row.children]
.filter((cell) => !cell.dataset.columnKey || selected.has(cell.dataset.columnKey));
.filter((cell) => !cell.dataset.columnKey || !selected || selected.has(cell.dataset.columnKey));
const header = cellsForExport(table.tHead.rows[0]).map((cell) => csvEscape(cell.textContent.trim())).join(',');
const lines = [];
button.disabled = true;
@ -643,7 +1165,7 @@
button.disabled = false;
button.textContent = originalLabel;
}
});
}));
tools.querySelector('[data-directory-template]')?.addEventListener('click', () => {
const header = [...table.querySelectorAll('thead th')].map((cell) => csvEscape(cell.textContent.trim())).join(',');
const blob = new Blob([`\ufeff${header}\n`], { type: 'text/csv;charset=utf-8' });

View File

@ -13,7 +13,15 @@
],
'warnings' => [
'title' => '预警页面', 'description' => '集中查看经营、税务、人员与资质风险',
'columns' => ['预警编号', '风险等级', '类型', '关联对象', '影响金额', '当前状态'],
'columns' => ['预警编号', '预警类型', '风险等级', '关联对象', '风险描述', '数据依据', '影响', '触发规则', '触发时间', '责任人', '截止时间', '处理状态', '复审人', '复审时间', '复审备注'],
],
'entity-trademark-data-logic' => [
'title' => '店铺主体商标数据逻辑配置', 'description' => '店铺主体与商标数据的来源优先级、期间切割和冲突处理规则',
'columns' => ['规则编号', '规则类别', '规则对象', '规则逻辑', '数据来源', '优先级说明', '最近命中次数', '生效时间', '状态'],
],
'store-report-data-logic' => [
'title' => '店铺维度报表逻辑配置', 'description' => '店铺维度报表的期初数据、爬虫新主体和生效/失效时间规则',
'columns' => ['规则编号', '规则类别', '规则对象', '规则逻辑', '数据来源', '优先级说明', '状态'],
],
'tasks' => [
'title' => '待办事项', 'description' => '跟进任务节点、责任人与处理时限',
@ -44,6 +52,10 @@
'title' => '主体维度经营报表', 'description' => '按公司主体汇总店铺经营表现',
'columns' => ['市', '主体', '公司类型', 'ERP系统店铺名称', '店铺所属利润中心', '店铺负责人', '部门负责人', '开店时间', '状态', ...$recentCompletedMonthSalesColumns, '连续12个月店铺累计销量', '连续12个月主体累计销量', '能否变更主体', '规划', '新主体(变更流程同步新主体)'],
],
'company-cost-arrangement' => [
'title' => '公司成本安排', 'description' => '按公司主体查看成本安排',
'columns' => ['公司', '公司类型', '发票类型', '当月收入预测', '往期成本缺口', '供应商成本安排', '快递成本安排', '当月税金预测', '人工成本预测', '其他费用预测'],
],
'trademark-progress' => [
'title' => '商标进度表', 'description' => '跟踪商标续展与变更业务',
'columns' => ['商标名称', '商标号', '业务', '进度', '是否完成', '完成时间', '办理人'],
@ -64,6 +76,10 @@
'title' => '店铺维度报表', 'description' => '查看店铺销量及历史主体归属',
'columns' => ['ERP系统店铺名称', '店铺ID(唯一值)', '店铺所属利润中心', '店铺负责人', '部门负责人', '开店时间', '状态', '能否变更', '主体', '公司类型', '生效时间', '失效时间', '期间销量', '1月销量', '2月销量', '3月销量', '4月销量', '5月销量', '6月销量', '7月销量', '8月销量', '9月销量', '10月销量', '11月销量', '12月销量'],
],
'internal-flow-report' => [
'title' => '内部流转报表', 'description' => '查看内部流转事项及办理状态',
'columns' => [],
],
];
$definition = $definitions[$slug] ?? ['title' => $page['title'] ?? '业务页面', 'description' => '', 'columns' => [], 'rows' => []];
@endphp

View File

@ -0,0 +1,158 @@
@php
use Modules\EcommerceFinance\Services\ShopEntityTrademarkLogic;
$rules = ShopEntityTrademarkLogic::rules();
@endphp
<style>
.logic-edit-page { max-width: 1100px; margin-top: 1.5rem; }
.logic-edit-card {
margin-bottom: 1.25rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.logic-edit-card h3 { margin: 0 0 0.35rem; font-size: 1rem; font-weight: 600; }
.logic-edit-card p.hint { margin: 0 0 1rem; color: #7c7565; font-size: 0.8125rem; line-height: 1.35rem; }
.logic-edit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 0.9rem 1.25rem; }
.logic-edit-field label { display: block; margin-bottom: 0.3rem; color: #4b4638; font-size: 0.8125rem; font-weight: 600; }
.logic-edit-field input[type="text"],
.logic-edit-field input[type="number"],
.logic-edit-field select {
width: 100%;
padding: 0.45rem 0.65rem;
border: 1px solid #d8d2c7;
border-radius: 0.45rem;
background: #ffffff;
color: #33302a;
font-size: 0.8125rem;
}
.logic-edit-field .field-hint { margin-top: 0.25rem; color: #8b8577; font-size: 0.75rem; }
.logic-edit-actions { display: flex; gap: 0.6rem; align-items: center; }
.logic-edit-actions button {
padding: 0.5rem 1.2rem;
border: 0;
border-radius: 0.45rem;
background: #2a2927;
color: #f2e7c5;
font-size: 0.875rem;
cursor: pointer;
}
.logic-edit-actions a { color: #8a6d1f; font-size: 0.875rem; text-decoration: underline; }
.logic-edit-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.logic-edit-table th {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
text-align: left;
white-space: nowrap;
}
.logic-edit-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f0ece4; color: #33302a; vertical-align: top; }
</style>
<div class="logic-edit-page">
<form method="POST" action="{{ route('business.entity-trademark-logic.update') }}">
@csrf
<div class="logic-edit-card">
<h3>运行参数</h3>
<p class="hint">这里改的是与代码一致的运行口径,保存后店铺基础信息页和预警检查按新参数执行。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>多商标分隔符</label>
<input type="text" name="trademark_separator" maxlength="8" value="{{ $settings['trademark_separator'] ?? '、' }}">
<div class="field-hint">同一店铺多个商标时,商标名和商标号都用这个符号串联。</div>
</div>
<div class="logic-edit-field">
<label>同日主体冲突处理</label>
<select name="same_day_conflict_policy">
<option value="crawl_wins" @selected(($settings['same_day_conflict_policy'] ?? 'crawl_wins') === 'crawl_wins')>以爬虫为准,同时生成预警</option>
<option value="flow_wins" @selected(($settings['same_day_conflict_policy'] ?? '') === 'flow_wins')>以流程为准,仅生成预警</option>
</select>
<div class="field-hint">对应规则 R6;每条冲突都会留预警给责任人复审。</div>
</div>
<div class="logic-edit-field">
<label>商标号查不到基础资料时</label>
<select name="missing_trademark_action">
<option value="warn_keep" @selected(($settings['missing_trademark_action'] ?? 'warn_keep') === 'warn_keep')>保留爬虫结果显示并预警</option>
<option value="warn_hide" @selected(($settings['missing_trademark_action'] ?? '') === 'warn_hide')>预警并回退系统商标资料</option>
</select>
<div class="field-hint">对应规则 R9。</div>
</div>
<div class="logic-edit-field">
<label>爬虫未更新检查</label>
<select name="stale_warning_enabled">
<option value="1" @selected(($settings['stale_warning_enabled'] ?? '1') === '1')>开启</option>
<option value="0" @selected(($settings['stale_warning_enabled'] ?? '') === '0')>关闭</option>
</select>
<div class="field-hint">对应规则 R7。</div>
</div>
<div class="logic-edit-field">
<label>超过多少天算未更新</label>
<input type="number" name="stale_after_days" min="1" max="30" value="{{ $settings['stale_after_days'] ?? 1 }}">
<div class="field-hint">默认 1 天,即今天没有新抓取数据就报警。</div>
</div>
</div>
</div>
<div class="logic-edit-card">
<h3>预警责任人</h3>
<p class="hint">留空时回退到系统内第一个启用用户;页面上的预警仍可单独改责任人。</p>
<div class="logic-edit-grid">
<div class="logic-edit-field">
<label>爬虫未更新</label>
<input type="text" name="owner_stale" maxlength="191" value="{{ $settings['owner_stale'] ?? '' }}" placeholder="姓名或账号">
</div>
<div class="logic-edit-field">
<label>主体名称不一致</label>
<input type="text" name="owner_subject_name" maxlength="191" value="{{ $settings['owner_subject_name'] ?? '' }}" placeholder="姓名或账号">
</div>
<div class="logic-edit-field">
<label>同日主体冲突</label>
<input type="text" name="owner_subject_conflict" maxlength="191" value="{{ $settings['owner_subject_conflict'] ?? '' }}" placeholder="姓名或账号">
</div>
<div class="logic-edit-field">
<label>关联缺失</label>
<input type="text" name="owner_trademark" maxlength="191" value="{{ $settings['owner_trademark'] ?? '' }}" placeholder="姓名或账号">
</div>
</div>
</div>
<div class="logic-edit-actions">
<button type="submit">保存配置</button>
<a href="{{ route('business.pages.show', ['section' => 'trademark-management', 'page' => 'entity-trademark-data-logic']) }}">返回规则页</a>
</div>
<input type="hidden" name="crawl_priority" value="1">
</form>
<div class="logic-edit-card" style="margin-top: 1.25rem;">
<h3>规则目录(只读)</h3>
<p class="hint">规则编号和逻辑说明与代码实现保持一致,用于核对上面参数的实际含义。</p>
<table class="logic-edit-table">
<thead>
<tr>
<th>规则编号</th>
<th>规则类别</th>
<th>规则对象</th>
<th>规则逻辑</th>
<th>优先级说明</th>
</tr>
</thead>
<tbody>
@foreach ($rules as $category => $categoryRules)
@foreach ($categoryRules as $rule)
<tr>
<td>{{ $rule['code'] }}</td>
<td>{{ $category }}</td>
<td>{{ $rule['object'] }}</td>
<td>{{ $rule['logic'] }}</td>
<td>{{ $rule['priority'] }}</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,220 @@
@php
use Modules\EcommerceFinance\Services\ShopEntityTrademarkLogic;
use Illuminate\Support\Facades\DB;
$rules = ShopEntityTrademarkLogic::rules();
$sources = [
[
'name' => '店铺对应主体(爬虫)',
'object' => 'rpt.crawl_shop_company',
'frequency' => '每天抓取',
'notice' => '最新抓取日期、更新时间停止前进时报警并指派责任人。',
],
[
'name' => '店铺对应商标(爬虫)',
'object' => 'rpt.crawl_shop_trademark',
'frequency' => '每天抓取',
'notice' => '多商标按同一顺序用「、」串联名称和号码。',
],
[
'name' => '流程主体期间',
'object' => 'fys_shopInfo_flow',
'frequency' => '流程触发',
'notice' => '爬虫没有覆盖的店铺继续沿用;同日冲突以爬虫为准并留预警。',
],
[
'name' => '工商主体资料',
'object' => 'fys_companyInfo',
'frequency' => '只读',
'notice' => '用于按 tax_no 归一主体名称。',
],
[
'name' => '商标基础资料',
'object' => 'fys_TrademarkInfo',
'frequency' => '只读',
'notice' => '爬虫商标号查不到资料时报警,但页面仍显示爬虫结果。',
],
];
// 数据源新鲜度:直接读共享业务镜像的同步状态,页面不访问上游。
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$sourceHealth = [];
foreach ([
'店铺对应主体(爬虫)' => ['table' => 'ops_crawl_shop_company', 'dataset' => ShopEntityTrademarkLogic::CRAWL_COMPANY_DATASET],
'店铺对应商标(爬虫)' => ['table' => 'ops_crawl_shop_trademark', 'dataset' => ShopEntityTrademarkLogic::CRAWL_TRADEMARK_DATASET],
] as $name => $meta) {
$health = ['status' => '尚未首次同步', 'latest_date' => null, 'latest_update' => null, 'shops' => 0];
try {
if ($connection->getSchemaBuilder()->hasTable($meta['table'])) {
$row = $connection->table($meta['table'])
->selectRaw('MAX(crawl_date) AS latest_date, MAX(update_time) AS latest_update, COUNT(DISTINCT shop_name) AS shops')
->first();
$health['latest_date'] = $row->latest_date ?? null;
$health['latest_update'] = $row->latest_update ?? null;
$health['shops'] = (int) ($row->shops ?? 0);
if ($health['latest_date'] !== null) {
$days = now()->startOfDay()->diffInDays(\Illuminate\Support\Carbon::parse($health['latest_date'])->startOfDay());
$health['status'] = $days < 1 ? '今天已更新' : ($days === 1 ? '昨天更新' : $days . ' 天未更新');
}
}
} catch (\Throwable) {
$health['status'] = '读取失败';
}
$sourceHealth[$name] = $health;
}
@endphp
<style>
.data-logic-page { margin-top: 1.5rem; }
.data-logic-note {
padding: 1rem 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
line-height: 1.5rem;
}
.data-logic-card {
margin-top: 1.25rem;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.data-logic-card h3 {
margin: 0;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
font-size: 0.9375rem;
font-weight: 600;
}
.data-logic-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.data-logic-table th {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.data-logic-table td {
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #f0ece4;
color: #33302a;
vertical-align: top;
line-height: 1.4rem;
}
.data-logic-table tr:last-child td { border-bottom: 0; }
.data-logic-code { font-weight: 600; color: #8a6d1f; white-space: nowrap; }
.data-logic-category { white-space: nowrap; }
.data-logic-tag {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.4rem;
background: #f4efe3;
color: #7a6428;
font-size: 0.75rem;
white-space: nowrap;
}
.data-logic-health {
margin-top: 0.35rem;
color: #7c7565;
font-size: 0.75rem;
line-height: 1.15rem;
white-space: nowrap;
}
.data-logic-edit-button {
padding: 0.45rem 1.1rem;
border-radius: 0.45rem;
background: #2a2927;
color: #f2e7c5 !important;
font-size: 0.8125rem;
text-decoration: none;
white-space: nowrap;
}
</style>
<div class="data-logic-page">
<div class="data-logic-note" style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem;">
<span>规则参数由负责人维护,修改后立即生效。</span>
<a class="data-logic-edit-button" href="{{ route('business.entity-trademark-logic.edit') }}">编辑配置</a>
</div>
<div class="data-logic-note">
本页是店铺主体、店铺商标数据当前实际使用的运行口径。实现与页面说明不一致时以负责人确认后的口径为准;
发生同日主体冲突、爬虫断更、期间异常或关联缺失时,系统按这些规则强制处理并生成预警,由指定责任人人工复审。
</div>
<div class="data-logic-card">
<h3>数据来源</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>来源</th>
<th>上游对象</th>
<th>更新频率</th>
<th>规则要点</th>
<th>当前状态</th>
</tr>
</thead>
<tbody>
@foreach ($sources as $source)
<tr>
<td>{{ $source['name'] }}</td>
<td><code>{{ $source['object'] }}</code></td>
<td>{{ $source['frequency'] }}</td>
<td>{{ $source['notice'] }}</td>
<td>
@php $health = $sourceHealth[$source['name']] ?? null; @endphp
@if ($health === null)
<span class="data-logic-tag">只读</span>
@else
<span class="data-logic-tag">{{ $health['status'] }}</span>
@if ($health['latest_date'] !== null)
<div class="data-logic-health">
最新抓取 {{ $health['latest_date'] }}<br>
更新时间 {{ $health['latest_update'] }}<br>
覆盖店铺 {{ $health['shops'] }}
</div>
@endif
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="data-logic-card">
<h3>规则清单</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>规则编号</th>
<th>规则类别</th>
<th>规则对象</th>
<th>规则逻辑</th>
<th>数据来源</th>
<th>优先级说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@foreach ($rules as $category => $categoryRules)
@foreach ($categoryRules as $rule)
<tr>
<td class="data-logic-code">{{ $rule['code'] }}</td>
<td class="data-logic-category">{{ $category }}</td>
<td>{{ $rule['object'] }}</td>
<td>{{ $rule['logic'] }}</td>
<td>{{ $rule['source'] }}</td>
<td>{{ $rule['priority'] }}</td>
<td><span class="data-logic-tag">已生效</span></td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,150 @@
<style>
.period-edit-page { margin-top: 1.5rem; }
.period-edit-card {
margin-bottom: 1.25rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.period-edit-card h3 { margin: 0 0 0.35rem; font-size: 1rem; font-weight: 600; }
.period-edit-card p.hint { margin: 0 0 1rem; color: #7c7565; font-size: 0.8125rem; line-height: 1.35rem; }
.period-edit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: 0.9rem 1rem; }
.period-edit-field label { display: block; margin-bottom: 0.3rem; color: #4b4638; font-size: 0.8125rem; font-weight: 600; }
.period-edit-field input {
width: 100%;
padding: 0.45rem 0.65rem;
border: 1px solid #d8d2c7;
border-radius: 0.45rem;
font-size: 0.8125rem;
}
.period-edit-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.period-edit-table th {
padding: 0.6rem 0.7rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
text-align: left;
white-space: nowrap;
}
.period-edit-table td { padding: 0.5rem 0.7rem; border-bottom: 1px solid #f0ece4; vertical-align: middle; }
.period-edit-table input { width: 100%; padding: 0.35rem 0.5rem; border: 1px solid #d8d2c7; border-radius: 0.4rem; font-size: 0.8125rem; }
.period-edit-table button {
padding: 0.35rem 0.8rem;
border: 0;
border-radius: 0.4rem;
background: #2a2927;
color: #f2e7c5;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}
.period-edit-table button.is-danger { border: 1px solid #d9b3ac; background: #fbeae7; color: #a52a1a; }
.period-edit-scroll { max-height: 34rem; overflow: auto; border: 1px solid var(--vip-border, #e5e0d6); border-radius: 0.6rem; }
.period-edit-actions { display: flex; gap: 0.6rem; align-items: center; margin-top: 1rem; }
.period-edit-actions a { color: #8a6d1f; font-size: 0.875rem; text-decoration: underline; }
.period-edit-metric { display: inline-block; margin-right: 1.25rem; color: #4b4638; font-size: 0.8125rem; }
.period-edit-metric strong { color: #171717; font-size: 1.05rem; }
</style>
<div class="period-edit-page">
<div class="period-edit-card">
<h3>期初主体数据</h3>
<p class="hint">
每行代表一家已经变更主体的店铺:老主体从期初生效日生效、失效时间为变更日前一天;新主体从变更当天生效并显示「存续中」。
保存后店铺维度报表和主体维度报表按这里的期间拆分销量,无需改代码。
</p>
<div>
<span class="period-edit-metric">期初生效日 <strong>{{ $initialEffectiveAt }}</strong></span>
<span class="period-edit-metric">已登记变更店铺 <strong>{{ count($initialShops) }}</strong> 家</span>
</div>
</div>
<div class="period-edit-card">
<h3>新增 / 更新一家店铺</h3>
<p class="hint">店铺名称与店铺基础信息页一致时才会生效;同一个店铺重复保存会覆盖原来的期间。</p>
<form method="POST" action="{{ route('business.store-report-logic.shops.save') }}">
@csrf
<div class="period-edit-grid">
<div class="period-edit-field">
<label>店铺名称</label>
<input type="text" name="store_name" maxlength="191" required placeholder="ERP 系统店铺名称">
</div>
<div class="period-edit-field">
<label>老主体</label>
<input type="text" name="previous_company" maxlength="191" required>
</div>
<div class="period-edit-field">
<label>新主体</label>
<input type="text" name="company" maxlength="191" required>
</div>
<div class="period-edit-field">
<label>变更日期</label>
<input type="date" name="changed_at" required>
</div>
</div>
<div class="period-edit-actions">
<button type="submit" class="period-save-button" style="padding: 0.5rem 1.2rem; border: 0; border-radius: 0.45rem; background: #2a2927; color: #f2e7c5; font-size: 0.875rem; cursor: pointer;">保存</button>
<a href="{{ route('business.pages.show', ['section' => 'trademark-management', 'page' => 'store-report-data-logic']) }}">返回规则页</a>
</div>
</form>
</div>
<div class="period-edit-card">
<h3>已登记的变更店铺({{ count($initialShops) }} 家)</h3>
<div class="period-edit-scroll">
<table class="period-edit-table">
<thead>
<tr>
<th>店铺名称</th>
<th>老主体</th>
<th>新主体</th>
<th>变更日期</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@forelse ($initialShops as $storeName => $rule)
@php $formId = 'period-form-' . md5($storeName); @endphp
<tr>
<td>
{{ $storeName }}
<input type="hidden" name="store_name" value="{{ $storeName }}" form="{{ $formId }}">
</td>
<td><input type="text" name="previous_company" maxlength="191" value="{{ $rule['previous_company'] }}" required form="{{ $formId }}"></td>
<td><input type="text" name="company" maxlength="191" value="{{ $rule['company'] }}" required form="{{ $formId }}"></td>
<td><input type="date" name="changed_at" value="{{ $rule['changed_at'] }}" required form="{{ $formId }}"></td>
<td><button type="submit" form="{{ $formId }}">保存</button></td>
</tr>
@empty
<tr><td colspan="5">尚未登记期初变更店铺。</td></tr>
@endforelse
</tbody>
</table>
</div>
@foreach ($initialShops as $storeName => $rule)
<form id="period-form-{{ md5($storeName) }}" method="POST" action="{{ route('business.store-report-logic.shops.save') }}" style="display: none;">
@csrf
</form>
@endforeach
<p class="hint" style="margin-top: 0.75rem;">删除请使用下方按钮:删除后该店铺按财务系统当前主体生成一条期间。</p>
<form method="POST" action="{{ route('business.store-report-logic.shops.destroy') }}" onsubmit="return confirm('确认删除这家店铺的期初主体记录?');">
@csrf
@method('DELETE')
<div class="period-edit-grid">
<div class="period-edit-field">
<label>要删除的店铺名称</label>
<input type="text" name="store_name" maxlength="191" list="initial-shop-names" required placeholder="输入或选择店铺名称">
<datalist id="initial-shop-names">
@foreach (array_keys($initialShops) as $storeName)
<option value="{{ $storeName }}"></option>
@endforeach
</datalist>
</div>
</div>
<div class="period-edit-actions">
<button type="submit" class="is-danger" style="padding: 0.45rem 1.1rem; border: 1px solid #d9b3ac; border-radius: 0.45rem; background: #fbeae7; color: #a52a1a; font-size: 0.875rem; cursor: pointer;">删除记录</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,337 @@
@php
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\StoreReportLogic;
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
$rules = StoreReportLogic::rules();
$initialDate = StoreReportPeriodBuilder::initialEffectiveAt();
$initialShops = \Modules\EcommerceFinance\Services\StoreReportInitialPeriods::shops();
$changeDates = array_filter(array_map(
static fn (array $rule): string => (string) ($rule['changed_at'] ?? ''),
$initialShops,
));
sort($changeDates);
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema = $connection->getSchemaBuilder();
$changeTotal = 0;
$health = [
'stores' => ['status' => '尚未同步', 'detail' => ''],
'crawler' => ['status' => '尚未同步', 'detail' => ''],
'changes' => ['status' => '尚未生成', 'detail' => ''],
'daily' => ['status' => '尚未同步', 'detail' => ''],
];
try {
if ($schema->hasTable('ops_fys_shop_info')) {
$row = $connection->table('ops_fys_shop_info')
->selectRaw('COUNT(*) AS total, SUM(CASE WHEN shopState = 0 THEN 1 ELSE 0 END) AS normal')
->first();
$health['stores'] = [
'status' => '已同步',
'detail' => sprintf('店铺 %d 家,其中在营 %d 家', (int) ($row->total ?? 0), (int) ($row->normal ?? 0)),
];
}
if ($schema->hasTable('ops_crawl_shop_company')) {
$row = $connection->table('ops_crawl_shop_company')
->selectRaw('COUNT(DISTINCT shop_name) AS shops, MAX(crawl_date) AS latest_date')
->first();
$health['crawler'] = [
'status' => '已同步',
'detail' => sprintf(
'覆盖店铺 %d 家,最新抓取 %s',
(int) ($row->shops ?? 0),
(string) ($row->latest_date ?? '暂无'),
),
];
}
if ($schema->hasTable(StoreReportPeriodBuilder::CHANGES_TABLE)) {
$row = $connection->table(StoreReportPeriodBuilder::CHANGES_TABLE)
->selectRaw('COUNT(*) AS total, COUNT(DISTINCT erp_store_name) AS shops, MAX(observed_on) AS latest_date')
->first();
$health['changes'] = [
'status' => '已生效',
'total' => (int) ($row->total ?? 0),
'detail' => sprintf(
'变更记录 %d 条,涉及店铺 %d 家,最近 %s',
(int) ($row->total ?? 0),
(int) ($row->shops ?? 0),
(string) ($row->latest_date ?? '暂无'),
),
];
$changeTotal = (int) ($row->total ?? 0);
}
if ($schema->hasTable('store_report_daily_rows')) {
$row = $connection->table('store_report_daily_rows')
->selectRaw('MAX(occurred_on) AS latest_date, COUNT(DISTINCT store_name) AS shops')
->first();
$health['daily'] = [
'status' => '已同步',
'detail' => sprintf(
'日汇总覆盖店铺 %d 家,最新日期 %s',
(int) ($row->shops ?? 0),
(string) ($row->latest_date ?? '暂无'),
),
];
}
} catch (\Throwable) {
// 镜像暂时不可用时页面保留规则说明,不阻塞查看配置。
}
$recentChanges = collect();
try {
if ($schema->hasTable(StoreReportPeriodBuilder::CHANGES_TABLE)) {
$recentChanges = $connection->table(StoreReportPeriodBuilder::CHANGES_TABLE)
->orderByDesc('observed_on')
->orderByDesc('id')
->limit(20)
->get();
}
} catch (\Throwable) {
$recentChanges = collect();
}
@endphp
<style>
.data-logic-page { margin-top: 1.5rem; }
.data-logic-note {
padding: 1rem 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
line-height: 1.5rem;
}
.data-logic-card {
margin-top: 1.25rem;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.data-logic-card h3 {
margin: 0;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
font-size: 0.9375rem;
font-weight: 600;
}
.data-logic-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.data-logic-table th {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.data-logic-table td {
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #f0ece4;
color: #33302a;
vertical-align: top;
line-height: 1.4rem;
}
.data-logic-table tr:last-child td { border-bottom: 0; }
.data-logic-code { font-weight: 600; color: #8a6d1f; white-space: nowrap; }
.data-logic-category { white-space: nowrap; }
.data-logic-tag {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.4rem;
background: #f4efe3;
color: #7a6428;
font-size: 0.75rem;
white-space: nowrap;
}
.data-logic-health {
margin-top: 0.35rem;
color: #7c7565;
font-size: 0.75rem;
line-height: 1.15rem;
}
.data-logic-summary { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.25rem; }
.data-logic-summary > div {
flex: 1 1 12rem;
padding: 0.9rem 1.1rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.data-logic-summary span { display: block; color: #7c7565; font-size: 0.75rem; }
.data-logic-summary strong { display: block; margin-top: 0.25rem; font-size: 1.1rem; color: #171717; }
.data-logic-edit-button {
padding: 0.45rem 1.1rem;
border-radius: 0.45rem;
background: #2a2927;
color: #f2e7c5 !important;
font-size: 0.8125rem;
text-decoration: none;
white-space: nowrap;
}
</style>
<div class="data-logic-page">
<div class="data-logic-note" style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem;">
<span>期初主体数据由负责人维护,可新增、修改、删除。</span>
<a class="data-logic-edit-button" href="{{ route('business.store-report-logic.edit') }}">编辑期初数据</a>
</div>
<div class="data-logic-note">
本页是店铺维度报表当前实际使用的期间口径:报表按「期初数据 + 财务系统主体 + 爬虫新主体」生成店铺主体期间,
用于把店铺销量拆分到不同主体,避免主体少申报或多申报收入。同一店铺同一期间只显示一行,
变更日当天归新主体,老主体统计到变更日前一天;关店和非正常状态的两列时间显示「-」。
</div>
<div class="data-logic-summary">
<div>
<span>期初起始日</span>
<strong>{{ $initialDate }}</strong>
</div>
<div>
<span>期初变更店铺</span>
<strong>{{ count($initialShops) }} 家</strong>
</div>
<div>
<span>期初变更日期范围</span>
<strong>{{ $changeDates === [] ? '暂无' : reset($changeDates) . ' 至 ' . end($changeDates) }}</strong>
</div>
<div>
<span>爬虫已识别新主体</span>
<strong>{{ $changeTotal }} 条</strong>
</div>
</div>
<div class="data-logic-card">
<h3>数据来源</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>来源</th>
<th>数据对象</th>
<th>更新方式</th>
<th>规则要点</th>
<th>当前状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>期初数据</td>
<td><code>{{ StoreReportLogic::INITIAL_PERIODS_CONFIG }}</code></td>
<td>负责人维护</td>
<td>已变更店铺的老主体、新主体和变更日期,是期间计算的基准。</td>
<td>
<span class="data-logic-tag">已生效</span>
<div class="data-logic-health">变更店铺 {{ count($initialShops) }} 家</div>
</td>
</tr>
<tr>
<td>店铺主体(财务系统)</td>
<td><code>ops_fys_shop_info + ops_fys_company_info</code></td>
<td>共享镜像</td>
<td>未列入期初数据的店铺按系统登记主体自 2026-07-01 起生成期间。</td>
<td>
<span class="data-logic-tag">{{ $health['stores']['status'] }}</span>
<div class="data-logic-health">{{ $health['stores']['detail'] }}</div>
</td>
</tr>
<tr>
<td>店铺对应主体(爬虫)</td>
<td><code>ops_crawl_shop_company</code></td>
<td>每天抓取</td>
<td>爬虫主体按税号归一到工商主体名称,名称笔误不产生新期间。</td>
<td>
<span class="data-logic-tag">{{ $health['crawler']['status'] }}</span>
<div class="data-logic-health">{{ $health['crawler']['detail'] }}</div>
</td>
</tr>
<tr>
<td>爬虫主体变更记录</td>
<td><code>{{ StoreReportPeriodBuilder::CHANGES_TABLE }}</code></td>
<td>同步任务写入</td>
<td>记录首次抓到的新主体日期,固定新主体生效时间,后续抓取不再前移。</td>
<td>
<span class="data-logic-tag">{{ $health['changes']['status'] }}</span>
<div class="data-logic-health">{{ $health['changes']['detail'] }}</div>
</td>
</tr>
<tr>
<td>店铺日汇总</td>
<td><code>store_report_daily_rows</code></td>
<td>共享镜像</td>
<td>收入按实际发生日期归属到对应主体期间,变更日当天归新主体。</td>
<td>
<span class="data-logic-tag">{{ $health['daily']['status'] }}</span>
<div class="data-logic-health">{{ $health['daily']['detail'] }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="data-logic-card">
<h3>规则清单</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>规则编号</th>
<th>规则类别</th>
<th>规则对象</th>
<th>规则逻辑</th>
<th>数据来源</th>
<th>优先级说明</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@foreach ($rules as $category => $categoryRules)
@foreach ($categoryRules as $rule)
<tr>
<td class="data-logic-code">{{ $rule['code'] }}</td>
<td class="data-logic-category">{{ $category }}</td>
<td>{{ $rule['object'] }}</td>
<td>{{ $rule['logic'] }}</td>
<td>{{ $rule['source'] }}</td>
<td>{{ $rule['priority'] }}</td>
<td><span class="data-logic-tag">已生效</span></td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
<div class="data-logic-card">
<h3>最近爬虫识别的新主体</h3>
<table class="data-logic-table">
<thead>
<tr>
<th>店铺</th>
<th>新主体</th>
<th>统一社会信用代码</th>
<th>首次抓到日期</th>
<th>生成期间</th>
</tr>
</thead>
<tbody>
@forelse ($recentChanges as $change)
<tr>
<td>{{ $change->erp_store_name }}</td>
<td>{{ $change->company_name }}</td>
<td>{{ $change->unified_social_credit_code ?: '—' }}</td>
<td>{{ $change->observed_on }}</td>
<td>{{ $change->observed_on }} 起,失效时间显示存续中</td>
</tr>
@empty
<tr>
<td colspan="5">尚未识别到爬虫新主体。爬虫抓到的店铺主体与当前期间主体不同时,这里会记录并生成新期间。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,267 @@
@php
$warningTypes = [
['type' => '爬虫未更新', 'level' => '高', 'trigger' => '最新抓取日期距今超过 1 天,或更新时间停止前进', 'rule' => 'R7'],
['type' => '同日主体冲突', 'level' => '高', 'trigger' => '流程表与爬虫同一天给出不同主体,系统已按爬虫覆盖期间', 'rule' => 'R6'],
['type' => '期间异常', 'level' => '中', 'trigger' => '同一店铺期间重复存续、断档或重叠', 'rule' => 'R9'],
['type' => '关联缺失', 'level' => '中', 'trigger' => '爬虫主体或商标号在财务系统查不到对应资料', 'rule' => 'R9'],
['type' => '主体名称不一致', 'level' => '低', 'trigger' => '爬虫公司名与 tax_no 对应工商主体名不一致', 'rule' => 'R8'],
];
$warningColumns = [
'预警编号', '预警类型', '风险等级', '关联对象', '风险描述', '数据依据', '影响',
'触发规则', '触发时间', '责任人', '截止时间', '处理状态', '复审人', '复审时间', '复审备注',
];
$rows = $warningRows ?? [];
$filters = $warningFilters ?? [];
$levelLabels = ['high' => '高', 'mid' => '中', 'low' => '低'];
@endphp
<style>
.warning-page { margin-top: 1.5rem; }
.warning-page-note {
padding: 1rem 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
line-height: 1.5rem;
}
.warning-page-card {
margin-top: 1.25rem;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.warning-page-card h3 {
margin: 0;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
font-size: 0.9375rem;
font-weight: 600;
}
.warning-page-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.warning-page-table th {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #faf8f4;
color: #6b6455;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.warning-page-table td {
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #f0ece4;
color: #33302a;
vertical-align: top;
line-height: 1.4rem;
}
.warning-page-table tr:last-child td { border-bottom: 0; }
.warning-level {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.4rem;
font-size: 0.75rem;
white-space: nowrap;
}
.warning-level-high { background: #fbeae7; color: #a52a1a; }
.warning-level-mid { background: #faf3e3; color: #8a6d1f; }
.warning-level-low { background: #eef3f8; color: #3f5f86; }
.warning-empty {
padding: 1.5rem 1.1rem;
color: #8b8577;
font-size: 0.875rem;
text-align: center;
}
.warning-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #e5e0d6);
background: #fdfcfa;
font-size: 0.8125rem;
}
.warning-filter-bar input,
.warning-filter-bar select {
min-width: 8rem;
padding: 0.35rem 0.6rem;
border: 1px solid #d8d2c7;
border-radius: 0.4rem;
background: #ffffff;
color: #33302a;
font-size: 0.8125rem;
}
.warning-filter-bar button {
padding: 0.35rem 0.9rem;
border: 0;
border-radius: 0.4rem;
background: #2a2927;
color: #f2e7c5;
font-size: 0.8125rem;
cursor: pointer;
}
.warning-filter-bar a {
align-self: center;
color: #8a6d1f;
text-decoration: underline;
}
.warning-filter-summary {
align-self: center;
margin-left: auto;
color: #6b6455;
}
.warning-actions form {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
min-width: 14rem;
}
.warning-actions input {
flex: 1 1 6rem;
min-width: 5rem;
padding: 0.25rem 0.4rem;
border: 1px solid #d8d2c7;
border-radius: 0.35rem;
font-size: 0.75rem;
}
.warning-actions button {
padding: 0.25rem 0.6rem;
border: 1px solid #cbbf9a;
border-radius: 0.35rem;
background: #f4efe3;
color: #6b5518;
font-size: 0.75rem;
cursor: pointer;
}
.warning-actions button.is-danger {
border-color: #d9b3ac;
background: #fbeae7;
color: #a52a1a;
}
.warning-closed { color: #8b8577; font-size: 0.75rem; }
</style>
<div class="warning-page">
<div class="warning-page-note">
预警集中展示爬虫断更、同日主体冲突、期间异常、关联缺失和主体名称不一致四类数据问题。
每条预警都指派责任人并记录处理状态、复审人、复审时间和复审备注;同日主体冲突会先按爬虫强制覆盖期间,再等待责任人确认或驳回。
</div>
<div class="warning-page-card">
<h3>预警规则</h3>
<table class="warning-page-table">
<thead>
<tr>
<th>预警类型</th>
<th>风险等级</th>
<th>触发条件</th>
<th>触发规则</th>
</tr>
</thead>
<tbody>
@foreach ($warningTypes as $warning)
<tr>
<td>{{ $warning['type'] }}</td>
<td>
<span class="warning-level {{ $warning['level'] === '高' ? 'warning-level-high' : ($warning['level'] === '中' ? 'warning-level-mid' : 'warning-level-low') }}">{{ $warning['level'] }}</span>
</td>
<td>{{ $warning['trigger'] }}</td>
<td>{{ $warning['rule'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="warning-page-card">
<h3>预警记录</h3>
<form class="warning-filter-bar" method="GET" action="{{ request()->url() }}">
<input type="text" name="warning_number" value="{{ $filters['warning_number'] ?? '' }}" placeholder="预警编号">
<input type="text" name="related_object" value="{{ $filters['related_object'] ?? '' }}" placeholder="关联对象">
<input type="text" name="responsible_person" value="{{ $filters['responsible_person'] ?? '' }}" placeholder="责任人">
<select name="warning_type">
<option value="">全部类型</option>
@foreach ($warningTypes as $warning)
<option value="{{ $warning['type'] }}" @selected(($filters['warning_type'] ?? '') === $warning['type'])>{{ $warning['type'] }}</option>
@endforeach
</select>
<select name="risk_level">
<option value="">全部等级</option>
@foreach (['high' => '高', 'mid' => '中', 'low' => '低'] as $value => $label)
<option value="{{ $value }}" @selected(($filters['risk_level'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<select name="review_status">
<option value="">全部状态</option>
@foreach (['pending' => '待复审', 'confirmed' => '已确认', 'rejected' => '已驳回'] as $value => $label)
<option value="{{ $value }}" @selected(($filters['review_status'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
<button type="submit">查询</button>
<a href="{{ request()->url() }}">重置</a>
<span class="warning-filter-summary">
共 {{ (int) ($warningTotal ?? 0) }} 条,待复审 {{ (int) ($warningPending ?? 0) }} 条
</span>
</form>
<table class="warning-page-table">
<thead>
<tr>
@foreach ($warningColumns as $column)
<th>{{ $column }}</th>
@endforeach
<th>复审动作</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row['warning_number'] }}</td>
<td>{{ $row['warning_type'] }}</td>
<td>
<span class="warning-level {{ $row['risk_level'] === 'high' ? 'warning-level-high' : ($row['risk_level'] === 'mid' ? 'warning-level-mid' : 'warning-level-low') }}">
{{ $levelLabels[$row['risk_level']] ?? $row['risk_level'] }}
</span>
</td>
<td>{{ $row['related_object'] }}</td>
<td>{{ $row['risk_description'] }}</td>
<td>{{ $row['evidence'] }}</td>
<td>{{ $row['impact'] }}</td>
<td>{{ $row['trigger_rule'] }}</td>
<td>{{ $row['triggered_at'] }}</td>
<td>{{ $row['responsible_person'] }}</td>
<td>{{ $row['due_at'] }}</td>
<td>{{ $row['review_status_label'] }}</td>
<td>{{ $row['reviewer'] }}</td>
<td>{{ $row['reviewed_at'] }}</td>
<td>{{ $row['review_note'] }}</td>
<td class="warning-actions">
@if ($row['review_status'] === 'pending')
<form method="POST" action="{{ route('business.warnings.review', ['warning' => $row['id']]) }}">
@csrf
<input type="text" name="responsible_person" value="{{ $row['responsible_person'] }}" placeholder="责任人">
<input type="text" name="review_note" placeholder="复审备注">
<button type="submit" name="decision" value="confirmed">确认</button>
<button type="submit" name="decision" value="rejected" class="is-danger">驳回</button>
</form>
@else
<span class="warning-closed">已处理</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="{{ count($warningColumns) + 1 }}" class="warning-empty">
@if ($warningReady ?? false)
当前没有符合条件的预警记录。
@else
预警数据表尚未迁移。执行数据库迁移后,这里会显示风险记录并指派责任人。
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View File

@ -1 +0,0 @@
@include('business.pages.partials.prototype-page')

View File

@ -149,7 +149,10 @@
]],
['title' => '经营报表', 'items' => [
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
['company-management', 'tax-forecast', '税金预测表', 'calculate'],
['store-management', 'store-report', '店铺维度报表', 'storefront'],
['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'],
['company-management', 'personnel-inventory', '人员盘点表', 'groups'],
]],
['title' => '基础资料', 'items' => [
@ -161,13 +164,16 @@
['title' => '规则信息', 'items' => [
['trademark-management', 'subject-trademark-rules', '主体商标规则', 'rule'],
['trademark-management', 'store-change-rules', '店铺变更规则', 'rule'],
['trademark-management', 'entity-trademark-data-logic', '店铺主体商标数据逻辑配置', 'account_tree'],
['trademark-management', 'store-report-data-logic', '店铺维度报表逻辑配置', 'table_chart'],
['trademark-management', 'tax-forecast-data-logic', '税金预测表逻辑配置', 'calculate'],
['trademark-management', 'warnings', '预警页面', 'warning_amber'],
]],
['title' => '业务进度', 'items' => [
['company-management', 'company-progress', '公司进度表', 'timeline'],
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
['workspace', 'warnings', '预警页面', 'warning_amber'],
]],
['title' => '财务报表', 'items' => [
['prototype-finance-reports', 'profit-report', '公司利润报表', 'request_quote'],

View File

@ -139,6 +139,22 @@ Route::get('workspace/financial-operations/installation-details/details/{guid}',
Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails')
->name('business.installation-details.export');
Route::get('workspace/trademark-management/entity-trademark-data-logic/edit', 'Business\Pages@editEntityTrademarkLogic')
->middleware('business.page:trademark-management,entity-trademark-data-logic')
->name('business.entity-trademark-logic.edit');
Route::get('workspace/trademark-management/store-report-data-logic/edit', 'Business\Pages@editStoreReportLogic')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.edit');
Route::get('workspace/trademark-management/tax-forecast-data-logic/edit', 'Business\Pages@editTaxForecastLogic')
->middleware('business.page:trademark-management,tax-forecast-data-logic')
->name('business.tax-forecast-logic.edit');
Route::post('workspace/trademark-management/tax-forecast-data-logic/edit', 'Business\Pages@updateTaxForecastLogic')
->middleware('business.page:trademark-management,tax-forecast-data-logic')
->name('business.tax-forecast-logic.update');
Route::prefix('workspace/courier-payables/courier-surcharge')
->name('business.courier-surcharge.')
->middleware('business.page:courier-payables,courier-surcharge')
@ -153,6 +169,23 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');
Route::post('workspace/trademark-management/warnings/{warning}/review', 'Business\Pages@reviewWarning')
->middleware('business.page:trademark-management,warnings')
->where('warning', '[0-9]+')
->name('business.warnings.review');
Route::post('workspace/trademark-management/entity-trademark-data-logic/edit', 'Business\Pages@updateEntityTrademarkLogic')
->middleware('business.page:trademark-management,entity-trademark-data-logic')
->name('business.entity-trademark-logic.update');
Route::post('workspace/trademark-management/store-report-data-logic/shops', 'Business\Pages@updateStoreReportInitialPeriod')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.shops.save');
Route::delete('workspace/trademark-management/store-report-data-logic/shops', 'Business\Pages@destroyStoreReportInitialPeriod')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.shops.destroy');
Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
->middleware('import')
->name('business.pages.import');

View File

@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Business;
use Mockery;
use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository;
use Tests\Feature\FeatureTestCase;
class InternalFlowReportPageTest extends FeatureTestCase
{
public function testInternalFlowReportPageRendersRowsWithQueryAndExportTools(): void
{
$repository = Mockery::mock(InternalFlowReportRepository::class);
$repository->shouldReceive('ready')->once()->andReturn(true);
$repository->shouldReceive('paginate')->once()->andReturn([
'data' => [[
'id' => 1,
'sort_order' => 1,
'company_name' => '示例甲供应链有限公司',
'tag' => '1',
'projected_sales' => '1200000.000000',
'invoiced_to_buyer' => '0.000000',
'internal_invoiced' => '1200000.000000',
'required_input_cost' => '0.000000',
'receipt_from_supplier' => null,
'receipt_from_suofeiya' => null,
'receipt_from_gujia' => null,
'internal_transfer_1' => '0.000000',
'internal_transfer_2' => '0.000000',
'internal_transfer_3' => '0.000000',
'receipt_total' => '0.000000',
'remaining_gap' => '0.000000',
'invoicing_ratio' => '1',
]],
'total' => 1,
]);
$this->app->instance(InternalFlowReportRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'store-management',
'page' => 'internal-flow-report',
]))
->assertOk()
->assertSeeText('内部流转报表')
->assertSeeText('按抬头、标签查询内部流转明细并导出')
->assertSeeText('示例甲供应链有限公司')
->assertSeeText('预计9月销量')
->assertSeeText('收票-内部流转(来源)1')
->assertSeeText('剩余缺口')
->assertSeeText('开票比例')
->assertSee('data-directory-query-picker', false)
->assertSee('data-directory-query-export', false)
->assertSee('data-directory-query-field="company_name"', false)
->assertSee('data-directory-query-select="tag"', false)
->assertSeeText('筛选字段')
->assertSeeText('导出 Excel')
->assertSee('排序和字段操作', false)
->assertSee('sort=projected_sales', false);
}
public function testInternalFlowReportPageExplainsMissingDataset(): void
{
$repository = Mockery::mock(InternalFlowReportRepository::class);
$repository->shouldReceive('ready')->once()->andReturn(false);
$this->app->instance(InternalFlowReportRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'store-management',
'page' => 'internal-flow-report',
]))
->assertOk()
->assertSeeText('内部流转数据尚未导入');
}
}

View File

@ -166,11 +166,13 @@ class ManagementCrudTest extends FeatureTestCase
'shareholders' => null,
]);
$this->loginAs()
$response = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'basic-information',
]))
]));
$response
->assertOk()
->assertSeeText('公司基础信息')
->assertSeeText('认缴注册资本(万元)')
@ -179,6 +181,13 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('已配置')
->assertSeeText('测试上游公司')
->assertSeeText('既有只读上游')
->assertSee('aria-label="公司基础信息查询条件"', false)
->assertSeeText('相关人员')
->assertSeeText('筛选字段')
->assertSeeText('导出 Excel')
->assertSee('data-directory-query-export-field', false)
->assertDontSee('<div class="directory-table-tools" data-directory-tools>', false)
->assertDontSee('name="registered_at"', false)
->assertDontSeeText('导入')
->assertDontSeeText('新增');
}
@ -211,6 +220,11 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('店铺名称')
->assertSeeText('店铺主体')
->assertSeeText('业务部门')
->assertSee('data-column-key="opened_at"', false)
->assertSee('data-column-key="renewed_at"', false)
->assertDontSee('name="opened_at_from"', false)
->assertDontSee('name="opened_at_to"', false)
->assertDontSee('name="renewed_at"', false)
->assertSeeText('测试上游店铺')
->assertSeeText('测试关联公司')
->assertSeeText('既有只读上游')
@ -283,6 +297,12 @@ class ManagementCrudTest extends FeatureTestCase
->assertOk()
->assertSeeText('商标名称')
->assertSeeText('注册时间')
->assertSee('data-column-key="registration_date"', false)
->assertSee('data-column-key="expiry_date"', false)
->assertDontSee('name="registration_date_from"', false)
->assertDontSee('name="registration_date_to"', false)
->assertDontSee('name="expiry_date_from"', false)
->assertDontSee('name="expiry_date_to"', false)
->assertSeeText('测试上游商标')
->assertSeeText('既有只读上游')
->assertDontSeeText('导入')
@ -295,7 +315,7 @@ class ManagementCrudTest extends FeatureTestCase
$sections = collect($navigation)->keyBy('slug');
$this->assertSame(
['subject-operating-report', 'basic-information', 'personnel-inventory', 'company-progress'],
['subject-operating-report', 'company-cost-arrangement', 'tax-forecast', 'basic-information', 'personnel-inventory', 'company-progress'],
array_column($sections['company-management']['pages'], 'slug')
);
$this->assertSame(
@ -305,15 +325,15 @@ class ManagementCrudTest extends FeatureTestCase
$rulesPage = collect($sections['trademark-management']['pages'])->firstWhere('slug', 'rules-information');
$this->assertNotNull($rulesPage);
$this->assertSame(
['subject-trademark-rules', 'store-change-rules'],
['subject-trademark-rules', 'store-change-rules', 'entity-trademark-data-logic', 'store-report-data-logic', 'tax-forecast-data-logic', 'warnings'],
array_column($rulesPage['children'], 'slug')
);
$this->assertSame(
['store-report', 'basic-information', 'store-accounts', 'change-progress'],
['store-report', 'internal-flow-report', 'basic-information', 'store-accounts', 'change-progress'],
array_column($sections['store-management']['pages'], 'slug')
);
$this->assertSame(
['todos', 'warnings'],
['todos'],
array_column($sections['other']['pages'], 'slug')
);
@ -337,6 +357,120 @@ class ManagementCrudTest extends FeatureTestCase
$this->assertArrayNotHasKey('opening-process', collect($sections['store-management']['pages'])->keyBy('slug'));
}
public function testDataLogicConfigurationCanBeEditedFromThePage(): void
{
setting([
'business_data_logic.trademark_separator' => '、',
'business_data_logic.stale_after_days' => '1',
])->save();
$this->loginAs()
->get(route('business.entity-trademark-logic.edit'))
->assertOk()
->assertSeeText('运行参数')
->assertSeeText('预警责任人');
$this->post(route('business.entity-trademark-logic.update'), [
'trademark_separator' => '/',
'same_day_conflict_policy' => 'crawl_wins',
'missing_trademark_action' => 'warn_keep',
'stale_warning_enabled' => '1',
'stale_after_days' => 3,
'owner_stale' => '数据组',
'owner_subject_name' => '财务张三',
'owner_subject_conflict' => '财务李四',
'owner_trademark' => '商标组',
'crawl_priority' => '1',
])->assertRedirect(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'entity-trademark-data-logic',
]));
$this->assertSame('/', setting('business_data_logic.trademark_separator'));
$this->assertSame('3', (string) setting('business_data_logic.stale_after_days'));
$this->assertSame('财务李四', setting('business_data_logic.owner.subject_conflict'));
setting([
'business_data_logic.trademark_separator' => '、',
'business_data_logic.stale_after_days' => '1',
])->save();
}
public function testTaxForecastLogicConfigurationCanBeEditedFromThePage(): void
{
$this->loginAs()
->get(route('business.tax-forecast-logic.edit'))
->assertOk()
->assertSeeText('小规模增值税征收率')
->assertSeeText('小规模季度免税额度')
->assertSeeText('核定应税所得率默认值')
->assertSeeText('规则目录(只读)');
$this->post(route('business.tax-forecast-logic.update'), [
'vat_small_rate' => '1',
'vat_free_threshold' => '300000',
'vat_general_enabled' => '1',
'surtax_city_rate' => '7',
'surtax_education_rate' => '3',
'surtax_local_education_rate' => '2',
'surtax_halved' => '1',
'stamp_duty_rate' => '0.03',
'stamp_duty_halved' => '1',
'cit_effective_rate' => '5',
'cit_threshold' => '3000000',
'individual_halved_threshold' => '2000000',
'assessed_income_rate' => '12',
'income_forecast_source' => 'cost_arrangement',
])->assertRedirect(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'tax-forecast-data-logic',
]));
$this->assertSame('12', (string) setting('business_tax_forecast.assessed_income_rate'));
$this->assertSame('cost_arrangement', setting('business_tax_forecast.income_forecast_source'));
$this->loginAs()
->get(route('business.pages.show', ['section' => 'trademark-management', 'page' => 'tax-forecast-data-logic']))
->assertOk()
->assertSeeText('当前运行参数')
->assertSeeText('规则清单')
->assertSeeText('R12')
->assertSeeText('小型微利企业所得税')
->assertSeeText('12%');
setting([
'business_tax_forecast.assessed_income_rate' => '10',
'business_tax_forecast.income_forecast_source' => 'last_month_actual',
])->save();
}
public function testStoreReportInitialPeriodsCanBeAddedAndRemoved(): void
{
$this->loginAs()
->get(route('business.store-report-logic.edit'))
->assertOk()
->assertSeeText('期初主体数据')
->assertSeeText('新增 / 更新一家店铺');
$shopKey = sha1(mb_strtolower('测试-编辑店铺'));
$this->post(route('business.store-report-logic.shops.save'), [
'store_name' => '测试-编辑店铺',
'previous_company' => '旧主体',
'company' => '新主体',
'changed_at' => '2026-09-20',
])->assertRedirect(route('business.store-report-logic.edit'));
$this->assertSame('新主体', setting('store_report.initial_periods.shop.' . $shopKey . '.company'));
$this->assertContains('测试-编辑店铺', explode("\n", (string) setting('store_report.initial_periods.shop_list')));
$this->delete(route('business.store-report-logic.shops.destroy'), [
'store_name' => '测试-编辑店铺',
])->assertRedirect(route('business.store-report-logic.edit'));
$this->assertNull(setting('store_report.initial_periods.shop.' . $shopKey . '.company'));
$this->assertNotContains('测试-编辑店铺', explode("\n", (string) setting('store_report.initial_periods.shop_list')));
}
public function testWorkbookDirectoryPagesRenderTheirDeclaredHeaders(): void
{
$this->mockUpstreamPage((object) [
@ -379,15 +513,46 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('核定使用商品')
->assertSeeText('保管部门');
$this->get(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'entity-trademark-data-logic',
]))
->assertOk()
->assertSeeText('规则清单')
->assertSeeText('爬虫优先')
->assertSeeText('R5');
$this->get(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'warnings',
]))
->assertOk()
->assertSeeText('预警类型')
->assertSeeText('同日主体冲突')
->assertSeeText('责任人');
$this->get(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'store-report-data-logic',
]))
->assertOk()
->assertSeeText('店铺维度报表逻辑配置')
->assertSeeText('规则清单')
->assertSeeText('期初数据')
->assertSeeText('爬虫抓到的新主体')
->assertSeeText('R8');
$expectedHeaders = [
['section' => 'company-management', 'page' => 'subject-operating-report', 'headers' => ['市', '主体', '最近12个月累计收入', '1月收入', '12月收入']],
['section' => 'company-management', 'page' => 'company-cost-arrangement', 'headers' => ['公司', '公司类型', '发票类型', '当月收入预测', '往期成本缺口', '供应商成本安排', '快递成本安排', '当月税金预测', '人工成本预测', '其他费用预测']],
['section' => 'company-management', 'page' => 'tax-forecast', 'headers' => ['序号', '公司名称', '公司税务类型', '工商类型', '征收方式', '预计增值税', '企业所得税/经营所得个税', '税金合计']],
['section' => 'company-management', 'page' => 'personnel-inventory', 'headers' => ['人员名称', '税务局密码', '可担任公司数量']],
['section' => 'company-management', 'page' => 'company-progress', 'headers' => ['公司进度表字段尚未定义']],
['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']],
['section' => 'store-management', 'page' => 'store-report', 'headers' => ['店铺ID(唯一值)', '1月销量', '12月销量']],
['section' => 'store-management', 'page' => 'change-progress', 'headers' => ['接替公司', '完成时间', '办理人']],
['section' => 'other', 'page' => 'todos', 'headers' => ['待办编号', '是否逾期', '关闭日期']],
['section' => 'other', 'page' => 'warnings', 'headers' => ['风险等级', '影响金额', '处理证据']],
['section' => 'trademark-management', 'page' => 'warnings', 'headers' => ['预警编号', '风险等级', '责任人', '处理状态', '复审备注']],
];
foreach ($expectedHeaders as $page) {
@ -402,6 +567,29 @@ class ManagementCrudTest extends FeatureTestCase
$response->assertSeeText($header);
}
}
$this->get(route('business.pages.show', [
'section' => 'store-management',
'page' => 'store-report',
]))
->assertOk()
->assertSee('data-directory-query-picker', false)
->assertSee('data-directory-query-export', false)
->assertSeeText('筛选字段')
->assertSeeText('导出 Excel')
->assertSee('position: sticky', false)
->assertSee('overflow: auto', false);
$this->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'tax-forecast',
]))
->assertOk()
->assertSeeText('按税务类型、工商类型和征收方式查询税金预测口径')
->assertSeeText('字段已定义')
->assertSeeText('个人独资企业')
->assertSeeText('核定征收')
->assertSeeText('查账征收');
}
private function mockUpstreamPage(object $row): void

View File

@ -102,7 +102,8 @@ class PrototypePageTest extends FeatureTestCase
->assertSeeText('收入汇总')
->assertSeeText('测试主体')
->assertSeeText('共 1 条')
->assertSeeText('已从共享业务镜像读取')
->assertDontSeeText('已从共享业务镜像读取')
->assertDontSee('<div class="directory-table-tools" data-directory-tools>', false)
->assertDontSeeText('经营报表尚未同步。完成店铺主体和销量镜像接入后')
->assertDontSeeText('经营报表共享镜像尚未完成同步。');
}

View File

@ -0,0 +1,185 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository;
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class InternalFlowReportImportTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$migration = require base_path(
'database/migrations/2026_10_05_000000_create_business_internal_flow_records_table.php'
);
$migration->up();
}
public function testImportKeepsWorkbookValuesAndSkipsTotalRows(): void
{
$path = $this->writeWorkbook($this->dataRows());
try {
$result = app(InternalFlowWorkbookImporter::class)->import($path);
} finally {
@unlink($path);
}
$this->assertSame(3, $result['imported']);
$this->assertSame(1, $result['skipped']);
$rows = DB::connection('business_mirror')
->table(InternalFlowWorkbookImporter::TABLE)
->orderBy('sort_order')
->get();
$this->assertCount(3, $rows);
$this->assertSame('示例甲供应链有限公司', $rows[0]->company_name);
$this->assertSame('1', (string) $rows[0]->tag);
$this->assertSame('1', $rows[0]->invoicing_ratio);
$this->assertEqualsWithDelta(1200000.0, (float) $rows[0]->projected_sales, 0.000001);
$this->assertSame(1, (int) $rows[0]->sort_order);
$this->assertSame('示例乙电子商务有限公司', $rows[1]->company_name);
$this->assertEqualsWithDelta(512345.6789, (float) $rows[1]->internal_invoiced, 0.000001);
$this->assertEqualsWithDelta(1833433.2211, (float) $rows[1]->required_input_cost, 0.000001);
$this->assertEqualsWithDelta(300000.5, (float) $rows[1]->internal_transfer_1, 0.000001);
$this->assertEqualsWithDelta(1200000.5, (float) $rows[1]->receipt_total, 0.000001);
$this->assertSame('0.42', $rows[1]->invoicing_ratio);
$this->assertSame('示例丙家居有限公司', $rows[2]->company_name);
$this->assertNull($rows[2]->invoiced_to_buyer);
$this->assertEqualsWithDelta(220000.11, (float) $rows[2]->receipt_total, 0.000001);
$this->assertSame('/', $rows[2]->invoicing_ratio);
$this->assertSame(basename($path), $rows[0]->source_file);
$this->assertSame(
0,
DB::connection('business_mirror')
->table(InternalFlowWorkbookImporter::TABLE)
->where('company_name', '合计')
->count(),
);
}
public function testImportReplacesPreviousDatasetAndCommandReportsCounts(): void
{
$first = $this->writeWorkbook($this->dataRows());
app(InternalFlowWorkbookImporter::class)->import($first);
@unlink($first);
$second = $this->writeWorkbook([
['示例乙电子商务有限公司', 2, 2345678.9, 456789.12, 512345.6789, 1833433.2211, 900000, null, null, 300000.5, 0, 0, 1200000.5, 633432.7211, 0.42],
]);
try {
$this->artisan('internal-flow:import', ['path' => $second])
->expectsOutputToContain('明细 1 行')
->assertSuccessful();
} finally {
@unlink($second);
}
$rows = DB::connection('business_mirror')
->table(InternalFlowWorkbookImporter::TABLE)
->get();
$this->assertCount(1, $rows);
$this->assertSame('示例乙电子商务有限公司', $rows[0]->company_name);
}
public function testRepositoryFiltersSortsAndPaginates(): void
{
$path = $this->writeWorkbook($this->dataRows());
app(InternalFlowWorkbookImporter::class)->import($path);
@unlink($path);
$repository = app(InternalFlowReportRepository::class);
$this->assertTrue($repository->ready());
$all = $repository->paginate(1, 50);
$this->assertSame(3, $all['total']);
$this->assertSame('示例甲供应链有限公司', $all['data'][0]['company_name']);
$filtered = $repository->paginate(1, 50, ['company_name' => '示例乙']);
$this->assertSame(1, $filtered['total']);
$this->assertSame('示例乙电子商务有限公司', $filtered['data'][0]['company_name']);
$tagged = $repository->paginate(1, 50, ['tag' => '4']);
$this->assertSame(1, $tagged['total']);
$this->assertSame('示例丙家居有限公司', $tagged['data'][0]['company_name']);
$sorted = $repository->paginate(1, 50, [], 'projected_sales', 'desc');
$this->assertSame('示例乙电子商务有限公司', $sorted['data'][0]['company_name']);
$unknownSort = $repository->paginate(1, 50, [], 'company_name; drop table users', 'desc');
$this->assertSame(3, $unknownSort['total']);
$this->assertSame('示例甲供应链有限公司', $unknownSort['data'][0]['company_name']);
$paged = $repository->paginate(2, 2);
$this->assertSame(3, $paged['total']);
$this->assertCount(1, $paged['data']);
$this->assertSame('示例丙家居有限公司', $paged['data'][0]['company_name']);
}
/**
* @return list<list<mixed>>
*/
private function dataRows(): array
{
return [
['示例甲供应链有限公司', 1, 1200000, 0, 1200000, 0, null, null, null, 0, 0, 0, 0, 0, 1],
['示例乙电子商务有限公司', 2, 2345678.9, 456789.12, 512345.6789, 1833433.2211, 900000, null, null, 300000.5, 0, 0, 1200000.5, 633432.7211, 0.42],
['示例丙家居有限公司', 4, 123456.78, null, null, 987654.32, null, null, null, 0, 0, 220000.11, 220000.11, 767654.21, '/'],
['合计', null, 3679135.68, 456789.12, 1712345.6789, 2821087.5411, 900000, 0, null, 300000.5, 0, 0, 1420000.61, 1401086.9311, '/'],
[null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
];
}
/**
* @param list<list<mixed>> $dataRows
*/
private function writeWorkbook(array $dataRows): string
{
$temporary = tempnam(sys_get_temp_dir(), 'internal-flow-');
$path = dirname($temporary) . DIRECTORY_SEPARATOR . 'internal-flow-' . basename($temporary) . '.xlsx';
rename($temporary, $path);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(InternalFlowWorkbookImporter::SHEET_TITLE);
$sheet->fromArray([
['抬头', '标签', '预计9月销量', '开票', null, '所需进项成本', '收票', null, null, null, null, null, null, '剩余缺口', '开票比例'],
[null, null, null, null, null, null, '供应商', '索菲亚', '顾家', '内部流转(来源)', null, null, '合计', null, null],
[null, null, null, '给买家开票', '内部开票', null, null, null, null, '1', '2', '3', null, null, null],
...$dataRows,
], null, 'A1');
foreach (['A1:A3', 'B1:B3', 'C1:C3', 'D1:E2', 'F1:F3', 'G1:M1', 'H2:H3', 'I2:I3', 'J2:L2', 'M2:M3', 'N1:N3', 'O1:O3'] as $range) {
$sheet->mergeCells($range);
}
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return $path;
}
}

View File

@ -115,6 +115,47 @@ class StoreReportMirrorSyncTest extends TestCase
];
}
public function test_it_records_first_seen_crawler_subject_change_once(): void
{
$mirror = DB::connection('business_mirror');
$mirror->table('ops_fys_shop_info')->insert([
['erpShopName' => '店铺A', 'unifiedSocialCreditCode' => 'CODE-A', 'shopState' => '0'],
['erpShopName' => '店铺B', 'unifiedSocialCreditCode' => 'CODE-C', 'shopState' => '0'],
]);
$mirror->table('ops_fys_company_info')->insert([
['unifiedSocialCreditCode' => 'CODE-A', 'businessLicenseName' => '主体甲'],
['unifiedSocialCreditCode' => 'CODE-B', 'businessLicenseName' => '主体乙'],
['unifiedSocialCreditCode' => 'CODE-C', 'businessLicenseName' => '主体丙'],
]);
$mirror->table('ops_crawl_shop_company')->insert([
// 同一店铺历史最新一行仍是老主体,最新一行抓到新主体。
['id' => 1, 'shop_name' => '店铺A', 'company_name' => '主体甲', 'tax_no' => 'CODE-A', 'crawl_date' => '2026-09-28'],
['id' => 2, 'shop_name' => '店铺A', 'company_name' => '主体乙', 'tax_no' => 'CODE-B', 'crawl_date' => '2026-09-29'],
// 名称笔误但税号相同:归一后仍是主体丙,不产生新期间。
['id' => 3, 'shop_name' => '店铺B', 'company_name' => '主体丙(笔误)', 'tax_no' => 'CODE-C', 'crawl_date' => '2026-09-29'],
]);
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
$upstream->method('lifecycleBatch')->willReturn([]);
$upstream->method('billBatch')->willReturn([]);
$synchronizer = new StoreReportMirrorSynchronizer($upstream, app('db'));
$first = $synchronizer->sync(100, 2);
$second = $synchronizer->sync(100, 2);
$this->assertSame('succeeded', $first['period_changes']['status']);
$this->assertSame(2, $first['period_changes']['checked']);
$this->assertSame(1, $first['period_changes']['created']);
$this->assertSame(0, $second['period_changes']['created']);
$this->assertSame(1, $mirror->table('store_report_shop_period_changes')->count());
$this->assertDatabaseHas('store_report_shop_period_changes', [
'erp_store_name' => '店铺A',
'company_name' => '主体乙',
'unified_social_credit_code' => 'CODE-B',
'observed_on' => '2026-09-29',
], 'business_mirror');
}
private function activeState(string $dataset, string $generation, int $cursor, int $rows): void
{
DB::connection('business_mirror')->table('store_report_import_states')->insert([
@ -185,5 +226,33 @@ class StoreReportMirrorSyncTest extends TestCase
$table->dateTime('updated_at')->nullable();
$table->unique(['generation', 'store_name', 'occurred_on']);
});
$schema->create('store_report_shop_period_changes', function (Blueprint $table): void {
$table->increments('id');
$table->string('erp_store_name');
$table->string('company_name')->nullable();
$table->string('unified_social_credit_code')->nullable();
$table->date('observed_on');
$table->string('source')->default('crawl_shop_company');
$table->string('business_key')->unique();
$table->timestamps();
});
$schema->create('ops_crawl_shop_company', function (Blueprint $table): void {
$table->increments('id');
$table->string('shop_name');
$table->string('company_name')->nullable();
$table->string('tax_no')->nullable();
$table->date('crawl_date');
});
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
$table->increments('Id');
$table->string('erpShopName');
$table->string('unifiedSocialCreditCode')->nullable();
$table->string('shopState')->nullable();
});
$schema->create('ops_fys_company_info', function (Blueprint $table): void {
$table->increments('Id');
$table->string('unifiedSocialCreditCode')->nullable();
$table->string('businessLicenseName')->nullable();
});
}
}

View File

@ -75,7 +75,7 @@ class BusinessDataPlacementTest extends TestCase
$upstream = require base_path('config/upstream_mirrors.php');
$businessData = require base_path('config/business_data.php');
$this->assertCount(52, $upstream['datasets']);
$this->assertCount(54, $upstream['datasets']);
$upstreamKeys = array_keys($upstream['datasets']);
$policyKeys = array_keys($businessData['datasets']);
sort($upstreamKeys);

View File

@ -58,6 +58,12 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
]);
$this->markReady();
$connection->table('ops_fys_company_info')->where('Id', 1)->update(['registeredCapitalVaildTime' => '2027-01-01']);
$connection->table('ops_fys_company_info')->where('Id', 2)->update([
'businessLicenseType' => '店铺-普通公司',
'taxCategoryAndType' => '小规模纳税人',
'legalRepresentative' => '张三',
'registeredAddress' => '上海市浦东新区测试路1号',
]);
$connection->table('ops_fys_shop_info')->where('Id', 8)->update(['sotreId' => 'STORE-8', 'renewalTime' => '2027-08-14', 'shopBankAccount' => 'fixture-account', 'trademarkNumber' => 'TM-5']);
$connection->table('ops_fys_trademark_info')->where('Id', 5)->update(['trademarkNumber' => 'TM-5', 'approvedCommodities' => '家具', 'transferenceUser' => '测试持有人', 'registrationAddress' => '测试地址', 'custodyDepartment' => '资料部']);
@ -65,6 +71,18 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
$store = $this->repository->paginate('store-information.read', 1, 20);
$trademarks = $this->repository->paginate('trademark-information.read', 1, 20);
$receivingStores = $this->repository->receivingAccountStores();
$filteredStore = $this->repository->paginate('store-information.read', 1, 20, [
'platform' => '天猫', 'store_status' => '0', 'opened_at_from' => '2026-08-14',
]);
$filteredTrademark = $this->repository->paginate('trademark-information.read', 1, 20, [
'trademark_status' => '1', 'registration_date_from' => '2020-01-01',
]);
$filteredCompany = $this->repository->paginate('company-information.read', 1, 20, ['company_name' => '新公司']);
$relatedCompany = $this->repository->paginate('company-information.read', 1, 20, ['related_people' => '张']);
$typedCompany = $this->repository->paginate('company-information.read', 1, 20, [
'taxpayer_type' => '小规模纳税人', 'company_status' => '正常', 'company_type' => '店铺-普通公司',
]);
$cityCompany = $this->repository->paginate('company-information.read', 1, 20, ['city' => '上海']);
$this->assertSame(3, $companies['total']);
$this->assertSame(1, $companies['data'][0]['id']);
@ -83,6 +101,12 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
$this->assertSame('TM-5', $trademarks['data'][0]['trademark_number']);
$this->assertSame('家具', $trademarks['data'][0]['approved_goods']);
$this->assertSame('测试持有人', $trademarks['data'][0]['final_holder']);
$this->assertSame(1, $filteredStore['total']);
$this->assertSame(1, $filteredTrademark['total']);
$this->assertSame('新公司', $filteredCompany['data'][0]['company_name']);
$this->assertSame('新公司', $relatedCompany['data'][0]['company_name']);
$this->assertSame(1, $typedCompany['total']);
$this->assertSame(1, $cityCompany['total']);
}
public function test_dataset_is_not_ready_until_initial_full_sync_is_marked_complete(): void
@ -121,6 +145,49 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
});
}
public function test_crawler_company_and_trademark_take_priority_over_flow_snapshot(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('ops_crawl_shop_company', function (Blueprint $table): void {
$table->integer('id'); $table->string('shop_name'); $table->string('company_name')->nullable();
$table->string('tax_no')->nullable(); $table->string('crawl_date')->nullable(); $table->dateTime('update_time')->nullable();
});
$schema->create('ops_crawl_shop_trademark', function (Blueprint $table): void {
$table->integer('id'); $table->string('shop_name'); $table->string('trademark_no')->nullable();
$table->string('trademark_name')->nullable(); $table->string('crawl_date')->nullable(); $table->dateTime('update_time')->nullable();
});
$connection = DB::connection('business_mirror');
$connection->table('ops_fys_company_info')->insert([
$this->company(1, '工商代码', '系统登记主体'),
$this->company(2, '抓取代码', '爬虫抓取主体'),
]);
$connection->table('ops_fys_shop_info')->insert([
['Id' => 21, 'openTime' => '2026-08-14 10:00:00', 'shopName' => '上游店铺名', 'erpShopName' => '测试店', 'unifiedSocialCreditCode' => '工商代码', 'trademarkNumber' => '旧商标号', 'platform' => '京东', 'responsibleDepartment' => '电商', 'shopHead' => '负责人', 'shopState' => '0', 'shopType' => '直营', 'channelType' => '线上'],
]);
$connection->table('ops_fys_trademark_info')->insert([
['Id' => 9, 'trademarkNumber' => '旧商标号', 'trademarkName' => '旧商标', 'internationalClassification' => '20', 'trademarkStatus' => '1', 'registrationDate' => null, 'termOfValidity' => null, 'remark' => null, 'isDelete' => 0],
]);
$connection->table('ops_crawl_shop_company')->insert([
['id' => 1, 'shop_name' => '测试店', 'company_name' => '爬虫抓取主体', 'tax_no' => '抓取代码', 'crawl_date' => '2026-09-29', 'update_time' => '2026-09-29 08:00:00'],
]);
$connection->table('ops_crawl_shop_trademark')->insert([
['id' => 1, 'shop_name' => '测试店', 'trademark_no' => '1000', 'trademark_name' => '甲商标', 'crawl_date' => '2026-09-29', 'update_time' => '2026-09-29 08:00:00'],
['id' => 2, 'shop_name' => '测试店', 'trademark_no' => '2000', 'trademark_name' => '乙商标', 'crawl_date' => '2026-09-29', 'update_time' => '2026-09-29 08:00:00'],
]);
$this->markReady(['operations_replica.rpt.fys_shopInfo']);
$payload = $this->repository->paginate('store-information.read', 1, 10);
$this->assertSame('爬虫抓取主体', $payload['data'][0]['company_name']);
$this->assertSame('系统登记主体', $payload['data'][0]['system_company_name']);
$this->assertSame('甲商标、乙商标', $payload['data'][0]['trademark_name']);
$this->assertSame('1000、2000', $payload['data'][0]['trademark_number']);
$filtered = $this->repository->paginate('store-information.read', 1, 10, ['trademark_number' => '2000']);
$this->assertSame(1, $filtered['total']);
$this->assertSame('测试店', $filtered['data'][0]['erp_store_name']);
}
private function markReady(?array $keys = null): void
{
$keys ??= array_keys((array) config('upstream_mirrors.datasets'));

View File

@ -64,7 +64,7 @@ class StoreAccountMirrorRepositoryTest extends TestCase
foreach (['MAIN' => '主账号', 'SUB' => '子账号', '/' => null] as $type => $expected) {
DB::connection('business_mirror')->table('store_accounts')->insert([
'id' => 'account-' . md5($type),
'storeName' => '测试店铺',
'storeName' => ['MAIN' => 'B店铺', 'SUB' => 'A店铺', '/' => 'C店铺'][$type],
'accountType' => $type,
'account' => 'test-account',
'passwordConfigured' => $type === 'MAIN' ? true : ($type === 'SUB' ? false : null),
@ -79,5 +79,8 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$this->assertTrue($rows['account-' . md5('MAIN')]['password_configured']);
$this->assertFalse($rows['account-' . md5('SUB')]['password_configured']);
$this->assertNull($rows['account-' . md5('/')]['password_configured']);
$sorted = $this->repository->paginate(1, 50, ['_sort' => 'store_name', '_direction' => 'asc']);
$this->assertSame(['A店铺', 'B店铺', 'C店铺'], array_column($sorted['data'], 'store_name'));
}
}

View File

@ -8,11 +8,10 @@ use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Tests\TestCase;
/** 店铺维度报表查询条件:生命周期字段按左闭右开区间过滤。 */
/** 店铺维度报表查询条件:主体期间字段按左闭右开区间过滤。 */
class StoreReportRepositoryFilterTest extends TestCase
{
private StoreReportRepository $repository;
private string $lifecycleGeneration = '11111111-1111-4111-8111-111111111111';
private string $dailyGeneration = '22222222-2222-4222-8222-222222222222';
/** @var array<int,array<string,mixed>> */
@ -25,9 +24,20 @@ class StoreReportRepositoryFilterTest extends TestCase
{
parent::setUp();
config(['database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
], 'upstream_mirrors.connection' => 'business_mirror']);
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
// 店铺A 在 2026-08-20 由主体甲变更到主体乙:主体甲统计到 08-19。
'store_report_initial_periods.shops' => [
'店铺A' => [
'previous_company' => '主体甲',
'company' => '主体乙',
'changed_at' => '2026-08-20',
],
],
]);
DB::purge('business_mirror');
$schema = Schema::connection('business_mirror');
$schema->create('store_report_import_states', function (Blueprint $table): void {
@ -35,15 +45,6 @@ class StoreReportRepositoryFilterTest extends TestCase
$table->string('active_generation')->nullable();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('store_report_lifecycle_rows', function (Blueprint $table): void {
$table->increments('id');
$table->string('generation');
$table->string('business_key');
$table->string('erp_store_name');
$table->string('company_name')->nullable();
$table->dateTime('effective_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
$schema->create('store_report_daily_rows', function (Blueprint $table): void {
$table->increments('id');
$table->string('generation');
@ -53,27 +54,9 @@ class StoreReportRepositoryFilterTest extends TestCase
$table->decimal('expense', 20, 4)->default(0);
});
DB::connection('business_mirror')->table('store_report_import_states')->insert([
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => $this->lifecycleGeneration, 'initial_completed_at' => now()],
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => 'lifecycle-live', 'initial_completed_at' => now()],
['dataset_key' => StoreReportRepository::DAILY_DATASET, 'active_generation' => $this->dailyGeneration, 'initial_completed_at' => now()],
]);
DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([
[
'generation' => $this->lifecycleGeneration,
'business_key' => 'lifecycle-a-1',
'erp_store_name' => '店铺A',
'company_name' => '主体甲',
'effective_at' => '2026-07-15 00:00:00',
'expires_at' => '2026-08-01 00:00:00',
],
[
'generation' => $this->lifecycleGeneration,
'business_key' => 'lifecycle-a-2',
'erp_store_name' => '店铺A',
'company_name' => '主体乙',
'effective_at' => '2026-08-10 00:00:00',
'expires_at' => null,
],
]);
$this->repository = new StoreReportRepository(app('db'));
}
@ -91,47 +74,48 @@ class StoreReportRepositoryFilterTest extends TestCase
['主体甲', '主体乙', '主体丙'],
array_column($rows, 'entity'),
);
$this->assertSame('2026-08-19', $rows[0]['invalid_at']);
$this->assertSame('存续中', $rows[1]['invalid_at']);
$this->assertSame('2026-08-01', $rows[0]['invalid_at']);
$this->assertSame('存续中', $rows[2]['invalid_at']);
}
public function test_effective_at_filter_is_left_closed_and_right_open(): void
{
$rows = $this->repository->rows($this->stores, 2026, [
'effective_at_from' => '2026-07-15',
'effective_at_to' => '2026-08-10',
'effective_at_from' => '2026-08-20',
'effective_at_to' => '2026-08-20',
]);
// 左边界 07-15 命中主体甲;右边界 08-10 命中的主体乙被排除(右开);
// 无生命周期兜底行(默认 2026-07-01)低于左边界被排除。
$this->assertSame(['主体甲'], array_column($rows, 'entity'));
// 右开:生效日正好等于上界的期间不命中。
$this->assertSame([], array_column($rows, 'entity'));
$rows = $this->repository->rows($this->stores, 2026, [
'effective_at_from' => '2026-07-16',
'effective_at_from' => '2026-07-02',
]);
// 左闭:期初期间(2026-07-01)低于下界被排除。
$this->assertSame(['主体乙'], array_column($rows, 'entity'));
}
public function test_invalid_at_filter_uses_resolved_expiry_and_excludes_ongoing(): void
{
$rows = $this->repository->rows($this->stores, 2026, [
'invalid_at_from' => '2026-07-01',
'invalid_at_from' => '2026-08-01',
'invalid_at_to' => '2026-09-01',
]);
// 主体乙与兜底行均为存续(2999-01-01),不在失效区间内。
// 主体乙和主体丙均为存续中(2999-01-01),不在失效区间内。
$this->assertSame(['主体甲'], array_column($rows, 'entity'));
$rows = $this->repository->rows($this->stores, 2026, [
'invalid_at_from' => '2026-08-01',
'invalid_at_from' => '2026-08-19',
]);
// 只有下界时,存续中(2999-01-01)尚未失效,同样满足条件。
$this->assertSame(['主体甲', '主体乙', '主体丙'], array_column($rows, 'entity'));
}
public function test_entity_filter_matches_lifecycle_and_fallback_companies(): void
public function test_entity_filter_matches_period_and_baseline_companies(): void
{
$rows = $this->repository->rows($this->stores, 2026, ['entity' => '主体甲']);

View File

@ -17,12 +17,11 @@ use RuntimeException;
use Tests\TestCase;
/**
* 店铺维度报表数据组合:共享生命周期和日汇总的拆行与收入归属。
* 店铺维度报表数据组合:期初数据、系统主体、爬虫变更的期间拆分与收入归属。
*/
class StoreReportRepositoryTest extends TestCase
{
private StoreReportRepository $repository;
private string $lifecycleGeneration = '11111111-1111-4111-8111-111111111111';
private string $dailyGeneration = '22222222-2222-4222-8222-222222222222';
protected function setUp(): void
@ -39,14 +38,15 @@ class StoreReportRepositoryTest extends TestCase
$table->string('active_generation')->nullable();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('store_report_lifecycle_rows', function (Blueprint $table): void {
$schema->create('store_report_shop_period_changes', function (Blueprint $table): void {
$table->increments('id');
$table->string('generation');
$table->string('business_key');
$table->string('erp_store_name');
$table->string('company_name')->nullable();
$table->dateTime('effective_at')->nullable();
$table->dateTime('expires_at')->nullable();
$table->string('unified_social_credit_code')->nullable();
$table->date('observed_on');
$table->string('source')->default('crawl_shop_company');
$table->string('business_key')->unique();
$table->timestamps();
});
$schema->create('store_report_daily_rows', function (Blueprint $table): void {
$table->increments('id');
@ -56,8 +56,19 @@ class StoreReportRepositoryTest extends TestCase
$table->decimal('income', 20, 4)->default(0);
$table->decimal('expense', 20, 4)->default(0);
});
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
$table->increments('Id');
$table->string('erpShopName');
$table->string('unifiedSocialCreditCode')->nullable();
$table->string('shopState')->nullable();
});
$schema->create('ops_fys_company_info', function (Blueprint $table): void {
$table->increments('Id');
$table->string('unifiedSocialCreditCode')->nullable();
$table->string('businessLicenseName')->nullable();
});
DB::connection('business_mirror')->table('store_report_import_states')->insert([
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => $this->lifecycleGeneration, 'initial_completed_at' => now()],
['dataset_key' => StoreReportRepository::LIFECYCLES_DATASET, 'active_generation' => 'lifecycle-live', 'initial_completed_at' => now()],
['dataset_key' => StoreReportRepository::DAILY_DATASET, 'active_generation' => $this->dailyGeneration, 'initial_completed_at' => now()],
]);
$this->repository = new StoreReportRepository(app('db'));
@ -70,38 +81,44 @@ class StoreReportRepositoryTest extends TestCase
parent::tearDown();
}
public function test_lifecycle_boundaries_split_monthly_income_without_double_counting(): void
public function test_initial_data_splits_shop_periods_and_income_at_change_day(): void
{
$this->insertLifecycle('店铺A', '主体甲', '2026-05-01 00:00:00', '2026-07-01 00:00:00');
$this->insertLifecycle('店铺A', '主体乙', '2026-07-01 00:00:00', null);
$this->insertBill('店铺A', '2026-04-30 10:00:00', 9);
$this->insertBill('店铺A', '2026-05-01 00:00:00', 100);
$this->insertBill('店铺A', '2026-06-30 23:59:59', 60);
$this->insertBill('店铺A', '2026-07-01 00:00:00', 70);
$this->insertBill('店铺A', '2026-08-01 10:00:00', 80);
$this->insertBill('店铺A', '2027-01-01 10:00:00', 5);
config(['store_report_initial_periods.shops' => [
'店铺A' => [
'previous_company' => '主体甲',
'company' => '主体乙',
'changed_at' => '2026-07-10',
],
]]);
$this->insertBill('店铺A', '2026-06-30', 9);
$this->insertBill('店铺A', '2026-07-01', 100);
$this->insertBill('店铺A', '2026-07-09', 60);
$this->insertBill('店铺A', '2026-07-10', 70);
$this->insertBill('店铺A', '2026-08-01', 80);
$this->insertBill('店铺A', '2027-01-01', 5);
$rows = $this->repository->rows([
['erp_store_name' => '店铺A', 'company_name' => '旧主体', 'store_status' => '3'],
['erp_store_name' => '店铺A', 'company_name' => '旧主体', 'store_status' => '0'],
], 2026);
$this->assertSame(['主体甲', '主体乙'], array_column($rows, 'entity'));
// 主体切换日 07-01 的账单只归主体乙(左闭右开),切换前的 04-30 不计入。
// 变更日前一天(07-09)仍归老主体,变更日(07-10)当天归新主体,窗口前的 06-30 不计入。
$this->assertSame([160.0, 155.0], array_column($rows, 'period_sales'));
$this->assertSame([100.0, 0.0], array_column($rows, 'sales_05'));
$this->assertSame([0.0, 70.0], array_column($rows, 'sales_07'));
$this->assertSame(['关店中', '关店中'], array_column($rows, 'status'));
$this->assertSame(['2026-07-01', '存续中'], array_column($rows, 'invalid_at'));
$this->assertSame([0.0, 0.0], array_column($rows, 'sales_05'));
$this->assertSame([160.0, 70.0], array_column($rows, 'sales_07'));
$this->assertSame([0.0, 80.0], array_column($rows, 'sales_08'));
$this->assertSame(['2026-07-01', '2026-07-10'], array_column($rows, 'effective_at'));
$this->assertSame(['2026-07-09', '存续中'], array_column($rows, 'invalid_at'));
$this->assertSame(['开店中', '开店中'], array_column($rows, 'status'));
}
public function test_fallback_row_uses_company_table_subject_and_default_window(): void
public function test_store_without_initial_data_uses_store_row_company_from_initial_date(): void
{
// 未匹配生命周期时按默认窗口 [2026-07-01, 2999-01-01) 归属账单。
$this->insertBill('店铺A', '2026-05-03 10:00:00', 25.5);
$this->insertBill('店铺A', '2026-07-05 10:00:00', 30);
$this->insertBill('店铺A', '2026-05-03', 25.5);
$this->insertBill('店铺A', '2026-07-05', 30);
$rows = $this->repository->rows([
['erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'opened_at' => '2026-05-01'],
['erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'store_status' => '0', 'opened_at' => '2026-05-01'],
], 2026);
$this->assertCount(1, $rows);
@ -112,6 +129,63 @@ class StoreReportRepositoryTest extends TestCase
$this->assertSame('存续中', $rows[0]['invalid_at']);
}
public function test_closed_store_shows_dash_for_period_columns_but_keeps_income(): void
{
$this->insertBill('店铺A', '2026-07-05', 42);
$rows = $this->repository->rows([
['erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '6'],
], 2026);
$this->assertCount(1, $rows);
$this->assertSame('关店', $rows[0]['status']);
$this->assertSame('-', $rows[0]['effective_at']);
$this->assertSame('-', $rows[0]['invalid_at']);
$this->assertSame('主体甲', $rows[0]['entity']);
$this->assertSame(42.0, $rows[0]['period_sales']);
}
public function test_crawler_change_appends_period_and_ignores_repeated_company(): void
{
$this->insertChange('店铺B', '主体新', '2026-08-20');
$this->insertChange('店铺B', '主体新', '2026-08-25');
$this->insertBill('店铺B', '2026-08-19', 10);
$this->insertBill('店铺B', '2026-08-20', 20);
$rows = $this->repository->rows([
['erp_store_name' => '店铺B', 'company_name' => '主体旧', 'store_status' => '0'],
], 2026);
$this->assertSame(['主体旧', '主体新'], array_column($rows, 'entity'));
$this->assertSame(['2026-07-01', '2026-08-20'], array_column($rows, 'effective_at'));
$this->assertSame(['2026-08-19', '存续中'], array_column($rows, 'invalid_at'));
$this->assertSame([10.0, 20.0], array_column($rows, 'period_sales'));
}
public function test_crawler_change_uses_system_entity_as_base_when_row_shows_crawler_company(): void
{
DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([
'erpShopName' => '店铺C', 'unifiedSocialCreditCode' => 'CODE-C', 'shopState' => '0',
]);
DB::connection('business_mirror')->table('ops_fys_company_info')->insert([
'unifiedSocialCreditCode' => 'CODE-C', 'businessLicenseName' => '主体旧',
]);
$this->insertChange('店铺C', '主体新', '2026-09-29', 'CODE-NEW');
DB::connection('business_mirror')->table('ops_fys_company_info')->insert([
'unifiedSocialCreditCode' => 'CODE-NEW', 'businessLicenseName' => '主体新',
]);
$this->insertBill('店铺C', '2026-09-28', 10);
$this->insertBill('店铺C', '2026-09-29', 20);
// 店铺基础信息页按爬虫优先展示主体新,报表期间仍以系统主体为基数拆分。
$rows = $this->repository->rows([
['erp_store_name' => '店铺C', 'company_name' => '主体新', 'store_status' => '0'],
], 2026);
$this->assertSame(['主体旧', '主体新'], array_column($rows, 'entity'));
$this->assertSame([10.0, 20.0], array_column($rows, 'period_sales'));
}
public function test_unfinished_shared_daily_summary_is_reported_instead_of_returning_misleading_zeroes(): void
{
DB::connection('business_mirror')->table('store_report_import_states')
@ -125,12 +199,11 @@ class StoreReportRepositoryTest extends TestCase
public function test_upstream_business_information_exposes_shared_store_report_rows(): void
{
$this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null);
$this->insertBill('店铺A', '2026-06-30 10:00:00', 60);
$this->insertBill('店铺A', '2026-07-05', 60);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
$mirror->shouldReceive('allStores')->once()->andReturn([[
'erp_store_name' => '店铺A', 'company_name' => '公司表主体', 'store_status' => '3',
'erp_store_name' => '店铺A', 'company_name' => '主体甲', 'store_status' => '0',
]]);
$accounts = Mockery::mock(StoreAccountMirrorRepository::class);
$accounts->shouldReceive('ownershipByStoreName')->andReturn([]);
@ -152,12 +225,9 @@ class StoreReportRepositoryTest extends TestCase
public function test_subject_operating_report_aggregates_income_by_entity(): void
{
Carbon::setTestNow('2026-09-01 10:00:00');
$this->insertLifecycle('店铺A', '主体甲', '2026-01-01 00:00:00', null);
$this->insertLifecycle('店铺B', '主体甲', '2026-01-01 00:00:00', null);
$this->insertBill('店铺A', '2026-06-10 10:00:00', 10);
$this->insertBill('店铺A', '2026-07-10 10:00:00', 20);
$this->insertBill('店铺A', '2026-08-10 10:00:00', 30);
$this->insertBill('店铺B', '2026-08-11 10:00:00', 40);
$this->insertBill('店铺A', '2026-07-10', 20);
$this->insertBill('店铺A', '2026-08-10', 30);
$this->insertBill('店铺B', '2026-08-11', 40);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->with('store-information.read')->andReturnTrue();
$mirror->shouldReceive('allStores')->once()->andReturn([
@ -180,32 +250,33 @@ class StoreReportRepositoryTest extends TestCase
$this->assertCount(1, $rows);
$this->assertSame('主体甲', $rows[0]->entity);
$this->assertSame(100.0, $rows[0]->rolling_12m_income);
$this->assertSame(10.0, $rows[0]->income_06);
$this->assertSame(90.0, $rows[0]->rolling_12m_income);
$this->assertSame(20.0, $rows[0]->income_07);
$this->assertSame(70.0, $rows[0]->income_08);
}
private function insertBill(string $store, string $occurredAt, float $income): void
private function insertBill(string $store, string $occurredOn, float $income): void
{
DB::connection('business_mirror')->table('store_report_daily_rows')->insert([
'generation' => $this->dailyGeneration,
'store_name' => $store,
'occurred_on' => substr($occurredAt, 0, 10),
'occurred_on' => $occurredOn,
'income' => $income,
'expense' => 0,
]);
}
private function insertLifecycle(string $store, string $company, ?string $from, ?string $to): void
private function insertChange(string $store, string $company, string $observedOn, ?string $taxNo = null): void
{
DB::connection('business_mirror')->table('store_report_lifecycle_rows')->insert([
'generation' => $this->lifecycleGeneration,
'business_key' => hash('sha256', implode('|', [$store, (string) $from, (string) $to])),
DB::connection('business_mirror')->table('store_report_shop_period_changes')->insert([
'erp_store_name' => $store,
'company_name' => $company,
'effective_at' => $from,
'expires_at' => $to,
'unified_social_credit_code' => $taxNo,
'observed_on' => $observedOn,
'source' => 'crawl_shop_company',
'business_key' => hash('sha256', implode('|', [$store, $company, $observedOn])),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@ -96,7 +96,7 @@ class UpstreamBusinessInformationTest extends TestCase
};
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->with($scope)->andReturnTrue();
$mirror->shouldReceive('paginate')->once()->with($scope, 2, 25)->andReturn([
$mirror->shouldReceive('paginate')->once()->with($scope, 2, 25, [])->andReturn([
'data' => [['id' => 10]],
'total' => 51,
]);