Merge remote-tracking branch 'origin/main' into fix/yueyang-offset-signed-amount
# Conflicts: # app/Console/Commands/SyncStoreReceiptOrderMatches.php
This commit is contained in:
commit
03b728a0fc
206
app/Business/CourierFreightImporter.php
Normal file
206
app/Business/CourierFreightImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
139
app/Business/CourierPayableSummaryMap.php
Normal file
139
app/Business/CourierPayableSummaryMap.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
|
||||
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
164
app/Console/Commands/FollowUpstreamColumns.php
Normal file
164
app/Console/Commands/FollowUpstreamColumns.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/ImportCourierFreight.php
Normal file
41
app/Console/Commands/ImportCourierFreight.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
42
app/Models/Business/CourierFreightRow.php
Normal file
42
app/Models/Business/CourierFreightRow.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Models/Business/ShopInfoFlow.php
Normal file
38
app/Models/Business/ShopInfoFlow.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@ -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,
|
||||
// 会因此看不到这两个被移动的页面;这里按「能看账单流水数据 就能看这两个页面」兼容旧配置。
|
||||
|
||||
@ -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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
98
tests/Unit/EcommerceFinance/FollowUpstreamColumnsTest.php
Normal file
98
tests/Unit/EcommerceFinance/FollowUpstreamColumnsTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
137
tests/Unit/EcommerceFinance/ShopInfoFlowMirrorRepositoryTest.php
Normal file
137
tests/Unit/EcommerceFinance/ShopInfoFlowMirrorRepositoryTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user