Compare commits
12 Commits
main
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df9d046ac | |||
| 37e8892e8a | |||
| 3ac88cf43d | |||
| b8726c91f8 | |||
| 967b62ca2f | |||
| 561c42d830 | |||
| fdf7f7ea85 | |||
| 21a3393c7d | |||
| e6a30137df | |||
| f0b22b5a24 | |||
| e33f22a24f | |||
| f6f66b6ff1 |
@ -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' => '已注销',
|
||||
|
||||
41
app/Console/Commands/CheckCrawlerWarnings.php
Normal file
41
app/Console/Commands/CheckCrawlerWarnings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/ImportInternalFlowReport.php
Normal file
36
app/Console/Commands/ImportInternalFlowReport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
267
app/Console/Commands/SyncCrawlerShopSubjects.php
Normal file
267
app/Console/Commands/SyncCrawlerShopSubjects.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
@ -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', '税金预测表逻辑配置已保存,下次预测按新参数计算。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递附加费报价维护。
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' => '预留聚水潭店铺维表'],
|
||||
|
||||
@ -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' => '关闭人/日期'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
587
config/store_report_initial_periods.php
Normal file
587
config/store_report_initial_periods.php
Normal 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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
// 清理动作不可逆,也不影响现有数据。
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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/仓库/店铺维表 | 四张事实表必须成组对账和切换,不能逐张切换造成跨库半成品 |
|
||||
|
||||
62
docs/税金预测表需求说明.md
Normal file
62
docs/税金预测表需求说明.md
Normal 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 两项确认前,页面保持“字段已定义、数据待接入”,不显示推测数据。
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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 => [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
246
modules/EcommerceFinance/Services/CrawlerDataGuard.php
Normal file
246
modules/EcommerceFinance/Services/CrawlerDataGuard.php
Normal 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] ?? '数据责任人';
|
||||
}
|
||||
}
|
||||
}
|
||||
135
modules/EcommerceFinance/Services/DataLogicSettings.php
Normal file
135
modules/EcommerceFinance/Services/DataLogicSettings.php
Normal 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) {
|
||||
// 读取失败时保持原上下文,使用代码默认值。
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
120
modules/EcommerceFinance/Services/ShopEntityTrademarkLogic.php
Normal file
120
modules/EcommerceFinance/Services/ShopEntityTrademarkLogic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
234
modules/EcommerceFinance/Services/StoreReportInitialPeriods.php
Normal file
234
modules/EcommerceFinance/Services/StoreReportInitialPeriods.php
Normal 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();
|
||||
}
|
||||
}
|
||||
83
modules/EcommerceFinance/Services/StoreReportLogic.php
Normal file
83
modules/EcommerceFinance/Services/StoreReportLogic.php
Normal 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' => '按日归属,区间不重叠',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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 ?? ''));
|
||||
|
||||
258
modules/EcommerceFinance/Services/StoreReportPeriodBuilder.php
Normal file
258
modules/EcommerceFinance/Services/StoreReportPeriodBuilder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
201
modules/EcommerceFinance/Services/TaxForecastLogic.php
Normal file
201
modules/EcommerceFinance/Services/TaxForecastLogic.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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('&','&').replaceAll('\"','"').replaceAll('<','<').replaceAll('>','>')}">${item.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>')}</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' });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
@include('business.pages.partials.prototype-page')
|
||||
@ -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'],
|
||||
|
||||
@ -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');
|
||||
|
||||
76
tests/Feature/Business/InternalFlowReportPageTest.php
Normal file
76
tests/Feature/Business/InternalFlowReportPageTest.php
Normal 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('内部流转数据尚未导入');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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('经营报表共享镜像尚未完成同步。');
|
||||
}
|
||||
|
||||
185
tests/Feature/Console/InternalFlowReportImportTest.php
Normal file
185
tests/Feature/Console/InternalFlowReportImportTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => '主体甲']);
|
||||
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user