diff --git a/app/Business/CourierFreightImporter.php b/app/Business/CourierFreightImporter.php new file mode 100644 index 00000000..c2f5a7d4 --- /dev/null +++ b/app/Business/CourierFreightImporter.php @@ -0,0 +1,206 @@ + '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,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> */ + 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 $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; + } +} diff --git a/app/Business/CourierPayableSummaryMap.php b/app/Business/CourierPayableSummaryMap.php new file mode 100644 index 00000000..cee02dda --- /dev/null +++ b/app/Business/CourierPayableSummaryMap.php @@ -0,0 +1,139 @@ + [['交易单号', '交易订单'], ['差异', '金额']], + 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 交易单号 => 金额(保留源文件里的原值字符串) + */ + 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 */ + 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 $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; + } +} diff --git a/app/Business/JstOrderLabelImporter.php b/app/Business/JstOrderLabelImporter.php index b160cd33..2179fbcd 100644 --- a/app/Business/JstOrderLabelImporter.php +++ b/app/Business/JstOrderLabelImporter.php @@ -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); // 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分 // 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。 diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index c5af6e96..11d3a1d6 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -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); } diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 9c17dcd1..1aa1dafd 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -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>,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 */ + /** @return array> */ 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); } } diff --git a/app/Business/JstOrderExportReader.php b/app/Business/XlsxSheetReader.php similarity index 89% rename from app/Business/JstOrderExportReader.php rename to app/Business/XlsxSheetReader.php index 1db58413..e1adbb4e 100644 --- a/app/Business/JstOrderExportReader.php +++ b/app/Business/XlsxSheetReader.php @@ -7,9 +7,9 @@ use RuntimeException; use ZipArchive; /** - * 聚水潭「导出订单」Excel(.xlsx)流式读取器。 + * xlsx 工作表流式读取器(按表头取列)。 * - * 聚水潭导出的文件有几个特点,直接套常规 Excel 读法会踩坑: + * 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑: * - 字符串用 inlineStr 内联存储(sharedStrings 里是空的); * - worksheet 的 只写一个 A1,靠它判断行数会把整表读成空; * - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。 @@ -17,14 +17,16 @@ use ZipArchive; * 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列, * 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。 */ -class JstOrderExportReader +class XlsxSheetReader { private const READ_CHUNK = 262144; /** * 逐行读取导出文件(跳过表头行)。 * - * @param list $columns 需要取出并作为返回键的表头名,必须与文件第一行完全一致 + * @param list> $columns 需要取出的列。给字符串就是精确匹配表头; + * 给数组表示同一列有多个可选表头(取第一个命中的, + * 返回键用数组里的第一个名字)。 * @return Generator> */ public function rows(string $path, array $columns): Generator @@ -56,7 +58,7 @@ class JstOrderExportReader /** * @param resource $stream * @param list $shared - * @param list $columns + * @param list> $columns * @return Generator> */ private function parseSheet($stream, array $shared, array $columns): Generator @@ -169,7 +171,7 @@ class JstOrderExportReader * 表头行 -> [列下标 => 列名],并校验需要的列都在。 * * @param array $header - * @param list $columns + * @param list> $columns * @return array */ 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; diff --git a/app/Console/Commands/FollowUpstreamColumns.php b/app/Console/Commands/FollowUpstreamColumns.php new file mode 100644 index 00000000..86b1c966 --- /dev/null +++ b/app/Console/Commands/FollowUpstreamColumns.php @@ -0,0 +1,164 @@ + 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 $keys @return list */ + 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 本次补入的字段 + */ + 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; + } +} diff --git a/app/Console/Commands/ImportCourierFreight.php b/app/Console/Commands/ImportCourierFreight.php new file mode 100644 index 00000000..09962225 --- /dev/null +++ b/app/Console/Commands/ImportCourierFreight.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 15c5a9cc..b1ecdf34 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -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 $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */ + /** @var array $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */ $orderAmount = []; /** @var array $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */ $orderSeen = []; @@ -452,46 +447,100 @@ class SyncStoreReceiptOrderMatches extends Command $coresByCore = []; /** @var array $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 $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, diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index d820840c..1ce24f58 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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'], diff --git a/app/Models/Business/CourierFreightRow.php b/app/Models/Business/CourierFreightRow.php new file mode 100644 index 00000000..19749dcb --- /dev/null +++ b/app/Models/Business/CourierFreightRow.php @@ -0,0 +1,42 @@ + 'decimal:4', + ]; + + public static function columnLabels(): array + { + return [ + 'source' => '来源', + 'part' => '成品配件', + 'transaction_no' => '交易单号', + 'freight_amount' => '提供成本运费', + ]; + } + + public static function fieldTypes(): array + { + return [ + 'freight_amount' => 'currency', + ]; + } +} diff --git a/app/Models/Business/ShopInfoFlow.php b/app/Models/Business/ShopInfoFlow.php new file mode 100644 index 00000000..8667d11b --- /dev/null +++ b/app/Models/Business/ShopInfoFlow.php @@ -0,0 +1,38 @@ +firstWhere('slug', 'financial-risk-control')['pages'] ?? []; + $statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics'); + + if ($statisticsPage) { + $riskSection['pages'][] = $statisticsPage; + } + } + unset($riskSection); + // 「种菜明细表 / 聚水潭订单标签明细」原先挂在「账单流水数据」下,后来移入新的一级菜单 // 「种菜数据汇总」。按页面键保存过精确权限的账号(访问组/角色)里不会有新的 planting-data, // 会因此看不到这两个被移动的页面;这里按「能看账单流水数据 就能看这两个页面」兼容旧配置。 diff --git a/config/business_navigation.php b/config/business_navigation.php index a24f7c22..8e1c1d77 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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'], + ], + ], + ], ], ], [ diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 05d72182..e5305288 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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' => [ diff --git a/database/migrations/2026_10_08_120000_create_courier_freight_rows.php b/database/migrations/2026_10_08_120000_create_courier_freight_rows.php new file mode 100644 index 00000000..d3c33152 --- /dev/null +++ b/database/migrations/2026_10_08_120000_create_courier_freight_rows.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_08_130000_add_soft_deletes_to_courier_freight_rows.php b/database/migrations/2026_10_08_130000_add_soft_deletes_to_courier_freight_rows.php new file mode 100644 index 00000000..2cf4bae7 --- /dev/null +++ b/database/migrations/2026_10_08_130000_add_soft_deletes_to_courier_freight_rows.php @@ -0,0 +1,35 @@ +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(); + }); + } + } +}; diff --git a/database/migrations/2026_10_08_160000_add_paid_amount_to_store_receipt_order_match_rows.php b/database/migrations/2026_10_08_160000_add_paid_amount_to_store_receipt_order_match_rows.php new file mode 100644 index 00000000..0c1e9105 --- /dev/null +++ b/database/migrations/2026_10_08_160000_add_paid_amount_to_store_receipt_order_match_rows.php @@ -0,0 +1,43 @@ +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'); + }); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 08949bad..f3d66957 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 84a97c2f..e4056eab 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 diff --git a/modules/EcommerceFinance/Repositories/ShopInfoFlowMirrorRepository.php b/modules/EcommerceFinance/Repositories/ShopInfoFlowMirrorRepository.php new file mode 100644 index 00000000..2a700245 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ShopInfoFlowMirrorRepository.php @@ -0,0 +1,216 @@ + '店铺开店', + 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 $filters + * @return array{data:list>,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 $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 */ + 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 */ + 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 ?? ''), + ]; + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php index 4bf123fa..8be8d34a 100644 --- a/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php @@ -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 指定状态下的账号店铺名 */ - private function storeNamesWithStatus(array $byStore, string $expected): array + /** + * 账号店铺 -> 最近一单店铺申请流程(开店/变更/关店/主体注销自动)的类型与审批状态。 + * + * 按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow,同一店铺取申请时间最近的一单; + * 审批状态用中台库 OA 流程表(dbo.flow_flow)的字典文案。 + * + * @return array 归一键 => 流程信息 + */ + 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 + */ + 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 命中任一指定状态的账号店铺名 */ + 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 自动跟随上游补进来的字段(镜像列名) */ + public function followedColumns(): array + { + return app(FollowedUpstreamColumns::class)->columns(self::DATASET); + } + + /** + * 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。 + * + * @param list> $fields + * @return list> + */ + 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); diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php index 3ea66e29..fc9cd686 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php @@ -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> */ public function fixedFilters(string $key): array { diff --git a/modules/EcommerceFinance/Services/FollowedUpstreamColumns.php b/modules/EcommerceFinance/Services/FollowedUpstreamColumns.php new file mode 100644 index 00000000..fbc57a20 --- /dev/null +++ b/modules/EcommerceFinance/Services/FollowedUpstreamColumns.php @@ -0,0 +1,49 @@ + */ + 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> $fields + * @return list> + */ + 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; + } +} diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 22b9e7b6..b9c04ead 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -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
查询条件 类型较少的字段直接选择,内容较多的字段支持关键词搜索
-
+ {{-- 这个空表单只是给下面的控件当 form= 的提交目标;不隐藏的话会占掉栅格第一格, + 把筛选字段整体挤到第二列去。 --}} + @if (request()->has('columns')) @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 +
+ {{ $field['label'] }} + + + + + +
+ @continue + @endif
+ @if ($directoryMultiSelectFields !== []) + + @endif @endif @if (($page['slug'] ?? '') === 'store-report' && ! $directoryUsesFieldQuery)
@@ -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); diff --git a/resources/views/business/pages/partials/multi-select.blade.php b/resources/views/business/pages/partials/multi-select.blade.php index b2fddb9b..e7e9949b 100644 --- a/resources/views/business/pages/partials/multi-select.blade.php +++ b/resources/views/business/pages/partials/multi-select.blade.php @@ -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 ''; + return ''; }).join(''); } diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index e9e253d2..f3274ce1 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -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()) diff --git a/tests/Feature/Business/StoreAccountPasswordsTest.php b/tests/Feature/Business/StoreAccountPasswordsTest.php index c675060d..48c1c384 100644 --- a/tests/Feature/Business/StoreAccountPasswordsTest.php +++ b/tests/Feature/Business/StoreAccountPasswordsTest.php @@ -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); diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 2052694f..8ea95a98 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -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]); diff --git a/tests/Feature/SystemSettings/UserPermissionsTest.php b/tests/Feature/SystemSettings/UserPermissionsTest.php index 96ab6990..e4ac3717 100644 --- a/tests/Feature/SystemSettings/UserPermissionsTest.php +++ b/tests/Feature/SystemSettings/UserPermissionsTest.php @@ -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') ); } diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index d5133205..87e2b2f0 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -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'), diff --git a/tests/Unit/EcommerceFinance/FollowUpstreamColumnsTest.php b/tests/Unit/EcommerceFinance/FollowUpstreamColumnsTest.php new file mode 100644 index 00000000..9ec5c90e --- /dev/null +++ b/tests/Unit/EcommerceFinance/FollowUpstreamColumnsTest.php @@ -0,0 +1,98 @@ + [ + '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']); + } +} diff --git a/tests/Unit/EcommerceFinance/ShopInfoFlowMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ShopInfoFlowMirrorRepositoryTest.php new file mode 100644 index 00000000..818c8769 --- /dev/null +++ b/tests/Unit/EcommerceFinance/ShopInfoFlowMirrorRepositoryTest.php @@ -0,0 +1,137 @@ + [ + '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']); + } +} diff --git a/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php index 72d66927..f75ca83c 100644 --- a/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreAccountMirrorRepositoryTest.php @@ -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));