Merge remote-tracking branch 'origin/main' into fix/yueyang-offset-signed-amount

# Conflicts:
#	app/Console/Commands/SyncStoreReceiptOrderMatches.php
This commit is contained in:
yueyang 2026-10-08 16:24:38 +08:00
commit 03b728a0fc
34 changed files with 2012 additions and 89 deletions

View File

@ -0,0 +1,206 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use RuntimeException;
/**
* 快递运费导入(.xlsx / .csv)。
*
* 源表头:来源 / 成品配件(也接受「成品运费」)/ 交易单号 / 提供成本运费(按表头名匹配,列顺序无关)。
* 快递方给的文件常见几十万行,xlsx 走 XlsxSheetReader 流式读取,不整表进内存。
*/
class CourierFreightImporter
{
/** 源表头 → 数据库列 */
private const COLUMNS = [
'来源' => 'source',
'成品配件' => 'part',
'成品运费' => 'part',
'交易单号' => 'transaction_no',
'提供成本运费' => 'freight_amount',
];
private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费'];
/** 表头别名:源文件里这一列有时写「成品配件」、有时写「成品运费」。 */
private const HEADER_ALIASES = [
'成品配件' => ['成品配件', '成品运费'],
];
private const CHUNK = 2000;
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $reader = null,
) {}
/**
* @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array<string,int>,seconds:float}
*/
public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array
{
if (! is_file($path)) {
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
}
$target = $this->databases->connection();
$startedAt = microtime(true);
$now = now();
if ($replace) {
$target->table('courier_freight_rows')->where('company_id', $companyId)->delete();
}
$inserted = 0;
$skipped = 0;
$amount = 0.0;
$sources = [];
$buffer = [];
$flush = function () use (&$buffer, &$inserted, $target): void {
foreach (array_chunk($buffer, self::CHUNK) as $chunk) {
$target->table('courier_freight_rows')->insert($chunk);
$inserted += count($chunk);
}
$buffer = [];
};
foreach ($this->lines($path) as $line) {
$sourceValue = $this->text($line['来源'] ?? null, 64);
$transactionNo = $this->text($line['交易单号'] ?? null, 191);
if ($sourceValue === null && $transactionNo === null) {
$skipped++;
continue;
}
$value = $this->number($line['提供成本运费'] ?? null);
$sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1;
$amount += $value;
$buffer[] = [
'company_id' => $companyId,
'source' => $sourceValue ?? '',
'part' => $this->text($line['成品配件'] ?? null, 191),
'transaction_no' => $transactionNo ?? '',
'freight_amount' => number_format($value, 4, '.', ''),
'created_at' => $now,
'updated_at' => $now,
];
if (count($buffer) >= self::CHUNK * 4) {
$flush();
}
}
$flush();
arsort($sources);
return [
'file' => basename($path),
'rows' => $inserted + $skipped,
'inserted' => $inserted,
'skipped' => $skipped,
'amount' => number_format($amount, 2, '.', ''),
'sources' => $sources,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/** @return \Generator<int, array<string,string>> */
private function lines(string $path): \Generator
{
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') {
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('无法读取文件:' . $path);
}
try {
$header = null;
while (($cells = fgetcsv($handle)) !== false) {
if ($header === null) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? ''));
$header = array_map(static fn ($value): string => trim((string) $value), $cells);
$this->assertHeader($header);
continue;
}
$line = [];
foreach ($header as $index => $name) {
$line[$this->canonicalName($name)] = (string) ($cells[$index] ?? '');
}
yield $line;
}
} finally {
fclose($handle);
}
return;
}
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new InvalidArgumentException('只支持 xlsx、csv。');
}
$columns = array_map(
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
self::HEADERS,
);
yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns);
}
/** 把表头别名折回规范列名。 */
private function canonicalName(string $header): string
{
foreach (self::HEADER_ALIASES as $canonical => $names) {
if (in_array($header, $names, true)) {
return $canonical;
}
}
return $header;
}
/** @param list<string> $header */
private function assertHeader(array $header): void
{
foreach (self::HEADERS as $required) {
$names = self::HEADER_ALIASES[$required] ?? [$required];
if (array_intersect($names, $header) === []) {
throw new InvalidArgumentException('文件缺少列:' . $required);
}
}
}
private function text(mixed $value, int $limit): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
return is_numeric($text) ? (float) $text : 0.0;
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* 快递应付的两个汇总页 → 「交易单号 ⇒ 金额类取值」映射。
*
* 这些数据都是财务在「应付款项 › 快递应付 › 运费差 / 其他附加费汇总 / 退件费」页面上传的汇总快照
* (JSON 文件,不是数据库表),快递运费页面按交易单号把对应金额带出来展示。
* 快照重新导入后文件时间会变,缓存键跟着变,自动失效。
*/
class CourierPayableSummaryMap
{
/** 运费差:差异(结算运费-提供成本运费)。 */
public const PAGE_FREIGHT_DIFFERENCE = 'freight-difference';
/** 其他附加费汇总:金额。 */
public const PAGE_OTHER_SURCHARGE = 'other-surcharge-summary';
/** 退件费:结算金额(单号列叫「交易订单」)。 */
public const PAGE_RETURN_FEE = 'return-fee';
/**
* 汇总页 → [单号列的关键字候选, 取值列的关键字候选]。
*
* 各页表头写法不统一(「交易单号」/「交易订单」、「金额」/「结算金额」),
* 所以按候选顺序找:先全等匹配,再退化成包含匹配。
*/
private const PAGES = [
self::PAGE_FREIGHT_DIFFERENCE => [['交易单号', '交易订单'], ['差异', '金额']],
self::PAGE_OTHER_SURCHARGE => [['交易单号', '交易订单'], ['金额']],
self::PAGE_RETURN_FEE => [['交易订单', '交易单号'], ['结算金额', '金额']],
];
private const CACHE_MINUTES = 10;
public function __construct(private readonly CourierPayableSummaryStore $store) {}
/**
* @param string $page self::PAGE_* 之一
* @return array<string,string> 交易单号 => 金额(保留源文件里的原值字符串)
*/
public function map(int $companyId, string $page): array
{
if (! isset(self::PAGES[$page])) {
return [];
}
$disk = Storage::disk('uploads');
$path = $this->store->path($companyId, $page);
if (! $disk->exists($path)) {
return [];
}
// 文件时间戳进缓存键:财务重新导入运费差后不用等缓存过期。
$key = sprintf('courier-payable-summary-map:v1:%s:%d:%d', $page, $companyId, (int) $disk->lastModified($path));
return Cache::remember($key, now()->addMinutes(self::CACHE_MINUTES), function () use ($companyId, $page): array {
try {
return $this->build($companyId, $page);
} catch (Throwable $exception) {
report($exception);
return [];
}
});
}
/** @return array<string,string> */
private function build(int $companyId, string $page): array
{
$snapshot = $this->store->snapshot($companyId, $page);
if ($snapshot === null) {
return [];
}
[$orderHeaders, $valueHeaders] = self::PAGES[$page];
$headers = array_map(static fn ($value): string => trim((string) $value), (array) $snapshot['headers']);
$orderColumn = $this->columnIndex($headers, $orderHeaders);
$valueColumn = $this->columnIndex($headers, $valueHeaders);
if ($orderColumn === null || $valueColumn === null) {
return [];
}
$map = [];
foreach ((array) $snapshot['rows'] as $row) {
$orderNos = trim((string) ($row[$orderColumn] ?? ''));
if ($orderNos === '') {
continue;
}
$value = trim((string) ($row[$valueColumn] ?? ''));
if ($value === '') {
continue;
}
// 源表一个格子可能放多个交易单号(逗号分隔),逐个挂到同一个金额上。
foreach (explode(',', $orderNos) as $orderNo) {
$orderNo = trim($orderNo);
if ($orderNo !== '' && ! isset($map[$orderNo])) {
$map[$orderNo] = $value;
}
}
}
return $map;
}
/**
* 按表头名定位:候选顺序优先,每个候选先找完全相等的表头,再退化成包含匹配
* (例如「差异(结算运费-提供成本运费)」只能靠包含匹配)。
*
* @param list<string> $needles
*/
private function columnIndex(array $headers, array $needles): ?int
{
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header === $needle) {
return (int) $index;
}
}
}
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header !== '' && mb_strpos($header, $needle) !== false) {
return (int) $index;
}
}
}
return null;
}
}

View File

@ -29,7 +29,7 @@ class JstOrderLabelImporter
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?JstOrderExportReader $exportReader = null,
private readonly ?XlsxSheetReader $exportReader = null,
) {}
/**
@ -200,7 +200,7 @@ class JstOrderLabelImporter
$buffer = [];
};
$reader = $this->exportReader ?? app(JstOrderExportReader::class);
$reader = $this->exportReader ?? app(XlsxSheetReader::class);
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。

View File

@ -17,6 +17,7 @@ use App\Models\Business\StoreChange;
use App\Models\Business\StoreChangeLog;
use App\Models\Business\StoreClosing;
use App\Models\Business\StoreInformation;
use App\Models\Business\ShopInfoFlow;
use App\Models\Business\StoreInvoice;
use App\Models\Business\StoreOpening;
use App\Models\Business\StoreReceivingAccount;
@ -171,6 +172,7 @@ class TableRegistry
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
'financial-operations/installation-details' => InstallationDetail::class,
'financial-risk-control/process-statistics' => ShopInfoFlow::class,
];
}
@ -260,6 +262,7 @@ class TableRegistry
'store-management/store-report',
'trademark-management/information',
'financial-operations/installation-details',
'financial-risk-control/process-statistics',
], true);
}

View File

@ -40,6 +40,7 @@ class UpstreamBusinessInformation
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
@ -55,6 +56,26 @@ class UpstreamBusinessInformation
);
}
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
*
* @return array{data:list<array<string,mixed>>,total:int}
*/
private function shopInfoFlowPage(int $page, int $perPage): array
{
try {
return app(\Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository::class)->paginate(
$page,
$perPage,
$this->pageFilters('financial-risk-control', 'process-statistics'),
);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺申请流程共享镜像尚未完成首次同步。');
}
}
private function installationPage(int $page, int $perPage): array
{
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
@ -646,7 +667,7 @@ SQL);
});
}
/** @return array<string,string> */
/** @return array<string,string|list<string>> */
private function pageFilters(string $section, string $page): array
{
$fields = collect(config('business_navigation'))
@ -661,7 +682,20 @@ SQL);
$filters = [];
foreach ($allowed as $name) {
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
$value = trim((string) request($key, ''));
$raw = request($key, '');
// 多选筛选(xxx[])传进来是数组:去重后原样带下去,由各自的仓库取并集。
if (is_array($raw)) {
$values = [];
foreach ($raw as $item) {
if (! is_scalar($item)) continue;
$item = mb_substr(trim((string) $item), 0, 191);
if ($item !== '') $values[$item] = true;
if (count($values) >= 50) break;
}
if ($values !== []) $filters[$key] = array_keys($values);
continue;
}
$value = trim((string) $raw);
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
}
}

View File

@ -7,9 +7,9 @@ use RuntimeException;
use ZipArchive;
/**
* 聚水潭「导出订单」Excel(.xlsx)流式读取器。
* xlsx 工作表流式读取器(按表头取列)。
*
* 聚水潭导出的文件有几个特点,直接套常规 Excel 读法会踩坑:
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
@ -17,14 +17,16 @@ use ZipArchive;
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
*/
class JstOrderExportReader
class XlsxSheetReader
{
private const READ_CHUNK = 262144;
/**
* 逐行读取导出文件(跳过表头行)。
*
* @param list<string> $columns 需要取出并作为返回键的表头名,必须与文件第一行完全一致
* @param list<string|list<string>> $columns 需要取出的列。给字符串就是精确匹配表头;
* 给数组表示同一列有多个可选表头(取第一个命中的,
* 返回键用数组里的第一个名字)。
* @return Generator<int, array<string, string>>
*/
public function rows(string $path, array $columns): Generator
@ -56,7 +58,7 @@ class JstOrderExportReader
/**
* @param resource $stream
* @param list<string> $shared
* @param list<string> $columns
* @param list<string|list<string>> $columns
* @return Generator<int, array<string, string>>
*/
private function parseSheet($stream, array $shared, array $columns): Generator
@ -169,7 +171,7 @@ class JstOrderExportReader
* 表头行 -> [列下标 => 列名],并校验需要的列都在。
*
* @param array<int, string> $header
* @param list<string> $columns
* @param list<string|list<string>> $columns
* @return array<int, string>
*/
private static function wantedColumns(array $header, array $columns): array
@ -184,10 +186,18 @@ class JstOrderExportReader
$wanted = [];
foreach ($columns as $column) {
if (! isset($byName[$column])) {
throw new RuntimeException('订单导出文件缺少列:' . $column);
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
$wanted[$byName[$column]] = $column;
if ($matched === null) {
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
}
$wanted[$byName[$matched]] = $names[0];
}
return $wanted;

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
use Throwable;
/**
* 让登记过的数据集自动跟随上游新增字段。
*
* 上游视图/表新增列时,数据字典(upstream_schema_columns)会先刷新出来,这里再把新列
* 补进数据集定义、建好镜像列;下一次常规同步就会把数据带进来,页面也会自动多出这一列。
* 只做"新增":不删除、不改写已有字段,敏感字段名一律跳过。
*/
class FollowUpstreamColumns extends Command
{
protected $signature = 'business-mirror:follow-upstream-columns
{--dataset= : 只处理指定数据集}';
protected $description = '把上游新增字段补进登记过的镜像数据集(页面字段自动跟随)';
private const DENIED_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'cipher', 'credential', 'private'];
public function handle(
BusinessMirrorDatasetRegistry $datasets,
BusinessMirrorSchemaManager $schemas,
): int {
$keys = array_values(array_filter(array_map(
static fn (mixed $key): string => trim((string) $key),
(array) ($this->option('dataset') ?: array_values((array) config('upstream_mirrors.auto_follow_columns', []))),
), static fn (string $key): bool => $key !== ''));
if ($keys === []) {
$this->info('没有登记需要自动跟随上游字段的数据集。');
return self::SUCCESS;
}
// 先刷新数据字典,否则新字段根本不在目录里。该命令自带 24 小时新鲜度判断,
// 已经同步过会直接跳过。
foreach (array_unique($this->sourcesOf($keys)) as $source) {
try {
// --due:数据字典新鲜(24 小时内同步过)就不再重复扫上游。
Artisan::call('upstream:schema:sync', ['source' => $source, '--due' => true]);
} catch (Throwable $exception) {
report($exception);
$this->warn($source .':数据字典刷新失败,本轮跳过新字段检测。');
}
}
$followed = 0;
foreach ($keys as $key) {
try {
$added = $this->follow($key, $datasets, $schemas);
if ($added !== []) {
$followed += count($added);
$this->info($key . ':新增 ' . implode('、', $added) . ' 并已建列,下次同步即带回数据。');
Log::info('业务镜像自动跟随上游新增字段。', ['dataset' => $key, 'columns' => $added]);
}
} catch (Throwable $exception) {
report($exception);
$this->error($key . ':跟随失败 —— ' . $exception->getMessage());
}
}
$this->info($followed > 0 ? sprintf('共补入 %d 个新字段。', $followed) : '没有发现上游新增字段。');
return self::SUCCESS;
}
/** @param list<string> $keys @return list<string> */
private function sourcesOf(array $keys): array
{
$sources = [];
foreach (DB::table('business_mirror_datasets')->whereIn('dataset_key', $keys)->get() as $row) {
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
if ($source !== '') {
$sources[] = $source;
}
}
return $sources;
}
/**
* @return list<string> 本次补入的字段
*/
private function follow(string $key, BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSchemaManager $schemas): array
{
$row = DB::table('business_mirror_datasets')->where('dataset_key', $key)->first();
if ($row === null) {
return [];
}
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
$schema = trim((string) ($definition['schema'] ?? ''));
$object = trim((string) ($definition['object'] ?? ''));
$current = array_values(array_filter(array_map(
static fn (mixed $column): string => trim((string) $column),
(array) ($definition['columns'] ?? []),
), static fn (string $column): bool => $column !== ''));
if ($source === '' || $schema === '' || $object === '' || $current === []) {
return [];
}
$upstream = DB::table('upstream_schema_columns')
->where('source_key', $source)
->whereRaw('LOWER(schema_name) = ?', [strtolower($schema)])
->whereRaw('LOWER(object_name) = ?', [strtolower($object)])
->orderBy('ordinal_position')
->pluck('column_name')
->map(static fn (mixed $name): string => (string) $name)
->all();
$added = [];
foreach ($upstream as $column) {
if (in_array($column, $current, true) || str_starts_with($column, '_mirror') || $this->isSensitive($column)) {
continue;
}
$added[] = $column;
}
if ($added === []) {
return [];
}
$definition['columns'] = array_values(array_merge($current, $added));
// 记下"跟随进来的字段":页面据此自动补列,不会把历史上刻意没展示的字段带出来。
$definition['followed_columns'] = array_values(array_unique(array_merge(
(array) ($definition['followed_columns'] ?? []),
$added,
)));
DB::table('business_mirror_datasets')->where('dataset_key', $key)->update([
'definition' => json_encode($definition, JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
// 立刻建列,下一次同步就能写入;页面在数据到达前显示为空即可。
$schemas->ensure($datasets->get($key));
return $added;
}
private function isSensitive(string $column): bool
{
$name = strtolower($column);
foreach (self::DENIED_KEYWORDS as $keyword) {
if (str_contains($name, $keyword)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\CourierFreightImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportCourierFreight extends Command
{
protected $signature = 'courier-freight:import
{file : xlsx / csv 文件路径}
{--company=1 : 公司 ID}
{--replace : 先清空该公司已有的快递运费明细}';
protected $description = '导入「快递运费」数据(财务经营报表 › 快递运费)';
public function handle(CourierFreightImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(int) $this->option('company'),
(bool) $this->option('replace'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$sources = $result['sources'];
$result['sources'] = count($sources);
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
foreach ($sources as $name => $count) {
$this->line(sprintf(' %s:%d 行', $name === '' ? '(空来源)' : $name, $count));
}
return self::SUCCESS;
}
}

View File

@ -11,7 +11,6 @@ use App\Business\JstOrderLabelPlantingRules;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
@ -147,7 +146,7 @@ class SyncStoreReceiptOrderMatches extends Command
/** 优惠/冲减账单所在的订单没有收款行时,补行用这个标注。 */
private const REMARK_PROMO_ONLY = '无收款账单';
public function handle(UpstreamReadOnlyQueryFactory $factory): int
public function handle(): int
{
$month = trim((string) $this->argument('month'));
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
@ -440,11 +439,7 @@ class SyncStoreReceiptOrderMatches extends Command
}
}
$upstream = $factory->forSource('operations_replica');
$dwsFrom = $start->copy()->subDays(150)->toDateString();
$dwsTo = $end->copy()->addDays(7)->toDateString();
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
$orderSeen = [];
@ -452,46 +447,100 @@ class SyncStoreReceiptOrderMatches extends Command
$coresByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
$chunks = array_chunk(array_keys($cores), $chunkSize);
$totalChunks = count($chunks);
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
// 再用 STRAIGHT_JOIN 走 so_id / o_id 索引聚合,避免逐批 IN 查询退化成全表扫描。
$mirror = DB::connection($connection);
$mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_receipt_order_nos');
$mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB');
foreach ($chunks as $index => $chunk) {
$in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk));
$candidates = array_keys($cores);
foreach (array_chunk($candidates, 1000) as $batch) {
$mirror->table('tmp_receipt_order_nos')->insertOrIgnore(
array_map(static fn (string $no): array => ['no' => $no], $batch)
);
}
$this->info('订单侧候选单号:' . count($candidates));
$orderRows = [];
$paidRows = [];
foreach ([
['ops_jst_qmcust_orders', 'ops_jst_qmcust_order_items', 'pay_amount', 'ops_jst_qmcust'],
['ops_jst_orders', 'ops_jst_order_item', 'buyer_paid_amount', 'ops_jst'],
] as [$ordersTable, $itemsTable, $paidColumn, $tag]) {
try {
$rows = $upstream->select(
"SELECT sourceno, shop_name, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di
WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'"
// 同一线上订单号在聚水潭可能对应多个 o_id(拆单/子单),且子单会重复包含
// 同一批子订单商品行;先按「子订单号 + 条码」去重再求和,避免重复计算。
// 虚拟条码(X0000x:信誉订单/虚拟订单)不是真实商品,不计入商品金额。
$result = $mirror->select(
"SELECT so_id, shop_name, SUM(amount) as amount
FROM (
SELECT o.so_id as so_id, o.shop_name as shop_name,
CONCAT(COALESCE(NULLIF(i.outer_oi_id, ''), CONCAT('o', o.o_id, '-', i.oi_id)), '|', i.sku_id) as dedup_key,
MAX(CASE WHEN UPPER(i.sku_id) LIKE 'X0000%' THEN 0 ELSE i.amount END) as amount
FROM tmp_receipt_order_nos t
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
STRAIGHT_JOIN {$itemsTable} i ON i.o_id = o.o_id
GROUP BY o.so_id, o.shop_name, dedup_key
) d
GROUP BY so_id, shop_name"
);
// 订单实付:取订单级字段(明细行上的实付会重复);拆单取主单(link_o_id 为空),
// 没有主单时退化为取该单号下的最大值。
$paid = $mirror->select(
"SELECT so_id, shop_name,
COALESCE(
MAX(CASE WHEN link_o_id IS NULL OR link_o_id = 0 THEN paid END),
MAX(paid)
) as paid
FROM (
SELECT o.so_id as so_id, o.shop_name as shop_name, o.link_o_id as link_o_id, o.{$paidColumn} as paid
FROM tmp_receipt_order_nos t
STRAIGHT_JOIN {$ordersTable} o ON o.so_id = t.no
) x
GROUP BY so_id, shop_name"
);
} catch (Throwable $exception) {
$this->error('订单侧查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
$this->error('订单侧查询失败(' . $ordersTable . '):' . $exception->getMessage());
return self::FAILURE;
}
foreach ($rows as $row) {
$core = self::cleanOrderNo((string) $row['sourceno']);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$orderSeen[$key] = true;
$coreSeen[$core] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
if ((int) ($row['goods_count'] ?? 0) <= 0) {
// 退款/冲减/0 元赠品行不计入商品金额,差额列另行体现。
continue;
}
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['sales_amount'];
foreach ($result as $row) {
$orderRows[] = (array) $row;
}
if (($index + 1) % 10 === 0 || $index + 1 === $totalChunks) {
$this->info(sprintf('订单侧进度 %d/%d 批', $index + 1, $totalChunks));
foreach ($paid as $row) {
$paidRows[] = (array) $row;
}
}
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
$orderPaid = [];
foreach ($paidRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid'];
}
foreach ($orderRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$orderSeen[$key] = true;
$coreSeen[$core] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
// 口径:按「店铺 + 线上订单号」对商品金额求和(一单多品/多子订单在订单侧先合并)。
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['amount'];
}
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
DB::connection($connection)->table(self::TARGET_TABLE)
@ -609,6 +658,9 @@ class SyncStoreReceiptOrderMatches extends Command
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
'product_amount' => $productAmount,
'paid_amount' => isset($orderPaid[$group['shop_key'] . '|' . $core])
? round((float) $orderPaid[$group['shop_key'] . '|' . $core], 2)
: null,
'difference' => $difference,
'match_status' => $status,
'remark' => $remark !== '' ? $remark : null,
@ -892,9 +944,10 @@ class SyncStoreReceiptOrderMatches extends Command
'income_amount' => 0,
'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null,
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空(列必须与其它行一致,否则批量插入会因列数不匹配失败)。
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => null,
'product_amount' => null,
'paid_amount' => null,
'difference' => null,
'match_status' => self::STATUS_UNMATCHED,
'remark' => $core === '' ? self::REMARK_EMPTY_NO : self::REMARK_PROMO_ONLY,

View File

@ -878,6 +878,51 @@ class Pages extends Controller
/**
* 业绩明细 → 店铺收款匹配订单明细导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
/**
* 财务经营报表 › 快递运费:按来源 / 成品配件 / 交易单号查询本地导入的明细。
*/
private function courierFreightRows(): LengthAwarePaginator
{
$query = \App\Models\Business\CourierFreightRow::query()
->where('company_id', company_id());
foreach (['source' => 'source', 'part' => 'part'] as $param => $column) {
$value = trim((string) request($param, ''));
if ($value !== '') {
$query->where($column, $value);
}
}
$transactionNo = trim((string) request('transaction_no', ''));
if ($transactionNo !== '') {
$query->where('transaction_no', 'like', '%' . addcslashes($transactionNo, '%_\\') . '%');
}
$requestedLimit = (int) request('per_page', 0);
$limit = in_array($requestedLimit, [10, 20, 50], true)
? $requestedLimit
: min(max((int) setting('default.list_limit', 25), 1), 50);
$rows = $query->orderByDesc('id')->paginate($limit)->withQueryString();
// 「运费差」「其他附加费汇总」「退件费」不在本表里,按交易单号去「快递应付」对应汇总快照里取。
$summaries = app(\App\Business\CourierPayableSummaryMap::class);
$differences = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_FREIGHT_DIFFERENCE);
$surcharges = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_OTHER_SURCHARGE);
$returnFees = $summaries->map((int) company_id(), \App\Business\CourierPayableSummaryMap::PAGE_RETURN_FEE);
$rows->setCollection($rows->getCollection()->map(static function ($row) use ($differences, $surcharges, $returnFees) {
$orderNo = trim((string) $row->transaction_no);
$pick = static fn (array $map): ?string => $orderNo !== '' && isset($map[$orderNo]) ? $map[$orderNo] : null;
$row->setAttribute('freight_difference', $pick($differences));
$row->setAttribute('other_surcharge_amount', $pick($surcharges));
$row->setAttribute('return_fee_amount', $pick($returnFees));
return $row;
}));
return $rows;
}
public function exportPerformanceDetails(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
@ -892,6 +937,7 @@ class Pages extends Controller
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],
'match_status' => ['match_status', '匹配状态', 'text'],
'remark' => ['remark', '异常标注', 'text'],
@ -1164,6 +1210,11 @@ class Pages extends Controller
[$rows, $receiptStats] = $this->storeReceiptOrderMatchRows();
}
if (($section['slug'] ?? null) === 'financial-operations'
&& ($page['slug'] ?? null) === 'courier-freight') {
$rows = $this->courierFreightRows();
}
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 财务经营报表 › 快递运费(来源:快递方提供的成本运费明细导入)。
*/
class CourierFreightRow extends Model
{
protected $table = 'courier_freight_rows';
protected $fillable = [
'company_id',
'source',
'part',
'transaction_no',
'freight_amount',
];
protected $casts = [
'freight_amount' => 'decimal:4',
];
public static function columnLabels(): array
{
return [
'source' => '来源',
'part' => '成品配件',
'transaction_no' => '交易单号',
'freight_amount' => '提供成本运费',
];
}
public static function fieldTypes(): array
{
return [
'freight_amount' => 'currency',
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)。
*
* 页面只读共享业务镜像(jiansun.dbo.fys_shopInfo_flow + 中台库 dbo.flow_flow),
* 这个模型只用于声明页面列,不承载本地表数据。
*/
class ShopInfoFlow extends Model
{
protected $table = 'shop_info_flow_rows';
protected $fillable = [
'flow_id',
'flow_type',
'approval_status',
'current_approver',
'shop_name',
'platform',
'shop_head',
'customer_manager',
'responsible_department',
'erp_shop_name',
'apply_user',
'apply_time',
'trademark_number',
'change_reason',
'close_reason',
'expected_closing_time',
'refund_type',
'refund_amount',
'change_successful',
];
}

View File

@ -149,6 +149,29 @@ class SidebarPermissions
}
unset($section);
// 「财务经营风险管控」新增「流程统计」子页面:按页面键保存过精确权限的账号只会
// 有 store-violation-alert,会看不到新页面;这里按「能看店铺违规预警 就能看流程统计」
// 兼容旧配置(两页同属财务经营风险管控权限)。
foreach ($navigation as &$riskSection) {
if (($riskSection['slug'] ?? null) !== 'financial-risk-control') {
continue;
}
$pageSlugs = array_column($riskSection['pages'] ?? [], 'slug');
if (! in_array('store-violation-alert', $pageSlugs, true) || in_array('process-statistics', $pageSlugs, true)) {
continue;
}
$configuredPages = collect(BusinessNavigation::all())
->firstWhere('slug', 'financial-risk-control')['pages'] ?? [];
$statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics');
if ($statisticsPage) {
$riskSection['pages'][] = $statisticsPage;
}
}
unset($riskSection);
// 「种菜明细表 / 聚水潭订单标签明细」原先挂在「账单流水数据」下,后来移入新的一级菜单
// 「种菜数据汇总」。按页面键保存过精确权限的账号(访问组/角色)里不会有新的 planting-data,
// 会因此看不到这两个被移动的页面;这里按「能看账单流水数据 就能看这两个页面」兼容旧配置。

View File

@ -351,6 +351,8 @@ $navigation = [
['name' => 'account_group_name', 'label' => '账号分组名称'],
['name' => 'authorization_status', 'label' => '授权状态'],
['name' => 'store_status', 'label' => '店铺状态'],
['name' => 'flow_type', 'label' => '流程类型'],
['name' => 'approval_status', 'label' => '审批状态'],
['name' => 'created_at', 'label' => '店铺创建时间', 'kind' => 'date'],
['name' => 'updated_at', 'label' => '店铺修改时间', 'kind' => 'date'],
],
@ -801,6 +803,7 @@ $navigation = [
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
['name' => 'match_status', 'label' => '匹配状态'],
['name' => 'remark', 'label' => '异常标注'],
@ -809,7 +812,25 @@ $navigation = [
],
['slug' => 'billing-details', 'title' => '账单明细'],
['slug' => 'product-costs', 'title' => '商品成本'],
['slug' => 'courier-freight', 'title' => '快递运费'],
[
'slug' => 'courier-freight',
'title' => '快递运费',
'directory' => [
'icon' => 'local_shipping',
'status' => '本地导入',
'description' => '快递方提供的成本运费明细(来源 / 成品配件 / 交易单号 / 提供成本运费 / 运费差 / 其他附加费汇总 / 退件费),由「5-7快递提供成本运费.xlsx」等原始表导入;成品配件含「成品运费」「配件运费」两类;后三列按交易单号分别取自「应付款项 › 快递应付 › 运费差 / 其他附加费汇总 / 退件费」的汇总数据;可按来源(月份)与交易单号查询、导出。',
'empty' => '快递运费明细尚未导入,请先执行 courier-freight:import。',
'fields' => [
['name' => 'source', 'label' => '来源'],
['name' => 'part', 'label' => '成品配件'],
['name' => 'transaction_no', 'label' => '交易单号'],
['name' => 'freight_amount', 'label' => '提供成本运费', 'kind' => 'number'],
['name' => 'freight_difference', 'label' => '运费差', 'kind' => 'number'],
['name' => 'other_surcharge_amount', 'label' => '其他附加费汇总', 'kind' => 'number'],
['name' => 'return_fee_amount', 'label' => '退件费', 'kind' => 'number'],
],
],
],
['slug' => 'installation-details', 'title' => '安装明细'],
['slug' => 'promotion-expenses', 'title' => '推广费用'],
['slug' => 'advertising-expenses', 'title' => '广告费用'],
@ -829,6 +850,37 @@ $navigation = [
'icon' => 'security',
'pages' => [
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
[
'slug' => 'process-statistics',
'title' => '流程统计',
'directory' => [
'icon' => 'timeline',
'status' => '共享镜像',
'description' => '店铺申请流程(开店 / 变更 / 关店)明细,数据来自共享业务镜像,只读展示。',
'empty' => '共享镜像中暂未读取到店铺申请流程数据,请检查镜像同步状态。',
'fields' => [
['name' => 'flow_id', 'label' => '单据编号'],
['name' => 'flow_type', 'label' => '流程类型'],
['name' => 'approval_status', 'label' => '审批状态'],
['name' => 'current_approver', 'label' => '当前审批人'],
['name' => 'shop_name', 'label' => '店铺名称'],
['name' => 'platform', 'label' => '平台'],
['name' => 'shop_head', 'label' => '店铺运营负责人'],
['name' => 'customer_manager', 'label' => '店铺客服负责人'],
['name' => 'responsible_department', 'label' => '利润中心'],
['name' => 'erp_shop_name', 'label' => 'ERP店铺名称'],
['name' => 'apply_user', 'label' => '申请人'],
['name' => 'apply_time', 'label' => '申请日期', 'kind' => 'date'],
['name' => 'trademark_number', 'label' => '商标号'],
['name' => 'change_reason', 'label' => '变更原因'],
['name' => 'close_reason', 'label' => '关店原因'],
['name' => 'expected_closing_time', 'label' => '预计关店时间', 'kind' => 'date'],
['name' => 'refund_type', 'label' => '退回类型'],
['name' => 'refund_amount', 'label' => '退回金额', 'kind' => 'number'],
['name' => 'change_successful', 'label' => '是否完成', 'kind' => 'boolean'],
],
],
],
],
],
[

View File

@ -3,6 +3,21 @@
return [
'connection' => env('BUSINESS_MIRROR_DB_CONNECTION', 'business_mirror'),
/*
|--------------------------------------------------------------------------
| 自动跟随上游新增字段的数据集
|--------------------------------------------------------------------------
|
| 格式:页面 key(section/page)=> 数据集 key。
| 登记之后,上游视图/表新增字段时(数据字典同步之后)会自动把新字段补进镜像表定义
| 并建列,对应页面也会自动多出这一列,不需要再改代码。
| 只跟随新增字段,不会删除或改写已有字段;名字里带 password/secret/token/cipher
| 等敏感关键字的字段一律跳过。
*/
'auto_follow_columns' => [
'store-management/store-accounts' => 'database_4.external_store_accounts.accounts_v1',
],
// 钉钉应付申请流程只同步“应付申请流程”这一固定流程编码,
// 页面从共享业务镜像读取,不在 HTTP 请求中直连上游。
'ding_payable_workflows' => [

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务经营报表 › 快递运费。
*
* 数据来自快递方提供的成本运费明细(如「5-7快递提供成本运费.xlsx」),
* 列与源表头一一对应:来源 / 成品配件 / 交易单号 / 提供成本运费。
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('courier_freight_rows')) {
return;
}
Schema::create('courier_freight_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->string('source', 64)->comment('来源,如 5月提供成本');
$table->string('part', 191)->nullable()->comment('成品配件');
$table->string('transaction_no', 191)->comment('交易单号');
$table->decimal('freight_amount', 18, 4)->default(0)->comment('提供成本运费');
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'source'], 'courier_freight_company_source');
$table->index(['company_id', 'transaction_no'], 'courier_freight_company_txn');
});
}
public function down(): void
{
Schema::dropIfExists('courier_freight_rows');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* courier_freight_rows 建表时漏了 deleted_at,而业务模型基类带软删除,
* 导致页面查询报「Unknown column deleted_at」。这里补上;建表迁移也已同步修正,
* 新环境不会重复执行到这里。
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('courier_freight_rows')) {
return;
}
if (! Schema::hasColumn('courier_freight_rows', 'deleted_at')) {
Schema::table('courier_freight_rows', function (Blueprint $table): void {
$table->softDeletes();
});
}
}
public function down(): void
{
if (Schema::hasTable('courier_freight_rows') && Schema::hasColumn('courier_freight_rows', 'deleted_at')) {
Schema::table('courier_freight_rows', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
}
}
};

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「订单实付金额」列:订单侧实付(聚水潭 buyer_paid_amount / 企微 pay_amount),
* 用于与账单收入对账。
*/
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_receipt_order_match_rows')
|| $schema->hasColumn('store_receipt_order_match_rows', 'paid_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('paid_amount', 20, 4)->nullable()->after('product_amount');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'paid_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('paid_amount');
});
}
};

View File

@ -192,6 +192,8 @@ CRON_TZ=Asia/Shanghai
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1

View File

@ -196,6 +196,8 @@ CRON_TZ=Asia/Shanghai
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1

View File

@ -0,0 +1,216 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
*
* 表单字段来自 jiansun.dbo.fys_shopInfo_flow(聚水潭侧申请单,含店铺/主体/变更明细),
* 审批状态与当前审批人来自中台库的 OA 流程表 dbo.flow_flow(按 flowId 关联)。
*/
class ShopInfoFlowMirrorRepository
{
public const FORM_DATASET = 'jiansun.dbo.fys_shopInfo_flow';
public const FLOW_DATASET = 'js_data_middle_platform.dbo.flow_flow';
/** OA 流程类型(flowType)到页面文案。 */
public const FLOW_TYPES = [
8 => '店铺开店',
9 => '店铺变更',
10 => '店铺关店',
12 => '主体注销自动流程',
];
/** OA 审批状态(flow_flow.flowStatus),取值与文案取自 OA 前端。 */
public const APPROVAL_STATUSES = [
0 => '暂存',
1 => '流程中',
2 => '拒绝',
3 => '完成',
4 => '撤回',
5 => '待签署',
6 => '已签署',
7 => '待审批',
];
/** 店铺档案状态(fys_shopInfo.shopState 口径)。 */
private const SHOP_STATES = [0 => '正常', 3 => '关闭中', 6 => '已关闭'];
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
}
public function ready(): bool
{
try {
return $this->mirror->ready($this->datasets->get(self::FORM_DATASET));
} catch (\Throwable) {
return false;
}
}
/**
* @param array<string,mixed> $filters
* @return array{data:list<array<string,mixed>>,total:int}
*/
public function paginate(int $page, int $perPage, array $filters = []): array
{
if (! $this->ready()) {
throw new RuntimeException('shop_info_flow_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$connection = $this->mirror->connection();
$form = $this->datasets->get(self::FORM_DATASET)->targetTable();
$flow = $this->datasets->get(self::FLOW_DATASET)->targetTable();
$query = $connection->table($form . ' as f')
->leftJoin($flow . ' as o', 'o.flowId', '=', 'f.flowId');
$this->applyFilters($query, $filters);
$total = (clone $query)->count();
$rows = $query
->select([
'f.Id as id', 'f.flowId as flow_id', 'f.flowType as flow_type', 'f.applyUser as apply_user',
'f.applyTime as apply_time', 'f.applyDepartment as apply_department', 'f.remark as remark',
'f.platform as platform', 'f.responsibleDepartment as responsible_department',
'f.shopHead as shop_head', 'f.customerManager as customer_manager', 'f.shopType as shop_type',
'f.shopState as shop_state', 'f.shopName as shop_name', 'f.erpShopName as erp_shop_name',
'f.trademarkNumber as trademark_number', 'f.unifiedSocialCreditCode as subject_code',
'f.informationChangeType as information_change_type', 'f.changeReason as change_reason',
'f.changeSuccessful as change_successful', 'f.closeReason as close_reason',
'f.expectedClosingTime as expected_closing_time', 'f.refundType as refund_type',
'f.refundAmount as refund_amount', 'f.refundIssue as refund_issue',
'o.flowStatus as approval_status_code', 'o.approver as current_approver',
'o.subject as flow_subject', 'o.updateTime as flow_updated_at',
])
->orderByDesc('f.applyTime')->orderByDesc('f.Id')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
return ['data' => $rows, 'total' => $total];
}
/** @param array<string,mixed> $filters */
private function applyFilters(Builder $query, array $filters): void
{
foreach ([
'flow_id' => 'f.flowId',
'apply_user' => 'f.applyUser',
'erp_shop_name' => 'f.erpShopName',
'shop_name' => 'f.shopName',
'shop_head' => 'f.shopHead',
'current_approver' => 'o.approver',
'responsible_department' => 'f.responsibleDepartment',
'trademark_number' => 'f.trademarkNumber',
] as $key => $column) {
$value = trim((string) ($filters[$key] ?? ''));
if ($value !== '') {
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
foreach ([['from', '>='], ['to', '<=']] as [$suffix, $operator]) {
$date = trim((string) ($filters['apply_' . $suffix] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) {
$query->whereDate('f.applyTime', $operator, $date);
}
}
// 多选筛选:流程类型 / 审批状态 / 平台,多个取值取并集。
$flowTypes = $this->filterValues($filters['flow_type'] ?? '');
if ($flowTypes !== []) {
$codes = [];
foreach ($flowTypes as $label) {
$code = array_search($label, self::FLOW_TYPES, true);
if ($code !== false) {
$codes[] = (int) $code;
}
}
$query->whereIn('f.flowType', $codes === [] ? [-1] : $codes);
}
$statuses = $this->filterValues($filters['approval_status'] ?? '');
if ($statuses !== []) {
$codes = [];
foreach ($statuses as $label) {
$code = array_search($label, self::APPROVAL_STATUSES, true);
if ($code !== false) {
$codes[] = (int) $code;
}
}
$query->whereIn('o.flowStatus', $codes === [] ? [-1] : $codes);
}
$platforms = $this->filterValues($filters['platform'] ?? '');
if ($platforms !== []) {
$query->whereIn('f.platform', $platforms);
}
}
/** @return list<string> */
private function filterValues(mixed $value): array
{
$values = [];
foreach ((array) $value as $item) {
if (! is_scalar($item)) {
continue;
}
$item = trim((string) $item);
if ($item !== '') {
$values[$item] = true;
}
}
return array_keys($values);
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
$flowType = (int) ($row->flow_type ?? 0);
$statusCode = $row->approval_status_code !== null ? (int) $row->approval_status_code : null;
return [
'id' => $row->id,
'flow_id' => (string) $row->flow_id,
'flow_type' => self::FLOW_TYPES[$flowType] ?? ('其它(' . $flowType . ')'),
'flow_subject' => (string) ($row->flow_subject ?? ''),
'approval_status' => $statusCode === null ? '' : (self::APPROVAL_STATUSES[$statusCode] ?? (string) $statusCode),
'current_approver' => (string) ($row->current_approver ?? ''),
'apply_user' => (string) ($row->apply_user ?? ''),
'apply_time' => (string) ($row->apply_time ?? ''),
'apply_department' => (string) ($row->apply_department ?? ''),
'remark' => (string) ($row->remark ?? ''),
'platform' => (string) ($row->platform ?? ''),
'responsible_department' => (string) ($row->responsible_department ?? ''),
'shop_head' => (string) ($row->shop_head ?? ''),
'customer_manager' => (string) ($row->customer_manager ?? ''),
'shop_type' => (string) ($row->shop_type ?? ''),
'shop_state' => $row->shop_state === null ? '' : (self::SHOP_STATES[(int) $row->shop_state] ?? (string) $row->shop_state),
'shop_name' => (string) ($row->shop_name ?? ''),
'erp_shop_name' => (string) ($row->erp_shop_name ?? ''),
'trademark_number' => (string) ($row->trademark_number ?? ''),
'subject_code' => (string) ($row->subject_code ?? ''),
'information_change_type' => $row->information_change_type === null ? '' : (string) $row->information_change_type,
'change_reason' => (string) ($row->change_reason ?? ''),
'change_successful' => $row->change_successful === null ? null : (bool) $row->change_successful,
'close_reason' => (string) ($row->close_reason ?? ''),
'expected_closing_time' => (string) ($row->expected_closing_time ?? ''),
'refund_type' => (string) ($row->refund_type ?? ''),
'refund_amount' => $row->refund_amount === null ? null : (float) $row->refund_amount,
'refund_issue' => (string) ($row->refund_issue ?? ''),
'flow_updated_at' => (string) ($row->flow_updated_at ?? ''),
];
}
}

View File

@ -4,20 +4,39 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\FollowedUpstreamColumns;
use RuntimeException;
class StoreAccountMirrorRepository
{
public const DATASET = 'database_4.external_store_accounts.accounts_v1';
/** 页面已接入的字段:页面字段名 => 镜像列名。 */
private const FIELD_COLUMNS = [
'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',
];
/** 店铺档案(fys_shopInfo.shopState)到页面文案的映射。 */
private const STORE_STATUS_LABELS = [0 => '正常', 3 => '关闭中', 6 => '已关闭'];
/** 授权状态文案;顺序同时用于下拉和排序。 */
private const AUTHORIZATION_LABELS = ['已授权', '已过期', '未授权'];
/** 店铺申请流程(开店/变更/关店/主体注销自动)所在的数据集。 */
private const FLOW_FORM_DATASET = 'jiansun.dbo.fys_shopInfo_flow';
private const FLOW_OA_DATASET = 'js_data_middle_platform.dbo.flow_flow';
/** 流程类型 / 审批状态文案;顺序同时用于下拉和排序。 */
private const FLOW_TYPE_LABELS = ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程'];
private const FLOW_STATUS_LABELS = ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'];
/** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v1';
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v3';
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
@ -61,20 +80,30 @@ class StoreAccountMirrorRepository
// 授权状态 / 店铺状态来自另两张镜像表(按店铺名关联),这里先在 PHP 里
// 把"账号店铺名 -> 状态"算出来,再用 whereIn 下推到 SQL,分页和总数不受影响。
$statuses = (trim((string) ($filters['authorization_status'] ?? '')) !== ''
|| trim((string) ($filters['store_status'] ?? '')) !== '')
$statusFilters = [
'store_status' => $this->filterValues($filters['store_status'] ?? ''),
'authorization_status' => $this->filterValues($filters['authorization_status'] ?? ''),
'flow_type' => $this->filterValues($filters['flow_type'] ?? ''),
'approval_status' => $this->filterValues($filters['approval_status'] ?? ''),
];
$statuses = ($statusFilters['store_status'] !== []
|| $statusFilters['authorization_status'] !== []
|| $statusFilters['flow_type'] !== []
|| $statusFilters['approval_status'] !== [])
? $this->statusesByStoreName()
: null;
if ($statuses !== null) {
foreach ([
'store_status' => $statuses['store'],
'authorization_status' => $statuses['authorization'],
] as $key => $byStore) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected === '') {
'store_status' => 'store',
'authorization_status' => 'authorization',
'flow_type' => 'flow_type',
'approval_status' => 'flow',
] as $key => $bucket) {
$expected = $statusFilters[$key];
if ($expected === []) {
continue;
}
$query->whereIn('storeName', $this->storeNamesWithStatus($byStore, $expected));
$query->whereIn('storeName', $this->storeNamesWithStatuses($statuses[$bucket], $expected));
}
}
$total = (clone $query)->count();
@ -99,32 +128,50 @@ class StoreAccountMirrorRepository
$statuses ??= $this->statusesByStoreName();
[$sql, $bindings] = $this->statusOrderBy($statuses[$sortKey], $labels, $direction);
$query->orderByRaw($sql, $bindings)->orderByDesc('id');
} elseif (in_array($filters['_sort'] ?? '', ['flow_type', 'approval_status'], true)) {
$statuses ??= $this->statusesByStoreName();
$sortedKey = $filters['_sort'] === 'flow_type' ? 'flow_type' : 'flow';
$labels = $filters['_sort'] === 'flow_type' ? self::FLOW_TYPE_LABELS : self::FLOW_STATUS_LABELS;
[$sql, $bindings] = $this->statusOrderBy($statuses[$sortedKey], $labels, $direction);
$query->orderByRaw($sql, $bindings)->orderByDesc('id');
} else {
$query->orderByDesc('id');
}
$statuses ??= $this->statusesByStoreName();
$followedColumns = $this->followedColumns();
$rows = $query
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => [
'id' => $row->id,
'store_name' => $row->storeName,
'operation_owner' => $row->operationOwner,
'operation_department_head' => $row->operationDepartmentHead,
'operation_director' => $row->operationDirector,
'profit_center' => $row->profitCenter,
'account_type' => $this->accountType($row->accountType),
'account' => $row->account,
// Only the safe boolean may enter the mirror, never a password.
'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null,
'bound_phone' => $row->boundPhone,
'verification_contact' => $row->verificationContact,
'account_group_name' => $row->accountGroupName,
'authorization_status' => $statuses['authorization'][$row->storeName] ?? '',
'store_status' => $statuses['store'][$row->storeName] ?? '',
'created_at' => $row->createdAt,
'updated_at' => $row->updatedAt,
])
->map(function (object $row) use ($statuses, $followedColumns): array {
$mapped = [
'id' => $row->id,
'store_name' => $row->storeName,
'operation_owner' => $row->operationOwner,
'operation_department_head' => $row->operationDepartmentHead,
'operation_director' => $row->operationDirector,
'profit_center' => $row->profitCenter,
'account_type' => $this->accountType($row->accountType),
'account' => $row->account,
// Only the safe boolean may enter the mirror, never a password.
'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null,
'bound_phone' => $row->boundPhone,
'verification_contact' => $row->verificationContact,
'account_group_name' => $row->accountGroupName,
'authorization_status' => $statuses['authorization'][$row->storeName] ?? '',
'store_status' => $statuses['store'][$row->storeName] ?? '',
'flow_type' => $statuses['flow_type'][$row->storeName] ?? '',
'approval_status' => $statuses['flow'][$row->storeName] ?? '',
'created_at' => $row->createdAt,
'updated_at' => $row->updatedAt,
];
// 自动跟随上游补进来的字段:镜像里有什么就带什么,页面据此多出对应列。
foreach ($followedColumns as $column) {
$mapped[$column] = $row->{$column} ?? null;
}
return $mapped;
})
->all();
return ['data' => $rows, 'total' => $total];
@ -172,26 +219,99 @@ class StoreAccountMirrorRepository
: ($timestamp >= $now ? '已授权' : '已过期');
}
$result = ['store' => [], 'authorization' => []];
$flows = $this->latestFlowByStoreName();
$result = ['store' => [], 'authorization' => [], 'flow' => [], 'flow_type' => []];
foreach ($this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->select('storeName')->whereNotNull('storeName')->distinct()->pluck('storeName') as $name) {
$name = (string) $name;
$key = $this->shopKey($name);
$result['store'][$name] = $storeStates[$key] ?? '';
$result['authorization'][$name] = $authorizations[$key] ?? '';
$result['flow_type'][$name] = $flows[$key]['flow_type'] ?? '';
$result['flow'][$name] = $flows[$key]['approval_status'] ?? '';
}
Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(10));
// 状态取自店铺档案与流程镜像,变化很慢;全量算一次要几秒,缓存久一点。
Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(30));
return $result;
}
/** @return list<string> 指定状态下的账号店铺名 */
private function storeNamesWithStatus(array $byStore, string $expected): array
/**
* 账号店铺 -> 最近一单店铺申请流程(开店/变更/关店/主体注销自动)的类型与审批状态。
*
* 按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow,同一店铺取申请时间最近的一单;
* 审批状态用中台库 OA 流程表(dbo.flow_flow)的字典文案。
*
* @return array<string,array{flow_type:string,approval_status:string}> 归一键 => 流程信息
*/
private function latestFlowByStoreName(): array
{
try {
$form = $this->datasets->get(self::FLOW_FORM_DATASET)->targetTable();
$oa = $this->datasets->get(self::FLOW_OA_DATASET)->targetTable();
} catch (\Throwable) {
// 店铺申请流程数据集尚未接入时,这两列留空即可,不影响其它列。
return [];
}
try {
$rows = $this->mirror->connection()->table($form . ' as f')
->leftJoin($oa . ' as o', 'o.flowId', '=', 'f.flowId')
->select(['f.erpShopName as erp_shop_name', 'f.flowType as flow_type', 'o.flowStatus as flow_status'])
->orderByDesc('f.applyTime')->orderByDesc('f.Id')
->get();
} catch (\Throwable $exception) {
report($exception);
return [];
}
$flows = [];
foreach ($rows as $row) {
$key = $this->shopKey($row->erp_shop_name);
if ($key === '' || array_key_exists($key, $flows)) {
continue; // 已按申请时间倒序,首次出现的就是最近一单
}
$type = (int) $row->flow_type;
$flows[$key] = [
'flow_type' => ShopInfoFlowMirrorRepository::FLOW_TYPES[$type] ?? ('其它(' . $type . ')'),
'approval_status' => $row->flow_status === null
? ''
: (ShopInfoFlowMirrorRepository::APPROVAL_STATUSES[(int) $row->flow_status] ?? (string) $row->flow_status),
];
}
return $flows;
}
/**
* 筛选值统一成列表:单选("正常")和多选(["正常","已关闭"])都支持。
*
* @return list<string>
*/
private function filterValues(mixed $value): array
{
$values = [];
foreach ((array) $value as $item) {
if (! is_scalar($item)) {
continue;
}
$item = trim((string) $item);
if ($item !== '') {
$values[$item] = true;
}
}
return array_keys($values);
}
/** @return list<string> 命中任一指定状态的账号店铺名 */
private function storeNamesWithStatuses(array $byStore, array $expected): array
{
$names = [];
foreach ($byStore as $name => $status) {
if ($status === $expected) {
if (in_array($status, $expected, true)) {
$names[] = $name;
}
}
@ -212,7 +332,7 @@ class StoreAccountMirrorRepository
$bindings = [];
foreach ($labels as $index => $label) {
$names = $this->storeNamesWithStatus($byStore, $label);
$names = $this->storeNamesWithStatuses($byStore, [$label]);
if ($names === []) {
continue;
}
@ -233,6 +353,23 @@ class StoreAccountMirrorRepository
return str_replace(['_', ' ', ' '], ['-', '', ''], trim((string) $name));
}
/** @return list<string> 自动跟随上游补进来的字段(镜像列名) */
public function followedColumns(): array
{
return app(FollowedUpstreamColumns::class)->columns(self::DATASET);
}
/**
* 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。
*
* @param list<array<string,mixed>> $fields
* @return list<array<string,mixed>>
*/
public function withFollowedFields(array $fields): array
{
return app(FollowedUpstreamColumns::class)->appendFields(self::DATASET, $fields);
}
public function containsAccount(string $id): bool
{
$dataset = $this->datasets->get(self::DATASET);

View File

@ -71,6 +71,12 @@ class BusinessMirrorDatasetRegistry
return $this->definitions()[$key]['columns'] ?? null;
}
/** 读取登记数据集里的自定义键(例如自动跟随上游补进来的字段列表)。 */
public function definitionValue(string $key, string $name, mixed $default = null): mixed
{
return $this->definitions()[$key][$name] ?? $default;
}
/** @return array<string,scalar|list<scalar>> */
public function fixedFilters(string $key): array
{

View File

@ -0,0 +1,49 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 自动跟随上游新增字段:读取数据集定义里记录的 followed_columns。
*
* 字段由 `business-mirror:follow-upstream-columns` 写入(见 config/upstream_mirrors.php
* 的 auto_follow_columns);页面据此自动补列,镜像行里带上同名键即可直接展示。
*/
class FollowedUpstreamColumns
{
public function __construct(private readonly BusinessMirrorDatasetRegistry $datasets)
{
}
/** @return list<string> */
public function columns(string $datasetKey): array
{
$columns = [];
foreach ((array) $this->datasets->definitionValue($datasetKey, 'followed_columns', []) as $column) {
$column = trim((string) $column);
if ($column !== '' && ! str_starts_with($column, '_mirror')) {
$columns[] = $column;
}
}
return array_values(array_unique($columns));
}
/**
* 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。
*
* @param list<array<string,mixed>> $fields
* @return list<array<string,mixed>>
*/
public function appendFields(string $datasetKey, array $fields): array
{
$existing = array_map(static fn (array $field): string => (string) ($field['name'] ?? ''), $fields);
foreach ($this->columns($datasetKey) as $column) {
if (in_array($column, $existing, true)) {
continue;
}
$fields[] = ['name' => $column, 'label' => $column, 'followed' => true];
}
return $fields;
}
}

View File

@ -47,6 +47,8 @@
'company-management/tax-forecast',
'company-management/personnel-inventory',
'store-management/internal-flow-report',
'financial-operations/courier-freight',
'financial-risk-control/process-statistics',
], true);
$directoryQueryPageDescriptions = [
'company-management/basic-information' => '公司主体、状态和登记资料的查询与导出',
@ -60,6 +62,7 @@
'company-management/personnel-inventory' => '按人员、职位和公司类型查询人员盘点字段',
'product-inventory/monthly-weighted-average-price' => '按月份、商品编码、仓储方和分类查询商品月加权平均单价',
'store-management/internal-flow-report' => '按抬头、标签查询内部流转明细并导出',
'financial-operations/courier-freight' => '按来源(月份)和交易单号查询快递方提供的成本运费明细',
];
@endphp
@ -578,6 +581,18 @@
text-align: right;
}
/* 快递运费:这四列是从来源表/快递应付带出来的金额,按业务要求表头和数值都靠左展示。 */
.directory-table-page .directory-table th.is-number[data-column-key="freight_amount"],
.directory-table-page .directory-table td.is-number[data-column-key="freight_amount"],
.directory-table-page .directory-table th.is-number[data-column-key="freight_difference"],
.directory-table-page .directory-table td.is-number[data-column-key="freight_difference"],
.directory-table-page .directory-table th.is-number[data-column-key="other_surcharge_amount"],
.directory-table-page .directory-table td.is-number[data-column-key="other_surcharge_amount"],
.directory-table-page .directory-table th.is-number[data-column-key="return_fee_amount"],
.directory-table-page .directory-table td.is-number[data-column-key="return_fee_amount"] {
text-align: left;
}
.directory-table .is-empty td {
height: 8rem;
color: var(--vip-content-muted, #595959);
@ -1082,6 +1097,15 @@
$directoryExcludedQueryFields = match ($directoryPageKey) {
'store-management/basic-information' => ['opened_at', 'renewed_at'],
'trademark-management/information' => ['registration_date', 'expiry_date'],
// 快递运费:金额 / 配件类别 / 运费差 / 其他附加费 / 退件费都不做查询条件(后三列来自快递应付的汇总快照)。
'financial-operations/courier-freight' => [
'part', 'freight_amount', 'freight_difference', 'other_surcharge_amount', 'return_fee_amount',
],
// 流程统计:查询条件保留外部店铺申请页那一套(单据编号/申请人/日期/审批人/店铺/平台/负责人/利润中心/商标/类型/状态),其余列只做展示。
'financial-risk-control/process-statistics' => [
'shop_name', 'customer_manager', 'change_reason', 'close_reason',
'expected_closing_time', 'refund_type', 'refund_amount', 'change_successful',
],
default => [],
};
if (!empty($directoryExcludedQueryFields)) {
@ -1109,21 +1133,45 @@
->all();
$directoryPrimaryQueryFields = collect($directoryQueryFields)
->reject(fn (array $field): bool => in_array($field['name'], array_column($directoryMoreQueryFields, 'name'), true))
// 自动跟随上游补进来的列只做展示,不生成查询条件(后端没有对应的筛选口径)。
->reject(fn (array $field): bool => (bool) ($field['followed'] ?? false))
->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', 'authorization_status', 'store_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'],
'financial-operations/courier-freight' => ['source'],
default => [],
};
// 这些字段渲染成可搜索多选(按钮 + 复选面板),提交为 xxx[],多个取值取并集。
$directoryMultiSelectOptions = match (($section['slug'] ?? '') . '/' . ($page['slug'] ?? '')) {
'store-management/store-accounts' => [
'authorization_status' => ['已授权', '已过期', '未授权'],
'store_status' => ['正常', '关闭中', '已关闭'],
// 该店铺最近一单申请流程(开店/变更/关店/主体注销自动)的类型与审批状态。
'flow_type' => ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程'],
'approval_status' => ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'],
],
'financial-risk-control/process-statistics' => [
'flow_type' => ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程'],
'approval_status' => ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'],
'platform' => [
'京东', '京东供销', '京东自营', '京喜企业店', '唯品会', '唯品会MP', '外贸', '外贸部',
'天猫', '小红书', '希音', '得物', '快手小店', '抖音小店', '拼多多', '拼多多跨境',
'淘宝', '淘宝分销', '淘工厂', '红星', '视频号小店', '贝贝', '速卖通', '阿里巴巴', '阿里巴巴国际站',
],
],
default => [],
};
$directoryMultiSelectFields = array_keys($directoryMultiSelectOptions);
$directoryColumnNames = collect($directoryFields)->pluck('name')->all();
$requestedDirectoryColumns = array_values(array_intersect(
array_filter(explode(',', (string) request('columns', ''))),
@ -1146,12 +1194,17 @@
$directoryQueryFormId = 'directory-query-form-' . ($page['slug'] ?? 'page');
@endphp
@if ($directoryUsesFieldQuery)
@if ($directoryMultiSelectFields !== [])
@include('business.pages.partials.multi-select')
@endif
<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>
{{-- 这个空表单只是给下面的控件当 form= 的提交目标;不隐藏的话会占掉栅格第一格,
把筛选字段整体挤到第二列去。 --}}
<form id="{{ $directoryQueryFormId }}" method="GET" action="{{ request()->url() }}" hidden></form>
@if (request()->has('columns'))
<input type="hidden" form="{{ $directoryQueryFormId }}" name="columns" value="{{ implode(',', $visibleDirectoryColumns) }}">
@endif
@ -1167,6 +1220,29 @@
'created_at',
'updated_at',
], true))
@if (in_array($field['name'], $directoryMultiSelectFields, true))
@php
$multiSelected = array_values(array_filter(
array_map(static fn (mixed $value): string => is_scalar($value) ? trim((string) $value) : '', (array) request($field['name'], [])),
static fn (string $value): bool => $value !== '',
));
@endphp
<div class="directory-query-field cw-ms" data-directory-query-field="{{ $field['name'] }}"
data-directory-multi-select="{{ $field['name'] }}"
data-name="{{ $field['name'] }}"
data-initial="{{ json_encode($multiSelected, JSON_UNESCAPED_UNICODE) }}"
data-options="{{ json_encode($directoryMultiSelectOptions[$field['name']] ?? [], JSON_UNESCAPED_UNICODE) }}">
<span title="{{ $field['label'] }}">{{ $field['label'] }}</span>
<span class="cw-ms__box">
<button type="button" class="cw-ms__btn" data-role="btn" aria-expanded="false">
<span class="cw-ms__label">全部</span><span class="cw-ms__caret">▾</span>
</button>
<span class="cw-ms__panel" data-role="panel" hidden></span>
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
@continue
@endif
<label class="directory-query-field" data-directory-query-field="{{ $field['name'] }}">
<span title="{{ $field['label'] }}">{{ $field['label'] }}</span>
@if ($kind === 'date')
@ -1196,6 +1272,9 @@
'can_change', 'can_change_entity' => ['是', '否'],
'invoice_type' => ['普票', '专票'],
'tag' => ['1', '2', '3', '4'],
'source' => \App\Models\Business\CourierFreightRow::query()
->where('company_id', company_id())
->distinct()->orderBy('source')->pluck('source')->all(),
default => [],
};
@endphp
@ -1297,6 +1376,20 @@
</details>
</div>
</div>
@if ($directoryMultiSelectFields !== [])
<script>
// 多选筛选:勾选结果写成 name[] 并关联到查询表单,一个都不勾=全部。
(function () {
var api = window.CaiWuMultiSelect;
if (!api) { return; }
document.querySelectorAll('[data-directory-multi-select]').forEach(function (el) {
var options = [];
try { options = JSON.parse(el.getAttribute('data-options') || '[]') || []; } catch (e) { options = []; }
api.create(el, { options: options, searchable: false, formId: @json($directoryQueryFormId) });
});
})();
</script>
@endif
@endif
@if (($page['slug'] ?? '') === 'store-report' && ! $directoryUsesFieldQuery)
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="店铺维度报表筛选查询栏">
@ -2393,6 +2486,8 @@
fields.forEach((field) => {
const key = field.dataset.directoryQueryField;
url.searchParams.delete(key); url.searchParams.delete(`${key}_from`); url.searchParams.delete(`${key}_to`);
// 多选筛选提交的是 key[],重置时一并清掉。
url.searchParams.delete(`${key}[]`);
});
url.searchParams.delete('page');
window.location.assign(url);

View File

@ -224,8 +224,10 @@
btn.classList.add('cw-ms__btn--on');
}
btn.title = selectedList.length ? selectedList.join('、') : '全部';
// 目录表把控件放在 query 表单外面(靠 form 属性关联),这里支持传 formId。
var formAttr = opts.formId ? ' form="' + esc(opts.formId) + '"' : '';
valuesEl.innerHTML = selectedList.map(function (v) {
return '<input type="hidden" name="' + esc(name) + '[]" value="' + esc(v) + '">';
return '<input type="hidden" name="' + esc(name) + '[]"' + formAttr + ' value="' + esc(v) + '">';
}).join('');
}

View File

@ -15,6 +15,15 @@
$activeContentTitle = $activeTab['title'] ?? ($page['title'] ?? $section['title']);
$directory = $page['directory'] ?? null;
// 上游新增字段自动补列:config/upstream_mirrors.auto_follow_columns 登记过的页面,
// 会自动多出 business-mirror:follow-upstream-columns 补进来的列。
$followedDataset = config('upstream_mirrors.auto_follow_columns.'
. (($section['slug'] ?? '') . '/' . ($page['slug'] ?? '')));
if (is_array($directory) && is_string($followedDataset) && $followedDataset !== '') {
$directory['fields'] = app(\Modules\EcommerceFinance\Services\FollowedUpstreamColumns::class)
->appendFields($followedDataset, $directory['fields'] ?? []);
}
if ($isMarketingAccountBillsPage && isset($marketingAccountTabTitles[$activeTab['slug'] ?? null])) {
$activeContentTitle = $marketingAccountTabTitles[$activeTab['slug']];
}
@ -578,6 +587,9 @@
@include('business.pages.supplier-payables.navigation', ['section' => $supplierWorkspaceSection])
@elseif ($isCourierFinalDirectory)
@include('business.pages.partials.section-page-tabs', ['section' => $section])
@elseif (($section['slug'] ?? '') === 'financial-risk-control')
{{-- 财务经营风险管控下已有多个页面,用目录内页签切换。 --}}
@include('business.pages.partials.section-page-tabs', ['section' => $section])
@endif
@if ($tabs->isNotEmpty())

View File

@ -317,6 +317,14 @@ class StoreAccountPasswordsTest extends FeatureTestCase
// 账号分组名称后面新增的两列。
->assertSeeText('授权状态')
->assertSeeText('店铺状态')
->assertSeeText('流程类型')
->assertSeeText('审批状态')
// 授权状态 / 店铺状态按可搜索多选渲染(提交为 xxx[])。
->assertSee('data-directory-multi-select="authorization_status"', false)
->assertSee('data-directory-multi-select="store_status"', false)
->assertSee('data-directory-multi-select="flow_type"', false)
->assertSee('data-directory-multi-select="approval_status"', false)
->assertSee('CaiWuMultiSelect', false)
->assertDontSee('data-password-dialog', false)
->assertDontSee('data-store-password="view"', false)
->assertDontSee($this->fixture);

View File

@ -131,6 +131,37 @@ class NavigationTest extends FeatureTestCase
$this->assertSame('security', $navigation[$riskControlIndex]['icon']);
$this->assertSame([
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
[
'slug' => 'process-statistics',
'title' => '流程统计',
'directory' => [
'icon' => 'timeline',
'status' => '共享镜像',
'description' => '店铺申请流程(开店 / 变更 / 关店)明细,数据来自共享业务镜像,只读展示。',
'empty' => '共享镜像中暂未读取到店铺申请流程数据,请检查镜像同步状态。',
'fields' => [
['name' => 'flow_id', 'label' => '单据编号'],
['name' => 'flow_type', 'label' => '流程类型'],
['name' => 'approval_status', 'label' => '审批状态'],
['name' => 'current_approver', 'label' => '当前审批人'],
['name' => 'shop_name', 'label' => '店铺名称'],
['name' => 'platform', 'label' => '平台'],
['name' => 'shop_head', 'label' => '店铺运营负责人'],
['name' => 'customer_manager', 'label' => '店铺客服负责人'],
['name' => 'responsible_department', 'label' => '利润中心'],
['name' => 'erp_shop_name', 'label' => 'ERP店铺名称'],
['name' => 'apply_user', 'label' => '申请人'],
['name' => 'apply_time', 'label' => '申请日期', 'kind' => 'date'],
['name' => 'trademark_number', 'label' => '商标号'],
['name' => 'change_reason', 'label' => '变更原因'],
['name' => 'close_reason', 'label' => '关店原因'],
['name' => 'expected_closing_time', 'label' => '预计关店时间', 'kind' => 'date'],
['name' => 'refund_type', 'label' => '退回类型'],
['name' => 'refund_amount', 'label' => '退回金额', 'kind' => 'number'],
['name' => 'change_successful', 'label' => '是否完成', 'kind' => 'boolean'],
],
],
],
], $navigation[$riskControlIndex]['pages']);
$this->loginAs()
@ -157,6 +188,28 @@ class NavigationTest extends FeatureTestCase
->assertSee('/workspace/financial-risk-control/store-violation-alert', false);
}
public function testProcessStatisticsPageIsAvailableUnderRiskControl(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-risk-control',
'page' => 'process-statistics',
]))
->assertOk()
->assertSeeText('流程统计')
// 店铺申请流程镜像:列头与查询条件都按外部店铺申请页对齐。
->assertSeeText('单据编号')
->assertSeeText('审批状态')
->assertSeeText('ERP店铺名称')
->assertSee('data-directory-multi-select="flow_type"', false)
->assertSee('data-directory-multi-select="approval_status"', false)
// 测试环境没有业务镜像,页面给出同步提示而不是报错。
->assertSeeText('店铺申请流程共享镜像尚未完成首次同步。')
// 目录内页签:两个页面可以互相切换。
->assertSee('/workspace/financial-risk-control/store-violation-alert', false)
->assertSee('/workspace/financial-risk-control/process-statistics', false);
}
public function testGroupedNavigationKeepsRestrictedPagePermissions(): void
{
$this->user->roles()->sync([Role::where('name', 'manager')->firstOrFail()->id]);

View File

@ -141,7 +141,26 @@ class UserPermissionsTest extends FeatureTestCase
$this->assertSame(['financial-risk-control'], array_column($navigation, 'slug'));
$this->assertSame(
['store-violation-alert'],
['store-violation-alert', 'process-statistics'],
array_column($navigation[0]['pages'], 'slug')
);
}
public function test_process_statistics_follows_the_violation_alert_page_permission(): void
{
// 只按页面键授权过的账号(旧配置)也要能看到新增的「流程统计」,与店铺违规预警同权限。
$regular = $this->account('user');
$subject = 'user_'.$regular->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => ['financial-risk-control/store-violation-alert']],
]);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(['financial-risk-control'], array_column($navigation, 'slug'));
$this->assertSame(
['store-violation-alert', 'process-statistics'],
array_column($navigation[0]['pages'], 'slug')
);
}

View File

@ -41,6 +41,11 @@ class BusinessMirrorScheduleTest extends TestCase
$contents,
$script,
);
$this->assertStringContainsString(
'40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet',
$contents,
$script,
);
$minuteLines = array_values(array_filter(
preg_split('/\R/', $contents) ?: [],
static fn (string $line): bool => str_starts_with($line, '* * * * * root'),

View File

@ -0,0 +1,98 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class FollowUpstreamColumnsTest extends TestCase
{
private const DATASET = 'demo.accounts';
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [],
'upstream_mirrors.auto_follow_columns' => ['demo/page' => self::DATASET],
]);
DB::purge('business_mirror');
DB::table('business_mirror_datasets')->insert([
'dataset_key' => self::DATASET,
'source_key' => 'demo',
'definition' => json_encode([
'source_key' => 'demo', 'schema' => 'external', 'object' => 'accounts_v1',
'target' => 'demo_accounts', 'keys' => ['id'], 'columns' => ['id', 'name'],
], JSON_THROW_ON_ERROR),
'sync_enabled' => true,
'initial_sync_approved' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$this->insertCatalogColumn('id', 1);
$this->insertCatalogColumn('name', 2);
}
private function insertCatalogColumn(string $column, int $position): void
{
DB::table('upstream_schema_columns')->insert([
'source_key' => 'demo',
'schema_name' => 'external',
'object_name' => 'accounts_v1',
'object_type' => 'view',
'column_name' => $column,
'ordinal_position' => $position,
'data_type' => 'varchar',
'max_length' => 191,
'synchronized_at' => now(),
]);
}
public function test_it_adds_new_upstream_columns_and_skips_sensitive_ones(): void
{
$this->insertCatalogColumn('platform', 3);
$this->insertCatalogColumn('passwordCiphertext', 4);
$this->artisan('business-mirror:follow-upstream-columns')->assertSuccessful();
$definition = json_decode(
(string) DB::table('business_mirror_datasets')->where('dataset_key', self::DATASET)->value('definition'),
true,
);
$this->assertSame(['id', 'name', 'platform'], $definition['columns']);
$this->assertSame(['platform'], $definition['followed_columns']);
$this->assertTrue(Schema::connection('business_mirror')->hasColumn('demo_accounts', 'platform'));
$this->assertFalse(Schema::connection('business_mirror')->hasColumn('demo_accounts', 'passwordCiphertext'));
}
public function test_it_is_idempotent_when_there_is_nothing_new(): void
{
$this->insertCatalogColumn('platform', 3);
$this->artisan('business-mirror:follow-upstream-columns')->assertSuccessful();
$definition = json_decode(
(string) DB::table('business_mirror_datasets')->where('dataset_key', self::DATASET)->value('definition'),
true,
);
$this->assertSame(['id', 'name', 'platform'], $definition['columns']);
// 再跑一次不会重复追加,也不会重复记录。
$this->artisan('business-mirror:follow-upstream-columns')->assertSuccessful();
$again = json_decode(
(string) DB::table('business_mirror_datasets')->where('dataset_key', self::DATASET)->value('definition'),
true,
);
$this->assertSame(['id', 'name', 'platform'], $again['columns']);
$this->assertSame(['platform'], $again['followed_columns']);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class ShopInfoFlowMirrorRepositoryTest extends TestCase
{
private ShopInfoFlowMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
ShopInfoFlowMirrorRepository::FORM_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_shopInfo_flow',
'target' => 'shop_info_flow_form', 'keys' => ['Id'],
],
ShopInfoFlowMirrorRepository::FLOW_DATASET => [
'source_key' => 'js_data_middle_platform', 'schema' => 'dbo', 'object' => 'flow_flow',
'target' => 'oa_flow_flow', 'keys' => ['Id'],
],
],
]);
DB::purge('business_mirror');
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('shop_info_flow_form', function (Blueprint $table): void {
foreach ([
'Id', 'flowId', 'flowType', 'applyUser', 'applyTime', 'applyDepartment', 'remark', 'platform',
'responsibleDepartment', 'shopHead', 'customerManager', 'shopType', 'shopState', 'shopName',
'erpShopName', 'trademarkNumber', 'unifiedSocialCreditCode', 'informationChangeType',
'changeReason', 'changeSuccessful', 'closeReason', 'expectedClosingTime', 'refundType',
'refundAmount', 'refundIssue',
] as $column) {
$table->string($column)->nullable();
}
});
$schema->create('oa_flow_flow', function (Blueprint $table): void {
foreach (['Id', 'flowId', 'flowType', 'flowStatus', 'subject', 'approver', 'updateTime'] as $column) {
$table->string($column)->nullable();
}
});
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => ShopInfoFlowMirrorRepository::FORM_DATASET,
'initial_completed_at' => '2026-10-08 10:00:00',
]);
// 三单:开店(完成)、变更(流程中,有当前审批人)、关店(拒绝)。
$this->insertApplication(1, 'shp-1', 8, '拼多多', '甲店', '拼多多-甲店', '张三', '2026-10-01 09:00:00');
$this->insertApplication(2, 'shp-2', 9, '京东', '乙店', '京东-乙店', '李四', '2026-10-02 09:00:00');
$this->insertApplication(3, 'shp-3', 10, '抖音小店', '丙店', '抖音-丙店', '王五', '2026-10-03 09:00:00');
DB::connection('business_mirror')->table('oa_flow_flow')->insert([
['Id' => 1, 'flowId' => 'shp-1', 'flowType' => 8, 'flowStatus' => 3, 'subject' => '[店铺信息-开店流程]', 'approver' => '', 'updateTime' => '2026-10-01 10:00:00'],
['Id' => 2, 'flowId' => 'shp-2', 'flowType' => 9, 'flowStatus' => 1, 'subject' => '[店铺信息-变更流程]', 'approver' => '审核人A', 'updateTime' => '2026-10-02 10:00:00'],
['Id' => 3, 'flowId' => 'shp-3', 'flowType' => 10, 'flowStatus' => 2, 'subject' => '[店铺信息-关店流程]', 'approver' => '', 'updateTime' => '2026-10-03 10:00:00'],
]);
$this->repository = new ShopInfoFlowMirrorRepository(
new BusinessMirrorDatasetRegistry(),
new GenericBusinessMirrorRepository(app('db')),
);
}
private function insertApplication(int $id, string $flowId, int $flowType, string $platform, string $shop, string $erpShop, string $user, string $applyTime): void
{
DB::connection('business_mirror')->table('shop_info_flow_form')->insert([
'Id' => $id, 'flowId' => $flowId, 'flowType' => $flowType, 'platform' => $platform,
'shopName' => $shop, 'erpShopName' => $erpShop, 'applyUser' => $user, 'applyTime' => $applyTime,
'shopHead' => $user, 'responsibleDepartment' => '运营中心A',
]);
}
public function test_it_lists_applications_with_oa_approval_state(): void
{
$result = $this->repository->paginate(1, 10);
$this->assertSame(3, $result['total']);
$rows = collect($result['data'])->keyBy('flow_id');
$this->assertSame('店铺开店', $rows['shp-1']['flow_type']);
$this->assertSame('完成', $rows['shp-1']['approval_status']);
$this->assertSame('店铺变更', $rows['shp-2']['flow_type']);
$this->assertSame('流程中', $rows['shp-2']['approval_status']);
$this->assertSame('审核人A', $rows['shp-2']['current_approver']);
$this->assertSame('店铺关店', $rows['shp-3']['flow_type']);
$this->assertSame('拒绝', $rows['shp-3']['approval_status']);
// 默认按申请时间倒序。
$this->assertSame(['shp-3', 'shp-2', 'shp-1'], array_column($result['data'], 'flow_id'));
}
public function test_it_filters_by_type_status_platform_and_dates(): void
{
$this->assertSame(1, $this->repository->paginate(1, 10, ['flow_type' => ['店铺开店']])['total']);
$this->assertSame(
2,
$this->repository->paginate(1, 10, ['flow_type' => ['店铺开店', '店铺变更']])['total'],
);
$this->assertSame(1, $this->repository->paginate(1, 10, ['approval_status' => ['流程中']])['total']);
$this->assertSame(2, $this->repository->paginate(1, 10, ['platform' => ['拼多多', '京东']])['total']);
$this->assertSame(1, $this->repository->paginate(1, 10, ['apply_from' => '2026-10-03'])['total']);
$this->assertSame(2, $this->repository->paginate(1, 10, ['apply_to' => '2026-10-02'])['total']);
$this->assertSame(1, $this->repository->paginate(1, 10, ['apply_user' => '李四'])['total']);
$this->assertSame(1, $this->repository->paginate(1, 10, ['erp_shop_name' => '丙店'])['total']);
$this->assertSame(0, $this->repository->paginate(1, 10, ['flow_id' => '不存在'])['total']);
}
public function test_missing_oa_row_leaves_approval_state_blank(): void
{
DB::connection('business_mirror')->table('shop_info_flow_form')->insert([
'Id' => 4, 'flowId' => 'shp-4', 'flowType' => 8, 'platform' => '天猫',
'shopName' => '丁店', 'applyUser' => '赵六', 'applyTime' => '2026-10-04 09:00:00',
]);
$rows = collect($this->repository->paginate(1, 10)['data'])->keyBy('flow_id');
$this->assertSame('', $rows['shp-4']['approval_status']);
$this->assertSame('', $rows['shp-4']['current_approver']);
}
}

View File

@ -32,6 +32,16 @@ class StoreAccountMirrorRepositoryTest extends TestCase
'object' => 'accounts_v1',
'target' => 'store_accounts',
'keys' => ['id'],
// 模拟 business-mirror:follow-upstream-columns 记录下来的跟随字段。
'followed_columns' => ['platform'],
],
'jiansun.dbo.fys_shopInfo_flow' => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_shopInfo_flow',
'target' => 'shop_info_flow_form', 'keys' => ['Id'],
],
'js_data_middle_platform.dbo.flow_flow' => [
'source_key' => 'js_data_middle_platform', 'schema' => 'dbo', 'object' => 'flow_flow',
'target' => 'oa_flow_flow', 'keys' => ['Id'],
],
],
]);
@ -47,6 +57,8 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$table->string($column)->nullable();
}
$table->boolean('passwordConfigured')->nullable();
// 自动跟随上游补进来的字段。
$table->string('platform')->nullable();
});
// 授权状态 / 店铺状态来自这两张镜像表(按店铺名关联)。
Schema::connection('business_mirror')->create('ops_fys_shop_info', function (Blueprint $table): void {
@ -59,6 +71,19 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$table->string('shop_name')->nullable();
$table->dateTime('session_expired')->nullable();
});
// 店铺关店流程:表单(含 ERP 店铺名)+ OA 流程状态。
Schema::connection('business_mirror')->create('shop_info_flow_form', function (Blueprint $table): void {
$table->integer('Id');
$table->string('flowId')->nullable();
$table->integer('flowType')->nullable();
$table->string('erpShopName')->nullable();
$table->dateTime('applyTime')->nullable();
});
Schema::connection('business_mirror')->create('oa_flow_flow', function (Blueprint $table): void {
$table->integer('Id');
$table->string('flowId')->nullable();
$table->integer('flowStatus')->nullable();
});
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => StoreAccountMirrorRepository::DATASET,
'initial_completed_at' => '2026-09-04 10:00:00',
@ -95,6 +120,66 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$this->assertSame(['A店铺', 'B店铺', 'C店铺'], array_column($sorted['data'], 'store_name'));
}
public function test_it_shows_the_latest_application_flow(): void
{
$db = DB::connection('business_mirror');
// 账号表用的就是带平台前缀的 ERP 店铺名(关店流程里可能写成下划线,需要归一)。
foreach (['拼多多-甲店' => '拼多多_甲店', '拼多多-乙店' => '拼多多-乙店', '拼多多-丙店' => '拼多多-丙店'] as $store => $erp) {
$db->table('store_accounts')->insert([
'id' => 'account-' . md5($store), 'storeName' => $store, 'account' => 'acct-' . $store,
]);
// 只有建店流程的表单也放进来,确认只取关店流程(flowType=10)。
$db->table('shop_info_flow_form')->insert([
'Id' => crc32('open-' . $store), 'flowId' => 'open-' . $store, 'flowType' => 8,
'erpShopName' => $erp, 'applyTime' => '2026-09-01 09:00:00',
]);
}
// 甲店/乙店:最近一单是关店流程(流程中 / 完成);丙店只有开店流程。
$db->table('shop_info_flow_form')->insert([
['Id' => 1, 'flowId' => 'close-jia', 'flowType' => 10, 'erpShopName' => '拼多多_甲店', 'applyTime' => '2026-10-01 09:00:00'],
['Id' => 2, 'flowId' => 'close-yi', 'flowType' => 10, 'erpShopName' => '拼多多-乙店', 'applyTime' => '2026-10-02 09:00:00'],
]);
$db->table('oa_flow_flow')->insert([
['Id' => 1, 'flowId' => 'close-jia', 'flowStatus' => 1],
['Id' => 2, 'flowId' => 'close-yi', 'flowStatus' => 3],
]);
$rows = collect($this->repository->paginate(1, 10)['data'])->keyBy('store_name');
$this->assertSame('店铺关店', $rows['拼多多-甲店']['flow_type']);
$this->assertSame('流程中', $rows['拼多多-甲店']['approval_status']);
$this->assertSame('店铺关店', $rows['拼多多-乙店']['flow_type']);
$this->assertSame('完成', $rows['拼多多-乙店']['approval_status']);
// 丙店最近一单是开店流程,没有对应 OA 状态。
$this->assertSame('店铺开店', $rows['拼多多-丙店']['flow_type']);
$this->assertSame('', $rows['拼多多-丙店']['approval_status']);
$filtered = $this->repository->paginate(1, 10, ['approval_status' => ['流程中']]);
$this->assertSame(['拼多多-甲店'], array_column($filtered['data'], 'store_name'));
$byType = $this->repository->paginate(1, 10, ['flow_type' => ['店铺门店']]);
$this->assertSame([], array_column($byType['data'], 'store_name'));
$closeOnly = $this->repository->paginate(1, 10, ['flow_type' => ['店铺关店']]);
$this->assertSame(2, $closeOnly['total']);
}
public function test_it_exposes_followed_upstream_columns_to_the_page(): void
{
DB::connection('business_mirror')->table('store_accounts')->insert([
'id' => 'account-followed',
'storeName' => 'A店铺',
'account' => 'acct-A',
'platform' => '拼多多',
]);
$this->assertSame(['platform'], $this->repository->followedColumns());
$this->assertSame([
['name' => 'store_name', 'label' => '店铺名称'],
['name' => 'platform', 'label' => 'platform', 'followed' => true],
], $this->repository->withFollowedFields([['name' => 'store_name', 'label' => '店铺名称']]));
$rows = collect($this->repository->paginate(1, 10)['data'])->keyBy('id');
$this->assertSame('拼多多', $rows['account-followed']['platform']);
}
public function test_it_attaches_authorization_and_store_status_and_filters_them(): void
{
$db = DB::connection('business_mirror');
@ -136,6 +221,14 @@ class StoreAccountMirrorRepositoryTest extends TestCase
$missing = $this->repository->paginate(1, 50, ['authorization_status' => '未授权']);
$this->assertSame(['C店铺'], array_column($missing['data'], 'store_name'));
// 多选:勾选多个状态时取并集(正常 + 已关闭)。
$multi = $this->repository->paginate(1, 50, ['store_status' => ['正常', '已关闭']]);
$this->assertSame(['A店铺', 'B店铺'], array_column($multi['data'], 'store_name'));
$multiAuth = $this->repository->paginate(1, 50, ['authorization_status' => ['已授权', '未授权']]);
$multiAuthNames = array_column($multiAuth['data'], 'store_name');
sort($multiAuthNames);
$this->assertSame(['A店铺', 'C店铺'], $multiAuthNames);
// 关联出来的列也能排序:正常 -> 已关闭 -> 无档案。
$sorted = $this->repository->paginate(1, 50, ['_sort' => 'store_status', '_direction' => 'asc']);
$this->assertSame(['A店铺', 'B店铺'], array_slice(array_column($sorted['data'], 'store_name'), 0, 2));