diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index bd9f6f62..ad422cf4 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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(), diff --git a/config/business_navigation.php b/config/business_navigation.php index e67a7d35..bb7d400c 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -1049,13 +1049,6 @@ $navigation = [ [ 'slug' => 'cost-freight-anomaly', 'title' => '成本/运费异常', - 'directory' => [ - 'icon' => 'warning_amber', - 'status' => '待接入', - 'description' => '监控商品成本与运费异常,集中展示成本缺失、成本波动、运费异常和异常结果。', - 'empty' => '成本/运费异常数据尚未接入。提供取数来源、成本口径和异常阈值后,这里会显示可追溯的异常明细。', - 'fields' => [], - ], ], [ 'slug' => 'overview', diff --git a/database/migrations/2026_11_07_171000_create_shengsuan_order_profit_rows.php b/database/migrations/2026_11_07_171000_create_shengsuan_order_profit_rows.php index 238d0ec3..5cf51fbc 100644 --- a/database/migrations/2026_11_07_171000_create_shengsuan_order_profit_rows.php +++ b/database/migrations/2026_11_07_171000_create_shengsuan_order_profit_rows.php @@ -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'); }); } diff --git a/database/migrations/2026_11_07_180000_add_cost_and_ship_time_to_shengsuan_order_profit_rows.php b/database/migrations/2026_11_07_180000_add_cost_and_ship_time_to_shengsuan_order_profit_rows.php new file mode 100644 index 00000000..b653bf29 --- /dev/null +++ b/database/migrations/2026_11_07_180000_add_cost_and_ship_time_to_shengsuan_order_profit_rows.php @@ -0,0 +1,79 @@ +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'); + } + }); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 4d91e865..e19f599b 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 diff --git a/modules/EcommerceFinance/Console/ImportShengsuanOrderProfitRows.php b/modules/EcommerceFinance/Console/ImportShengsuanOrderProfitRows.php index 89103aab..d03ce3d5 100644 --- a/modules/EcommerceFinance/Console/ImportShengsuanOrderProfitRows.php +++ b/modules/EcommerceFinance/Console/ImportShengsuanOrderProfitRows.php @@ -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', diff --git a/modules/EcommerceFinance/Repositories/CostFreightAnomalyRepository.php b/modules/EcommerceFinance/Repositories/CostFreightAnomalyRepository.php new file mode 100644 index 00000000..891be26e --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CostFreightAnomalyRepository.php @@ -0,0 +1,163 @@ +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} */ + 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 $filters + * @return array{rows:list>,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 */ + 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'); + } +} diff --git a/modules/EcommerceFinance/Services/ShengsuanOrderProfitRowMapper.php b/modules/EcommerceFinance/Services/ShengsuanOrderProfitRowMapper.php index 110cfeb2..ef6553b2 100644 --- a/modules/EcommerceFinance/Services/ShengsuanOrderProfitRowMapper.php +++ b/modules/EcommerceFinance/Services/ShengsuanOrderProfitRowMapper.php @@ -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); + } } diff --git a/modules/EcommerceFinance/View/CostFreightAnomalyPageData.php b/modules/EcommerceFinance/View/CostFreightAnomalyPageData.php new file mode 100644 index 00000000..0147d407 --- /dev/null +++ b/modules/EcommerceFinance/View/CostFreightAnomalyPageData.php @@ -0,0 +1,116 @@ + '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} */ + 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)); + } +} diff --git a/resources/views/business/pages/fee-ratio-monitoring/cost-freight-anomaly.blade.php b/resources/views/business/pages/fee-ratio-monitoring/cost-freight-anomaly.blade.php new file mode 100644 index 00000000..6826f774 --- /dev/null +++ b/resources/views/business/pages/fee-ratio-monitoring/cost-freight-anomaly.blade.php @@ -0,0 +1,132 @@ +@include('business.pages.partials.upstream-data-styles') + + + +@php + $rows = $costFreightRows ?? null; + $filters = $costFreightFilters ?? []; + $status = $costFreightStatus ?? []; +@endphp + +
+
+
+ + +
+
+ + +
+
+ + +
+ @include('business.pages.partials.order-data-shop-picker', [ + 'shopPickerOptions' => $costFreightShopOptions ?? [], + 'shopPickerSelected' => $filters['shop_names'] ?? [], + 'shopPickerToggleId' => 'cost-freight-shop', + ]) +
+ + + + 重置 + +
+
+ +
+ + 口径:聚胜算订单利润页面中“商品成本”为 0/空,且“快递费(自动匹配)”为 0/空的订单。费用结果来自聚胜算页面接口,按发货时间监控。 +
+ + @if ($costFreightError ?? null) + + @endif + +
+
+ 数据状态:{{ ($status['ready'] ?? false) ? '胜算费用镜像已就绪' : '共享镜像同步中' }} + @if (!empty($status['last_synced_at'])) · 最近同步 {{ $status['last_synced_at'] }} @endif + · 当前筛选 {{ number_format((int) ($status['total'] ?? 0)) }} 条 +
+
+ + + + + + + + + + + + + + + + + @forelse ($rows ?? [] as $row) + + + + + + + + + + + + + @empty + + + + @endforelse + +
店铺名称线上单号内部单号发货时间商品成本快递费(自动匹配)账单费用(自动匹配)包材费(自动匹配)异常类型同步时间
{{ $row->shop_name ?: '-' }}{{ $row->online_order_no ?: '-' }}{{ $row->internal_order_no ?: '-' }}{{ $row->ship_time ?: '-' }}{{ $row->product_cost === null ? '-' : number_format((float) $row->product_cost, 2) }}{{ $row->matched_freight_fee === null ? '-' : number_format((float) $row->matched_freight_fee, 2) }}{{ $row->matched_bill_fee === null ? '-' : number_format((float) $row->matched_bill_fee, 2) }}{{ $row->matched_package_fee === null ? '-' : number_format((float) $row->matched_package_fee, 2) }}{{ $row->anomaly_type }}{{ $row->synced_at ?: '-' }}
当前范围内没有成本和快递费同时缺失的订单。
+
+
+ 当前页 {{ $rows?->count() ?? 0 }} 条 + +
+
+
diff --git a/tests/Feature/Business/CostFreightAnomalyPageTest.php b/tests/Feature/Business/CostFreightAnomalyPageTest.php new file mode 100644 index 00000000..e65ebd1c --- /dev/null +++ b/tests/Feature/Business/CostFreightAnomalyPageTest.php @@ -0,0 +1,53 @@ +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('最近同步'); + } +} diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 5d566ec5..4ce06587 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -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', [ diff --git a/tests/Unit/EcommerceFinance/CostFreightAnomalyRepositoryTest.php b/tests/Unit/EcommerceFinance/CostFreightAnomalyRepositoryTest.php new file mode 100644 index 00000000..3c1f147b --- /dev/null +++ b/tests/Unit/EcommerceFinance/CostFreightAnomalyRepositoryTest.php @@ -0,0 +1,84 @@ + [ + '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']); + } +} diff --git a/tests/Unit/EcommerceFinance/ShengsuanOrderProfitRowMapperTest.php b/tests/Unit/EcommerceFinance/ShengsuanOrderProfitRowMapperTest.php index a817e852..e0685c25 100644 --- a/tests/Unit/EcommerceFinance/ShengsuanOrderProfitRowMapperTest.php +++ b/tests/Unit/EcommerceFinance/ShengsuanOrderProfitRowMapperTest.php @@ -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']);