feat: 新增成本运费异常监控 #992
@ -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(),
|
||||
|
||||
@ -1049,13 +1049,6 @@ $navigation = [
|
||||
[
|
||||
'slug' => 'cost-freight-anomaly',
|
||||
'title' => '成本/运费异常',
|
||||
'directory' => [
|
||||
'icon' => 'warning_amber',
|
||||
'status' => '待接入',
|
||||
'description' => '监控商品成本与运费异常,集中展示成本缺失、成本波动、运费异常和异常结果。',
|
||||
'empty' => '成本/运费异常数据尚未接入。提供取数来源、成本口径和异常阈值后,这里会显示可追溯的异常明细。',
|
||||
'fields' => [],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'overview',
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
116
modules/EcommerceFinance/View/CostFreightAnomalyPageData.php
Normal file
116
modules/EcommerceFinance/View/CostFreightAnomalyPageData.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
53
tests/Feature/Business/CostFreightAnomalyPageTest.php
Normal file
53
tests/Feature/Business/CostFreightAnomalyPageTest.php
Normal 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('最近同步');
|
||||
}
|
||||
}
|
||||
@ -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', [
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user