feat: 新增成本运费异常监控 #992

Merged
renshen merged 1 commits from feature/renshen-cost-freight-anomaly-v2 into main 2026-11-07 18:04:45 +08:00
14 changed files with 668 additions and 16 deletions

View File

@ -4809,6 +4809,7 @@ class Pages extends Controller
'jingxi-refund-data' => $this->contentForJingxiRefundData(),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'refund-pending' => $this->contentForRefundPending(),
'cost-freight-anomaly' => app(\Modules\EcommerceFinance\View\CostFreightAnomalyPageData::class)->page(),
'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),

View File

@ -1049,13 +1049,6 @@ $navigation = [
[
'slug' => 'cost-freight-anomaly',
'title' => '成本/运费异常',
'directory' => [
'icon' => 'warning_amber',
'status' => '待接入',
'description' => '监控商品成本与运费异常,集中展示成本缺失、成本波动、运费异常和异常结果。',
'empty' => '成本/运费异常数据尚未接入。提供取数来源、成本口径和异常阈值后,这里会显示可追溯的异常明细。',
'fields' => [],
],
],
[
'slug' => 'overview',

View File

@ -26,6 +26,8 @@ return new class extends Migration
$table->string('online_order_no', 64)->nullable();
$table->string('internal_order_no', 32)->nullable();
$table->string('order_key', 191)->default('');
$table->dateTime('ship_time')->nullable();
$table->decimal('product_cost', 18, 2)->nullable();
$table->decimal('matched_package_fee', 18, 2)->nullable();
$table->decimal('matched_freight_fee', 18, 2)->nullable();
$table->decimal('matched_bill_fee', 18, 2)->nullable();
@ -42,6 +44,7 @@ return new class extends Migration
$table->index(['shop_name', 'synced_at'], 'shengsuan_shop_synced');
$table->index(['online_order_no', 'synced_at'], 'shengsuan_online_synced');
$table->index(['internal_order_no', 'synced_at'], 'shengsuan_internal_synced');
$table->index(['ship_time', 'synced_at'], 'shengsuan_ship_synced');
});
}

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const TABLE = 'ops_shengsuan_order_profit_rows';
public function up(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection($db->getName());
if (! $schema->hasTable(self::TABLE)) {
return;
}
$schema->table(self::TABLE, function (Blueprint $table) use ($schema): void {
if (! $schema->hasColumn(self::TABLE, 'ship_time')) {
$table->dateTime('ship_time')->nullable();
}
if (! $schema->hasColumn(self::TABLE, 'product_cost')) {
$table->decimal('product_cost', 18, 2)->nullable();
}
});
if (in_array($db->getDriverName(), ['mysql', 'mariadb'], true)) {
if (! $schema->hasIndex(self::TABLE, 'shengsuan_ship_synced')) {
$db->statement(
'ALTER TABLE `' . self::TABLE
. '` ADD INDEX `shengsuan_ship_synced` (`ship_time`, `synced_at`)'
);
}
// 已导入的行保留完整页面 JSON,用字段代码回填,避免重新请求聚胜算。
$db->statement(
'UPDATE `' . self::TABLE . "` SET `product_cost` = CAST("
. "JSON_UNQUOTE(JSON_EXTRACT(`source_payload`, '$.\"c_900201\"')) AS DECIMAL(18,2)) "
. 'WHERE `product_cost` IS NULL AND `source_payload` IS NOT NULL'
);
$db->statement(
'UPDATE `' . self::TABLE . "` SET `ship_time` = STR_TO_DATE("
. "REPLACE(JSON_UNQUOTE(JSON_EXTRACT(`source_payload`, '$.\"c_900972\"')), '/', '-'), "
. "'%Y-%m-%d %H:%i:%s') WHERE `ship_time` IS NULL AND `source_payload` IS NOT NULL"
);
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection($db->getName());
if (! $schema->hasTable(self::TABLE)) {
return;
}
if ($schema->hasIndex(self::TABLE, 'shengsuan_ship_synced')) {
$db->statement('ALTER TABLE `' . self::TABLE . '` DROP INDEX `shengsuan_ship_synced`');
}
$schema->table(self::TABLE, function (Blueprint $table) use ($schema): void {
if ($schema->hasColumn(self::TABLE, 'product_cost')) {
$table->dropColumn('product_cost');
}
if ($schema->hasColumn(self::TABLE, 'ship_time')) {
$table->dropColumn('ship_time');
}
});
}
};

View File

@ -42,7 +42,7 @@
| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 |
| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror`、`ding_process_payment_mirror` 与付款按明细行分摊的 `ding_process_payment_line_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留,费用明细行的“店铺”字段规范化写入 `store_name`。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。管理费用按分摊后的付款月份统计,跨月分笔付款不会把整张审批重复计入多月。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 |
| 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`、`5602.11.03.19 AI阿里云软件费`,共 194 个科目,其中 37 个进费比科目和 126 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 |
| 本次接入 | 订单利润的包材费、快递费、账单费用直接读取聚胜算订单利润页面接口 `pf.erp321.com/WebApi/PF/OrderSku/GetOrderSkuList`(支付/发货时间口径各分页抓取),导入 `ops_shengsuan_order_profit_rows`,页面按“店铺 + 线上/内部单号”原样回填,不把 0 或推算值写入缺口。抓取脚本为 `scripts/order-profit-shengsuan-sync.py`,导入命令为 `order-profit:import-shengsuan-rows`;脚本依赖本机已登录胜算的 Chrome 调试窗口,不能由服务器容器直连页面。 |
| 本次接入 | 订单利润的包材费、快递费、账单费用直接读取聚胜算订单利润页面接口 `pf.erp321.com/WebApi/PF/OrderSku/GetOrderSkuList`(支付/发货时间口径各分页抓取),导入 `ops_shengsuan_order_profit_rows`,页面按“店铺 + 线上/内部单号”原样回填,不把 0 或推算值写入缺口。抓取脚本为 `scripts/order-profit-shengsuan-sync.py`,导入命令为 `order-profit:import-shengsuan-rows`;脚本依赖本机已登录胜算的 Chrome 调试窗口,不能由服务器容器直连页面。`成本/运费异常`按该表“商品成本为0/空且快递费为0/空”筛选,以发货时间为监控日期。 |
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,页面定向刷新和一周一次的全量校准与增量同步共用 `/run/caiwu-oa-process.lock`,只在这些 OA 镜像任务之间排队,不再被店铺对账、商品同步等无关任务占用的全局上游锁阻塞;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。进行中审批的钉钉接口按配置并发读取(默认 5 个、上限 10 个),单个审批失败不会丢弃同批成功结果。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。

View File

@ -57,6 +57,7 @@ class ImportShengsuanOrderProfitRows extends Command
['order_key'],
[
'shop_name', 'online_order_no', 'internal_order_no',
'ship_time', 'product_cost',
'matched_package_fee', 'matched_freight_fee', 'matched_bill_fee',
'generated_classification_fee', 'generated_product_fee', 'manual_fee',
'order_fee', 'promotion_fee', 'source_payload', 'synced_at', 'updated_at',

View File

@ -0,0 +1,163 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\ShengsuanOrderProfitRowMapper;
class CostFreightAnomalyRepository
{
private const TABLE = ShengsuanOrderProfitRowMapper::TABLE;
public function ready(): bool
{
try {
$schema = Schema::connection($this->connectionName());
return $schema->hasTable(self::TABLE)
&& $schema->hasColumns(self::TABLE, [
'shop_name', 'online_order_no', 'internal_order_no',
'ship_time', 'product_cost', 'matched_freight_fee',
'matched_package_fee', 'matched_bill_fee',
]);
} catch (\Throwable) {
return false;
}
}
/** @return array{ready:bool,last_synced_at:?string,total:int,shops:list<string>} */
public function status(): array
{
try {
$query = $this->connection()->table(self::TABLE);
return [
'ready' => $this->ready(),
'last_synced_at' => (clone $query)->max('synced_at'),
'total' => (int) (clone $query)->count(),
'shops' => (clone $query)->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->orderBy('shop_name')
->pluck('shop_name')
->map(static fn (mixed $shop): string => trim((string) $shop))
->filter()
->values()
->all(),
];
} catch (\Throwable) {
return [
'ready' => false,
'last_synced_at' => null,
'total' => 0,
'shops' => [],
];
}
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,total:int,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$query = $this->filtered($this->connection()->table(self::TABLE), $filters);
$total = (int) (clone $query)->count();
$rows = $query
->select([
'shop_name', 'online_order_no', 'internal_order_no', 'order_key',
'ship_time', 'product_cost', 'matched_package_fee',
'matched_freight_fee', 'matched_bill_fee', 'synced_at',
])
->orderByDesc('ship_time')
->orderByDesc('order_key')
->forPage(max($page, 1), min(max($limit, 1), 100))
->get()
->map(static fn (object $row): array => [
'shop_name' => trim((string) ($row->shop_name ?? '')),
'online_order_no' => trim((string) ($row->online_order_no ?? '')),
'internal_order_no' => trim((string) ($row->internal_order_no ?? '')),
'order_key' => trim((string) ($row->order_key ?? '')),
'ship_time' => $row->ship_time,
'product_cost' => $row->product_cost === null ? null : (float) $row->product_cost,
'matched_package_fee' => $row->matched_package_fee === null ? null : (float) $row->matched_package_fee,
'matched_freight_fee' => $row->matched_freight_fee === null ? null : (float) $row->matched_freight_fee,
'matched_bill_fee' => $row->matched_bill_fee === null ? null : (float) $row->matched_bill_fee,
'synced_at' => $row->synced_at,
'anomaly_type' => '商品成本及快递费缺失',
])
->all();
return [
'rows' => $rows,
'total' => $total,
'has_more' => $page * min(max($limit, 1), 100) < $total,
];
}
private function filtered(\Illuminate\Database\Query\Builder $query, array $filters): \Illuminate\Database\Query\Builder
{
$query->where(function ($cost) {
$cost->whereNull('product_cost')->orWhere('product_cost', 0);
})->where(function ($freight) {
$freight->whereNull('matched_freight_fee')->orWhere('matched_freight_fee', 0);
});
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if ($from !== '') {
$query->where('ship_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('ship_time', '<=', $to . ' 23:59:59');
}
$shops = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
(array) ($filters['shop_names'] ?? []),
))));
if ($shops !== []) {
$query->whereIn('shop_name', array_slice($shops, 0, 50));
}
$orders = $this->batchValues((string) ($filters['order_no'] ?? ''));
if ($orders !== []) {
$query->where(function ($order) use ($orders): void {
$order->whereIn('online_order_no', $orders)
->orWhereIn('internal_order_no', $orders);
});
}
return $query;
}
/** @return list<string> */
private function batchValues(string $value): array
{
$parts = preg_split('/[\s,,;;]+/u', trim($value)) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '') {
$values[$part] = true;
}
if (count($values) >= 100) {
break;
}
}
return array_keys($values);
}
private function connection(): ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
}

View File

@ -17,6 +17,14 @@ class ShengsuanOrderProfitRowMapper
'其中:推广费' => 'promotion_fee',
];
private const ROW_FIELDS = [
'店铺名称' => 'shop_name',
'线上单号' => 'online_order_no',
'内部单号' => 'internal_order_no',
'发货时间' => 'ship_time',
'商品成本' => 'product_cost',
];
public function table(): string
{
return self::TABLE;
@ -37,8 +45,7 @@ class ShengsuanOrderProfitRowMapper
$dataList = is_array($data['dataList'] ?? null) ? $data['dataList'] : [];
$fields = [];
foreach (['店铺名称' => 'shop_name', '线上单号' => 'online_order_no', '内部单号' => 'internal_order_no']
+ self::FEE_FIELDS as $title => $field) {
foreach (self::ROW_FIELDS + self::FEE_FIELDS as $title => $field) {
$fields[$field] = $this->fieldForTitle($heads, $title);
}
@ -62,6 +69,8 @@ class ShengsuanOrderProfitRowMapper
'online_order_no' => $onlineOrder !== '' ? $onlineOrder : null,
'internal_order_no' => $internalOrder !== '' ? $internalOrder : null,
'order_key' => $shopName . '|' . $orderNo,
'ship_time' => $this->dateTime($this->value($record, $fields['ship_time'] ?? null)),
'product_cost' => $this->decimal($this->value($record, $fields['product_cost'] ?? null)),
'source_payload' => json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'synced_at' => $now,
'created_at' => $now,
@ -107,4 +116,14 @@ class ShengsuanOrderProfitRowMapper
return is_numeric($value) ? round((float) $value, 2) : null;
}
private function dateTime(mixed $value): ?string
{
if ($value === null || trim((string) $value) === '') {
return null;
}
$timestamp = strtotime((string) $value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\CostFreightAnomalyRepository;
class CostFreightAnomalyPageData
{
public function __construct(private readonly CostFreightAnomalyRepository $repository)
{
}
public function page(): array
{
return ['view' => 'business.pages.fee-ratio-monitoring.cost-freight-anomaly'] + $this->data();
}
public function data(?array $input = null, ?int $page = null, ?int $limit = null): array
{
$filters = $this->filters($input);
$page = max($page ?? (int) request('page', 1), 1);
$limit = min(max($limit ?? (int) setting('default.list_limit', 25), 1), 100);
$status = $this->repository->status();
$rows = new Paginator([], $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]);
$error = null;
if (! $status['ready']) {
$error = '成本/运费异常共享镜像尚未就绪。';
} else {
try {
$result = $this->repository->paginate($filters, $page, $limit);
$rows = new Paginator(
Collection::make($result['rows'])
->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
$rows->hasMorePagesWhen((bool) $result['has_more']);
$status['total'] = $result['total'];
} catch (\Throwable $exception) {
report($exception);
$error = '成本/运费异常数据暂时无法读取,请稍后重试。';
}
}
return [
'costFreightRows' => $rows,
'costFreightFilters' => $filters,
'costFreightStatus' => $status,
'costFreightShopOptions' => $status['shops'] ?? [],
'costFreightError' => $error,
];
}
/** @return array{from:string,to:string,order_no:string,shop_names:array<int,string>} */
private function filters(?array $input): array
{
$input ??= request()->query();
$shops = [];
foreach ((array) ($input['shop_name'] ?? $input['shop_names'] ?? []) as $shop) {
$shop = trim((string) $shop);
if ($shop !== '') {
$shops[$shop] = true;
}
}
$datesSubmitted = array_key_exists('from', $input) || array_key_exists('to', $input);
$from = $this->date($input['from'] ?? null);
$to = $this->date($input['to'] ?? null);
if (! $datesSubmitted && empty($input['reset'])) {
$from = now()->subDays(6)->toDateString();
$to = now()->toDateString();
}
return [
'from' => $from,
'to' => $to,
'order_no' => $this->batchText($input['order_no'] ?? null),
'shop_names' => array_keys($shops),
];
}
private function date(mixed $value): string
{
$value = trim((string) $value);
$timestamp = $value === '' ? false : strtotime($value);
return $timestamp === false ? '' : date('Y-m-d', $timestamp);
}
private function batchText(mixed $value): string
{
$values = is_array($value) ? $value : preg_split('/[\r\n,,;;]+/u', (string) $value);
$items = [];
foreach ((array) $values as $item) {
$item = trim((string) $item);
if ($item !== '') {
$items[$item] = true;
}
if (count($items) >= 100) {
break;
}
}
return implode("\n", array_keys($items));
}
}

View File

@ -0,0 +1,132 @@
@include('business.pages.partials.upstream-data-styles')
<style>
.cost-freight-note {
display: flex;
align-items: flex-start;
gap: .55rem;
padding: .75rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
background: #fffdf8;
color: var(--vip-content-muted, #595959);
font-size: .78rem;
line-height: 1.5;
}
.cost-freight-note .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 1rem; }
.cost-freight-sync {
padding: .6rem 1.1rem;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
color: var(--vip-content-muted, #595959);
font-size: .76rem;
}
</style>
@php
$rows = $costFreightRows ?? null;
$filters = $costFreightFilters ?? [];
$status = $costFreightStatus ?? [];
@endphp
<div class="upstream-data-page upstream-data-page--spaced">
<form method="GET" action="{{ request()->url() }}" class="upstream-data-toolbar" aria-label="成本运费异常筛选">
<div class="upstream-data-field">
<label for="cost-freight-from">发货开始日期</label>
<input id="cost-freight-from" name="from" type="date" value="{{ $filters['from'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="cost-freight-to">发货结束日期</label>
<input id="cost-freight-to" name="to" type="date" value="{{ $filters['to'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="cost-freight-order">订单号</label>
<textarea id="cost-freight-order" name="order_no" rows="2"
placeholder="线上或内部单号;可批量:每行一个">{{ $filters['order_no'] ?? '' }}</textarea>
</div>
@include('business.pages.partials.order-data-shop-picker', [
'shopPickerOptions' => $costFreightShopOptions ?? [],
'shopPickerSelected' => $filters['shop_names'] ?? [],
'shopPickerToggleId' => 'cost-freight-shop',
])
<div class="upstream-data-actions">
<button type="submit" class="upstream-data-button upstream-data-button--primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => 'fee-ratio-monitoring', 'page' => 'cost-freight-anomaly', 'reset' => 1]) }}"
class="upstream-data-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
</div>
</form>
<div class="cost-freight-note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>口径:聚胜算订单利润页面中“商品成本”为 0/空,且“快递费(自动匹配)”为 0/空的订单。费用结果来自聚胜算页面接口,按发货时间监控。</span>
</div>
@if ($costFreightError ?? null)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $costFreightError }}</span>
</div>
@endif
<div class="upstream-data-table-panel">
<div class="cost-freight-sync">
数据状态:{{ ($status['ready'] ?? false) ? '胜算费用镜像已就绪' : '共享镜像同步中' }}
@if (!empty($status['last_synced_at'])) · 最近同步 {{ $status['last_synced_at'] }} @endif
· 当前筛选 {{ number_format((int) ($status['total'] ?? 0)) }} 条
</div>
<div class="upstream-data-table-scroll">
<table class="upstream-data-table" aria-label="成本运费异常">
<thead>
<tr>
<th>店铺名称</th>
<th>线上单号</th>
<th>内部单号</th>
<th>发货时间</th>
<th class="is-number">商品成本</th>
<th class="is-number">快递费(自动匹配)</th>
<th class="is-number">账单费用(自动匹配)</th>
<th class="is-number">包材费(自动匹配)</th>
<th>异常类型</th>
<th>同步时间</th>
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
<td>{{ $row->shop_name ?: '-' }}</td>
<td>{{ $row->online_order_no ?: '-' }}</td>
<td>{{ $row->internal_order_no ?: '-' }}</td>
<td>{{ $row->ship_time ?: '-' }}</td>
<td class="is-number">{{ $row->product_cost === null ? '-' : number_format((float) $row->product_cost, 2) }}</td>
<td class="is-number">{{ $row->matched_freight_fee === null ? '-' : number_format((float) $row->matched_freight_fee, 2) }}</td>
<td class="is-number">{{ $row->matched_bill_fee === null ? '-' : number_format((float) $row->matched_bill_fee, 2) }}</td>
<td class="is-number">{{ $row->matched_package_fee === null ? '-' : number_format((float) $row->matched_package_fee, 2) }}</td>
<td>{{ $row->anomaly_type }}</td>
<td>{{ $row->synced_at ?: '-' }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="10">当前范围内没有成本和快递费同时缺失的订单。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>当前页 {{ $rows?->count() ?? 0 }} 条</span>
<div class="upstream-data-pagination__controls">
<a href="{{ $rows?->previousPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows || $rows->onFirstPage()])
aria-label="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows?->currentPage() ?? 1 }} 页</span>
<a href="{{ $rows?->nextPageUrl() ?? '#' }}"
@class(['upstream-data-button', 'is-disabled' => !$rows?->hasMorePages()])
aria-label="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Business;
use Mockery;
use Modules\EcommerceFinance\Repositories\CostFreightAnomalyRepository;
use Tests\Feature\FeatureTestCase;
class CostFreightAnomalyPageTest extends FeatureTestCase
{
public function test_page_renders_cost_freight_anomaly_rows(): void
{
$repository = Mockery::mock(CostFreightAnomalyRepository::class);
$repository->shouldReceive('status')->once()->andReturn([
'ready' => true,
'last_synced_at' => '2026-11-07 17:44:20',
'total' => 1,
'shops' => ['天猫-卓禾家具旗舰店'],
]);
$repository->shouldReceive('paginate')->once()->andReturn([
'rows' => [[
'shop_name' => '天猫-卓禾家具旗舰店',
'online_order_no' => '5127581702031023636',
'internal_order_no' => '10097143',
'order_key' => '天猫-卓禾家具旗舰店|5127581702031023636',
'ship_time' => '2026-11-05 12:58:35',
'product_cost' => 0.0,
'matched_package_fee' => 0.0,
'matched_freight_fee' => 0.0,
'matched_bill_fee' => -0.5,
'synced_at' => '2026-11-07 17:44:20',
'anomaly_type' => '商品成本及快递费缺失',
]],
'total' => 1,
'has_more' => false,
]);
$this->app->instance(CostFreightAnomalyRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fee-ratio-monitoring',
'page' => 'cost-freight-anomaly',
'from' => '2026-11-01',
'to' => '2026-11-07',
]))
->assertOk()
->assertSeeText('成本/运费异常')
->assertSeeText('商品成本')
->assertSeeText('快递费(自动匹配)')
->assertSeeText('5127581702031023636')
->assertSeeText('商品成本及快递费缺失')
->assertSeeText('最近同步');
}
}

View File

@ -314,7 +314,7 @@ class NavigationTest extends FeatureTestCase
['退款待确认', '成本/运费异常', '异常数据监控'],
array_column($section['pages'], 'title'),
);
$this->assertSame('待接入', $section['pages'][1]['directory']['status']);
$this->assertArrayNotHasKey('directory', $section['pages'][1]);
$this->assertSame('待接入', $section['pages'][2]['directory']['status']);
$sidebarGroup = collect(SidebarMenu::groups($this->user))
@ -329,7 +329,9 @@ class NavigationTest extends FeatureTestCase
->assertOk()
->assertSeeText('费比异常数据监控')
->assertSeeText('成本/运费异常')
->assertSeeText('成本/运费异常数据尚未接入');
->assertSeeText('商品成本')
->assertSeeText('快递费(自动匹配)')
->assertSeeText('成本和快递费同时缺失');
$this->loginAs()
->get(route('business.pages.show', [

View File

@ -0,0 +1,84 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CostFreightAnomalyRepository;
use Tests\TestCase;
class CostFreightAnomalyRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('ops_shengsuan_order_profit_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('shop_name');
$table->string('online_order_no')->nullable();
$table->string('internal_order_no')->nullable();
$table->string('order_key');
$table->dateTime('ship_time')->nullable();
$table->decimal('product_cost', 18, 2)->nullable();
$table->decimal('matched_package_fee', 18, 2)->nullable();
$table->decimal('matched_freight_fee', 18, 2)->nullable();
$table->decimal('matched_bill_fee', 18, 2)->nullable();
$table->dateTime('synced_at')->nullable();
$table->timestamps();
});
$db = DB::connection('business_mirror');
foreach ([
['shop_name' => '异常店', 'online_order_no' => 'NO-1', 'order_key' => '异常店|NO-1', 'ship_time' => '2026-11-05 10:00:00', 'product_cost' => 0, 'matched_freight_fee' => 0],
['shop_name' => '正常店', 'online_order_no' => 'NO-2', 'order_key' => '正常店|NO-2', 'ship_time' => '2026-11-05 11:00:00', 'product_cost' => 100, 'matched_freight_fee' => 0],
['shop_name' => '正常店', 'online_order_no' => 'NO-3', 'order_key' => '正常店|NO-3', 'ship_time' => '2026-11-06 11:00:00', 'product_cost' => 0, 'matched_freight_fee' => 7],
] as $row) {
$db->table('ops_shengsuan_order_profit_rows')->insert($row + [
'internal_order_no' => null,
'matched_package_fee' => 0,
'matched_bill_fee' => 0,
'synced_at' => '2026-11-07 10:00:00',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function test_only_orders_with_both_cost_and_freight_missing_are_returned(): void
{
$result = app(CostFreightAnomalyRepository::class)->paginate([
'from' => '2026-11-01',
'to' => '2026-11-07',
'order_no' => '',
'shop_names' => [],
], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('NO-1', $result['rows'][0]['online_order_no']);
$this->assertSame('商品成本及快递费缺失', $result['rows'][0]['anomaly_type']);
}
public function test_order_and_shop_batch_filters_are_applied(): void
{
$repository = app(CostFreightAnomalyRepository::class);
$result = $repository->paginate([
'from' => '2026-11-01',
'to' => '2026-11-07',
'order_no' => 'NO-1',
'shop_names' => ['异常店'],
], 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('异常店', $result['rows'][0]['shop_name']);
}
}

View File

@ -16,21 +16,25 @@ class ShengsuanOrderProfitRowMapperTest extends TestCase
['fld' => 'c_1', 'title' => '店铺名称'],
['fld' => 'c_2', 'title' => '线上单号'],
['fld' => 'c_3', 'title' => '内部单号'],
['fld' => 'c_8', 'title' => '发货时间'],
['fld' => 'c_9', 'title' => '商品成本'],
['fld' => 'c_4', 'title' => '包材费(自动匹配)'],
['fld' => 'c_5', 'title' => '快递费(自动匹配)'],
['fld' => 'c_6', 'title' => '账单费用(自动匹配)'],
['fld' => 'c_7', 'title' => '人工维护'],
['fld' => 'c_8', 'title' => '订单费用'],
['fld' => 'c_10', 'title' => '人工维护'],
['fld' => 'c_11', 'title' => '订单费用'],
],
'dataList' => [[
'c_1' => '天猫-卓禾家具旗舰店',
'c_2' => '5127581702031023636',
'c_3' => '10097143',
'c_8' => '2026/11/5 12:58:35',
'c_9' => 0,
'c_4' => 0,
'c_5' => 7.05,
'c_6' => -0.5,
'c_7' => 3.6,
'c_8' => 10.15,
'c_10' => 3.6,
'c_11' => 10.15,
]],
],
]);
@ -41,6 +45,8 @@ class ShengsuanOrderProfitRowMapperTest extends TestCase
$this->assertSame('5127581702031023636', $row['online_order_no']);
$this->assertSame('10097143', $row['internal_order_no']);
$this->assertSame('天猫-卓禾家具旗舰店|5127581702031023636', $row['order_key']);
$this->assertSame('2026-11-05 12:58:35', $row['ship_time']);
$this->assertSame(0.0, $row['product_cost']);
$this->assertSame(0.0, $row['matched_package_fee']);
$this->assertSame(7.05, $row['matched_freight_fee']);
$this->assertSame(-0.5, $row['matched_bill_fee']);