增加修改客户重新同步功能

This commit is contained in:
wuweihua
2026-09-29 11:58:01 +08:00
parent b9746e3801
commit 2d002e7291
13 changed files with 1098 additions and 167 deletions
@@ -18,6 +18,8 @@ import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.price.domain.PrcBillingTask;
import com.bms.price.domain.PrcBillingTaskRetryTask;
import com.bms.price.domain.vo.BillingTaskRetryByFilterRequest;
import com.bms.price.enums.BillTaskSuccessStatus;
import com.bms.price.service.IPrcBillingTaskService;
@@ -112,4 +114,31 @@ public class BillingTaskMonitorController extends BaseController
data.put("details", details);
return success(data);
}
/**
* Async resync by customer + bill_time (batched ID pages to avoid OOM).
* Returns asyncTaskId; poll {@code GET /retryTask/{asyncTaskId}}.
*/
@PreAuthorize("@ss.hasPermi('monitor:billingTask:retry')")
@Log(title = "billing task filter retry", businessType = BusinessType.UPDATE)
@PostMapping("/retryByFilter")
public AjaxResult retryByFilter(@RequestBody BillingTaskRetryByFilterRequest request)
{
String asyncTaskId = billingTaskService.startRetryByFilter(request);
Map<String, Object> data = new HashMap<String, Object>();
data.put("asyncTaskId", asyncTaskId);
return success(data);
}
@PreAuthorize("@ss.hasPermi('monitor:billingTask:retry') or @ss.hasPermi('monitor:billingTask:list')")
@GetMapping("/retryTask/{asyncTaskId}")
public AjaxResult retryTaskProgress(@PathVariable("asyncTaskId") String asyncTaskId)
{
PrcBillingTaskRetryTask task = billingTaskService.getRetryByFilterTask(asyncTaskId);
if (task == null)
{
return error("retry task not found or expired");
}
return success(task);
}
}
@@ -0,0 +1,114 @@
package com.bms.price.domain;
import java.io.Serializable;
/** Async filter-retry progress for billing tasks (Redis). */
public class PrcBillingTaskRetryTask implements Serializable
{
private static final long serialVersionUID = 1L;
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAIL = "FAIL";
private String taskId;
private String status;
private int percent;
private String message;
private int total;
private int processed;
private int ok;
private int fail;
private String error;
public String getTaskId()
{
return taskId;
}
public void setTaskId(String taskId)
{
this.taskId = taskId;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public int getPercent()
{
return percent;
}
public void setPercent(int percent)
{
this.percent = percent;
}
public String getMessage()
{
return message;
}
public void setMessage(String message)
{
this.message = message;
}
public int getTotal()
{
return total;
}
public void setTotal(int total)
{
this.total = total;
}
public int getProcessed()
{
return processed;
}
public void setProcessed(int processed)
{
this.processed = processed;
}
public int getOk()
{
return ok;
}
public void setOk(int ok)
{
this.ok = ok;
}
public int getFail()
{
return fail;
}
public void setFail(int fail)
{
this.fail = fail;
}
public String getError()
{
return error;
}
public void setError(String error)
{
this.error = error;
}
}
@@ -0,0 +1,25 @@
package com.bms.price.domain.vo;
import java.io.Serializable;
import lombok.Data;
/**
* Filter for async batch retry of billing tasks.
* Require customerCode + billTime range to avoid full-table resync.
*/
@Data
public class BillingTaskRetryByFilterRequest implements Serializable
{
private static final long serialVersionUID = 1L;
/** Customer code (exact match) */
private String customerCode;
/** Bill time start yyyy-MM-dd */
private String beginTime;
/** Bill time end yyyy-MM-dd */
private String endTime;
/** Optional: FAILED / WAITING / SUCCESS / ALL; default ALL */
private String successStatus;
/** Optional batch size; server clamps to 20~200, default 50 */
private Integer batchSize;
}
@@ -1,6 +1,7 @@
package com.bms.price.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PrcBillingTask;
public interface PrcBillingTaskMapper
@@ -15,4 +16,16 @@ public interface PrcBillingTaskMapper
/** CAS: WAITING -> processing claim not needed; writeback SUCCESS/FAILED */
int updateBillingResult(PrcBillingTask task);
/** Count tasks for filter retry (customer + bill_time window). */
int countTaskIdsByFilter(PrcBillingTask query);
/**
* Keyset page of task ids only (asc) to avoid loading full rows into memory.
* lastTaskId exclusive lower bound; pass 0 for first page.
*/
List<Long> selectTaskIdsByFilter(
@Param("query") PrcBillingTask query,
@Param("lastTaskId") Long lastTaskId,
@Param("limit") int limit);
}
@@ -2,6 +2,8 @@ package com.bms.price.service;
import java.util.List;
import com.bms.price.domain.PrcBillingTask;
import com.bms.price.domain.PrcBillingTaskRetryTask;
import com.bms.price.domain.vo.BillingTaskRetryByFilterRequest;
import com.bms.price.domain.vo.BillingTaskSubmitRequest;
public interface IPrcBillingTaskService
@@ -22,4 +24,12 @@ public interface IPrcBillingTaskService
* @return task after retry
*/
PrcBillingTask retryTask(Long taskId);
/**
* Start async filter retry (customer + bill_time). Returns async task id for progress poll.
*/
String startRetryByFilter(BillingTaskRetryByFilterRequest request);
/** Poll async filter-retry progress. */
PrcBillingTaskRetryTask getRetryByFilterTask(String asyncTaskId);
}
@@ -4,6 +4,7 @@ import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,7 +14,9 @@ import com.bms.common.exception.ServiceException;
import com.bms.common.utils.StringUtils;
import com.bms.price.config.BillingProperties;
import com.bms.price.domain.PrcBillingTask;
import com.bms.price.domain.PrcBillingTaskRetryTask;
import com.bms.price.domain.PrcOrderBill;
import com.bms.price.domain.vo.BillingTaskRetryByFilterRequest;
import com.bms.price.domain.vo.BillingTaskSubmitRequest;
import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsPriceQuoteResult;
@@ -28,6 +31,7 @@ import com.bms.price.oracle.OracleOrderBillMapper;
import com.bms.price.oracle.OracleUpstreamOrderService;
import com.bms.price.service.IPrcBillingTaskService;
import com.bms.price.service.IPrcOrderBillService;
import com.bms.price.task.PrcBillingTaskRetryAsyncRunner;
@Service
public class PrcBillingTaskServiceImpl implements IPrcBillingTaskService
@@ -55,6 +59,9 @@ public class PrcBillingTaskServiceImpl implements IPrcBillingTaskService
@Autowired
private BillingProperties billingProperties;
@Autowired
private PrcBillingTaskRetryAsyncRunner retryAsyncRunner;
@Override
@Transactional(rollbackFor = Exception.class)
public PrcBillingTask submitInbound(BillingTaskSubmitRequest request)
@@ -359,6 +366,50 @@ public class PrcBillingTaskServiceImpl implements IPrcBillingTaskService
return billingTaskMapper.selectPrcBillingTaskById(taskId);
}
@Override
public String startRetryByFilter(BillingTaskRetryByFilterRequest request)
{
if (request == null)
{
throw new ServiceException("request required");
}
String customerCode = trim(request.getCustomerCode());
String beginTime = trim(request.getBeginTime());
String endTime = trim(request.getEndTime());
if (StringUtils.isEmpty(customerCode))
{
throw new ServiceException("customerCode required");
}
if (StringUtils.isEmpty(beginTime) || StringUtils.isEmpty(endTime))
{
throw new ServiceException("beginTime and endTime required");
}
PrcBillingTask query = new PrcBillingTask();
query.setCustomerCode(customerCode);
query.setParams(PrcBillingTaskRetryAsyncRunner.timeParams(beginTime, endTime));
String status = trim(request.getSuccessStatus());
if (StringUtils.isNotEmpty(status) && !"ALL".equalsIgnoreCase(status))
{
query.setSuccessStatus(status);
}
String asyncTaskId = UUID.randomUUID().toString().replace("-", "");
retryAsyncRunner.put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_PENDING, 0, "queued",
0, 0, 0, 0, null);
retryAsyncRunner.run(asyncTaskId, query,
PrcBillingTaskRetryAsyncRunner.clampBatch(request.getBatchSize()));
return asyncTaskId;
}
@Override
public PrcBillingTaskRetryTask getRetryByFilterTask(String asyncTaskId)
{
if (StringUtils.isEmpty(asyncTaskId))
{
return null;
}
return retryAsyncRunner.get(asyncTaskId.trim());
}
private BillTaskType resolveAutoType(String transferNo, String trackingNo, String orderNo)
{
PrcOrderBill existed = null;
@@ -0,0 +1,169 @@
package com.bms.price.task;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import com.bms.common.core.redis.RedisCache;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcBillingTask;
import com.bms.price.domain.PrcBillingTaskRetryTask;
import com.bms.price.enums.BillTaskSuccessStatus;
import com.bms.price.mapper.PrcBillingTaskMapper;
import com.bms.price.service.IPrcBillingTaskService;
/**
* Async filter-based billing task retry: keyset-page IDs then retry one-by-one.
*/
@Component
public class PrcBillingTaskRetryAsyncRunner
{
private static final Logger log = LoggerFactory.getLogger(PrcBillingTaskRetryAsyncRunner.class);
public static final String CACHE_KEY_PREFIX = "billing:task:retry:";
public static final int TTL_MINUTES = 120;
public static final int DEFAULT_BATCH = 50;
public static final int MIN_BATCH = 20;
public static final int MAX_BATCH = 200;
@Autowired
private PrcBillingTaskMapper billingTaskMapper;
@Autowired
@Lazy
private IPrcBillingTaskService billingTaskService;
@Autowired
private RedisCache redisCache;
@Async("threadPoolTaskExecutor")
public void run(String asyncTaskId, PrcBillingTask query, int batchSize)
{
int batch = clampBatch(batchSize);
try
{
int total = billingTaskMapper.countTaskIdsByFilter(query);
put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_RUNNING, 0, "starting", total, 0, 0, 0, null);
if (total <= 0)
{
put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_SUCCESS, 100, "no tasks", 0, 0, 0, 0, null);
return;
}
long lastTaskId = 0L;
int processed = 0;
int ok = 0;
int fail = 0;
while (true)
{
List<Long> ids = billingTaskMapper.selectTaskIdsByFilter(query, Long.valueOf(lastTaskId), batch);
if (ids == null || ids.isEmpty())
{
break;
}
for (Long taskId : ids)
{
if (taskId == null)
{
continue;
}
lastTaskId = taskId.longValue();
try
{
PrcBillingTask task = billingTaskService.retryTask(taskId);
if (task != null && BillTaskSuccessStatus.SUCCESS.getCode().equals(task.getSuccessStatus()))
{
ok++;
}
else
{
fail++;
}
}
catch (RuntimeException e)
{
fail++;
log.warn("filter retry failed taskId={} msg={}", taskId, e.getMessage());
}
processed++;
int percent = Math.min(99, (int) ((processed * 100L) / Math.max(1, total)));
put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_RUNNING, percent,
"processing " + processed + "/" + total, total, processed, ok, fail, null);
}
if (ids.size() < batch)
{
break;
}
}
put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_SUCCESS, 100,
"done ok=" + ok + " fail=" + fail, total, processed, ok, fail, null);
log.info("billing filter retry done asyncTaskId={} total={} ok={} fail={}",
asyncTaskId, total, ok, fail);
}
catch (Exception e)
{
log.error("billing filter retry failed asyncTaskId={}", asyncTaskId, e);
String msg = e.getMessage();
if (StringUtils.isEmpty(msg))
{
msg = e.getClass().getSimpleName();
}
put(asyncTaskId, PrcBillingTaskRetryTask.STATUS_FAIL, 100, "failed", 0, 0, 0, 0, msg);
}
}
public void put(String taskId, String status, int percent, String message,
int total, int processed, int ok, int fail, String error)
{
PrcBillingTaskRetryTask task = new PrcBillingTaskRetryTask();
task.setTaskId(taskId);
task.setStatus(status);
task.setPercent(percent);
task.setMessage(message);
task.setTotal(total);
task.setProcessed(processed);
task.setOk(ok);
task.setFail(fail);
task.setError(error);
redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES);
}
public PrcBillingTaskRetryTask get(String taskId)
{
return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId);
}
public static int clampBatch(Integer batchSize)
{
int n = batchSize == null ? DEFAULT_BATCH : batchSize.intValue();
if (n < MIN_BATCH)
{
return MIN_BATCH;
}
if (n > MAX_BATCH)
{
return MAX_BATCH;
}
return n;
}
/** Build MyBatis query params map for begin/end time. */
public static Map<String, Object> timeParams(String beginTime, String endTime)
{
Map<String, Object> params = new HashMap<String, Object>();
if (StringUtils.isNotEmpty(beginTime))
{
params.put("beginTime", beginTime.trim());
}
if (StringUtils.isNotEmpty(endTime))
{
params.put("endTime", endTime.trim());
}
return params;
}
}
@@ -67,6 +67,47 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by task_id desc
</select>
<select id="countTaskIdsByFilter" parameterType="PrcBillingTask" resultType="int">
select count(1) from prc_billing_task
<where>
<if test="customerCode != null and customerCode != ''">
AND customer_code = #{customerCode}
</if>
<if test="successStatus != null and successStatus != ''">
AND success_status = #{successStatus}
</if>
<if test="params.beginTime != null and params.beginTime != ''">
AND date_format(bill_time,'%Y%m%d') &gt;= date_format(#{params.beginTime},'%Y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''">
AND date_format(bill_time,'%Y%m%d') &lt;= date_format(#{params.endTime},'%Y%m%d')
</if>
</where>
</select>
<select id="selectTaskIdsByFilter" resultType="long">
select task_id from prc_billing_task
<where>
<if test="query.customerCode != null and query.customerCode != ''">
AND customer_code = #{query.customerCode}
</if>
<if test="query.successStatus != null and query.successStatus != ''">
AND success_status = #{query.successStatus}
</if>
<if test="query.params != null and query.params.beginTime != null and query.params.beginTime != ''">
AND date_format(bill_time,'%Y%m%d') &gt;= date_format(#{query.params.beginTime},'%Y%m%d')
</if>
<if test="query.params != null and query.params.endTime != null and query.params.endTime != ''">
AND date_format(bill_time,'%Y%m%d') &lt;= date_format(#{query.params.endTime},'%Y%m%d')
</if>
<if test="lastTaskId != null">
AND task_id &gt; #{lastTaskId}
</if>
</where>
order by task_id asc
limit #{limit}
</select>
<insert id="insertPrcBillingTask" parameterType="PrcBillingTask" useGeneratedKeys="true" keyProperty="taskId">
insert into prc_billing_task (
transfer_no, tracking_no, order_no, customer_code, bill_type, success_status,
+16
View File
@@ -29,3 +29,19 @@ export function retryBillingTaskBatch(taskIds) {
data: taskIds
})
}
/** Async retry by customerCode + billTime range */
export function retryBillingTaskByFilter(data) {
return request({
url: '/monitor/billingTask/retryByFilter',
method: 'post',
data
})
}
export function getBillingTaskRetryProgress(asyncTaskId) {
return request({
url: '/monitor/billingTask/retryTask/' + asyncTaskId,
method: 'get'
})
}
@@ -0,0 +1,131 @@
<template>
<el-drawer v-model="open" size="600px" :title="drawerTitle" append-to-body destroy-on-close class="billing-task-drawer">
<div v-loading="loading" class="detail-wrap">
<el-empty v-if="!detail" description="暂无详情" />
<template v-else>
<div class="detail-header">
<el-tag :type="statusTag(detail.successStatus)" effect="light">{{ statusLabel(detail.successStatus) }}</el-tag>
<span class="detail-header-type">{{ billTypeLabel(detail.billType) }}</span>
<span class="detail-header-time">计费时间:{{ detail.billTime || '-' }}</span>
</div>
<el-descriptions :column="1" border size="small" class="detail-desc">
<el-descriptions-item label="任务ID">{{ detail.taskId }}</el-descriptions-item>
<el-descriptions-item label="客户代码">{{ detail.customerCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单号">{{ detail.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="运单号">{{ detail.trackingNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="转单号">{{ detail.transferNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="计费订单ID">{{ detail.billId || '-' }}</el-descriptions-item>
<el-descriptions-item label="失败码">{{ detail.failCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="detail.errorMessage" class="err-block">
<div class="err-block-head">
<span class="err-block-title">失败原因</span>
<el-button link type="primary" size="small" icon="CopyDocument" @click="copyError">复制</el-button>
</div>
<div class="err-block-body">{{ detail.errorMessage }}</div>
</div>
</template>
</div>
</el-drawer>
</template>
<script setup>
import { getBillingTask } from '@/api/monitor/billingTask'
import { billTypeLabel, statusLabel, statusTag } from './billingTaskOptions'
const { proxy } = getCurrentInstance()
const open = ref(false)
const loading = ref(false)
const detail = ref(null)
const drawerTitle = ref('计费任务详情')
function openDrawer(row) {
if (!row || !row.taskId) return
drawerTitle.value = '计费任务 #' + row.taskId
open.value = true
loading.value = true
detail.value = null
getBillingTask(row.taskId).then(res => {
detail.value = res.data || row
}).finally(() => {
loading.value = false
})
}
function copyError() {
const text = detail.value?.errorMessage || ''
if (!text) return
navigator.clipboard.writeText(text).then(() => {
proxy.$modal.msgSuccess('已复制')
}).catch(() => {
proxy.$modal.msgError('复制失败')
})
}
defineExpose({ open: openDrawer })
</script>
<style scoped>
.detail-wrap {
padding: 0 4px;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
background: #f8f9fb;
border-radius: 6px;
}
.detail-header-type {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.detail-header-time {
margin-left: auto;
font-size: 12px;
color: #909399;
}
.detail-desc :deep(.el-descriptions__label) {
width: 96px;
min-width: 96px;
color: #909399;
}
.detail-desc :deep(.el-descriptions__content) {
word-break: break-all;
}
.err-block {
margin-top: 16px;
border: 1px solid #fde2e2;
border-radius: 6px;
overflow: hidden;
}
.err-block-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #fef0f0;
}
.err-block-title {
font-size: 13px;
font-weight: 600;
color: #f56c6c;
}
.err-block-body {
max-height: 260px;
overflow-y: auto;
padding: 10px 12px;
font-size: 12px;
font-family: Consolas, Monaco, monospace;
line-height: 1.7;
color: #c45656;
word-break: break-all;
white-space: pre-wrap;
background: #fff;
}
</style>
@@ -0,0 +1,313 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="480px"
append-to-body
destroy-on-close
:close-on-click-modal="!loading"
:close-on-press-escape="!loading"
:show-close="!loading"
@closed="onClosed"
>
<el-form v-show="!loading && !finished" label-width="96px" @submit.prevent>
<el-form-item label="客户代码" required>
<customer-select
v-model="form.customerCode"
value-key="customerCode"
placeholder="请选择客户"
/>
</el-form-item>
<el-form-item label="计费时间" required>
<el-date-picker
v-model="form.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.successStatus" clearable placeholder="全部" style="width: 100%">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="按客户+计费时间分批查询任务ID并异步重试,避免一次性加载全量数据导致OOM。"
/>
</el-form>
<div v-if="loading || finished" class="filter-retry-progress">
<el-progress
:percentage="progressPercent"
:status="progressStatus"
:stroke-width="14"
striped
:striped-flow="loading"
/>
<div class="filter-retry-progress__msg">{{ progressMessage }}</div>
<div class="filter-retry-progress__text">
总数 {{ progress.total || 0 }}
/ 已处理 {{ progress.processed || 0 }}
/ 成功 {{ progress.ok || 0 }}
/ 失败 {{ progress.fail || 0 }}
</div>
<div v-if="progress.error" class="filter-retry-progress__error">{{ progress.error }}</div>
<p v-if="loading" class="filter-retry-progress__hint">请勿关闭页面,大量任务重新计费可能需要一些时间</p>
</div>
<template #footer>
<template v-if="!loading && !finished">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submit">开始重新同步</el-button>
</template>
<template v-else-if="finished">
<el-button type="primary" @click="visible = false">关闭</el-button>
</template>
<template v-else>
<el-button disabled>执行中...</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup>
import { retryBillingTaskByFilter, getBillingTaskRetryProgress } from '@/api/monitor/billingTask'
import { statusOptions } from './billingTaskOptions'
const emit = defineEmits(['success', 'progress'])
const { proxy } = getCurrentInstance()
const visible = ref(false)
const loading = ref(false)
const finished = ref(false)
const progress = ref({
percent: 0,
message: '',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: '',
error: ''
})
const form = reactive({
customerCode: '',
dateRange: [],
successStatus: 'ALL'
})
const dialogTitle = computed(() => {
if (loading.value) return '异步重新计费进行中'
if (finished.value) return '异步重新计费完成'
return '条件批量重新同步'
})
const progressPercent = computed(() => Math.min(100, Math.max(0, Number(progress.value?.percent) || 0)))
const progressStatus = computed(() => {
const st = (progress.value?.status || '').toUpperCase()
if (st === 'SUCCESS') return 'success'
if (st === 'FAIL') return 'exception'
return undefined
})
const progressMessage = computed(() => mapProgressMessage(progress.value?.message, progress.value))
function mapProgressMessage(message, data) {
const m = message || ''
if (!m || m === 'queued') return '排队中...'
if (m === 'starting') return '正在统计任务...'
if (m === 'no tasks') return '无匹配任务'
if (m === 'failed') return '执行失败'
if (m.startsWith('processing ')) {
return '正在重新计费 ' + m.slice('processing '.length)
}
if (m.startsWith('done ')) {
return '完成:成功 ' + (data?.ok || 0) + ' / 失败 ' + (data?.fail || 0)
}
return m
}
function emitProgress() {
emit('progress', {
loading: loading.value,
finished: finished.value,
percent: progressPercent.value,
status: progress.value?.status || '',
message: progressMessage.value,
total: progress.value?.total || 0,
processed: progress.value?.processed || 0,
ok: progress.value?.ok || 0,
fail: progress.value?.fail || 0,
error: progress.value?.error || ''
})
}
function open() {
if (loading.value || finished.value) {
visible.value = true
return
}
form.customerCode = ''
form.dateRange = []
form.successStatus = 'ALL'
progress.value = {
percent: 0,
message: '',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: '',
error: ''
}
loading.value = false
finished.value = false
visible.value = true
emitProgress()
}
function onClosed() {
if (!loading.value) {
finished.value = false
}
}
function submit() {
const customerCode = (form.customerCode || '').trim()
const range = form.dateRange || []
if (!customerCode) {
proxy.$modal.msgError('请选择客户')
return
}
if (!range.length || range.length < 2) {
proxy.$modal.msgError('请选择计费时间范围')
return
}
proxy.$modal.confirm(
'确认按客户 ' + customerCode + ' 、计费时间 ' + range[0] + ' ~ ' + range[1] + ' 异步批量重新计费?'
).then(() => {
loading.value = true
finished.value = false
progress.value = {
percent: 0,
message: 'queued',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: 'PENDING',
error: ''
}
emitProgress()
return retryBillingTaskByFilter({
customerCode,
beginTime: range[0],
endTime: range[1],
successStatus: form.successStatus || 'ALL',
batchSize: 50
})
}).then(res => {
const asyncTaskId = res.data?.asyncTaskId || res.data
if (!asyncTaskId) {
throw new Error('asyncTaskId missing')
}
return poll(asyncTaskId)
}).then((data) => {
finished.value = true
emit('success', data)
proxy.$modal.msgSuccess(
'完成:成功 ' + (data.ok || 0) + ' / 失败 ' + (data.fail || 0)
)
}).catch(() => {
if (loading.value) {
finished.value = true
if (!progress.value?.status || progress.value.status === 'PENDING' || progress.value.status === 'RUNNING') {
progress.value = {
...progress.value,
status: 'FAIL',
percent: 100,
message: 'failed',
error: progress.value?.error || '批量重新计费失败'
}
}
}
}).finally(() => {
loading.value = false
emitProgress()
})
}
function poll(asyncTaskId) {
return new Promise((resolve, reject) => {
const tick = () => {
getBillingTaskRetryProgress(asyncTaskId).then(res => {
const data = res.data || {}
progress.value = {
percent: Number(data.percent) || 0,
message: data.message || '',
total: data.total || 0,
processed: data.processed || 0,
ok: data.ok || 0,
fail: data.fail || 0,
status: data.status || '',
error: data.error || ''
}
emitProgress()
const st = (data.status || '').toUpperCase()
if (st === 'SUCCESS') {
progress.value.percent = 100
resolve(data)
return
}
if (st === 'FAIL') {
proxy.$modal.msgError(data.error || data.message || '批量重新计费失败')
reject(new Error(data.error || 'fail'))
return
}
setTimeout(tick, 1500)
}).catch(err => reject(err))
}
tick()
})
}
defineExpose({ open })
</script>
<style scoped>
.filter-retry-progress {
padding: 8px 4px 0;
}
.filter-retry-progress__msg {
margin-top: 14px;
font-size: 14px;
font-weight: 600;
color: #303133;
line-height: 1.5;
}
.filter-retry-progress__text {
margin-top: 8px;
font-size: 12px;
color: #606266;
line-height: 1.6;
}
.filter-retry-progress__error {
margin-top: 8px;
font-size: 12px;
color: #f56c6c;
word-break: break-all;
}
.filter-retry-progress__hint {
margin: 12px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
</style>
@@ -0,0 +1,52 @@
/** Shared labels/options for billing task monitor */
export const statusOptions = [
{ label: '失败', value: 'FAILED' },
{ label: '待处理', value: 'WAITING' },
{ label: '成功', value: 'SUCCESS' },
{ label: '全部', value: 'ALL' }
]
export const billTypeOptions = [
{ label: '待计费', value: 'PENDING' },
{ label: '重计费', value: 'REBILL' },
{ label: '退费', value: 'REFUND' }
]
export const failCodeOptions = [
{ label: 'NO_CUSTOMER_PACKAGE', value: 'NO_CUSTOMER_PACKAGE' },
{ label: 'NO_ACTIVE_PACKAGE_VERSION', value: 'NO_ACTIVE_PACKAGE_VERSION' },
{ label: 'ALREADY_IN_AR_BILL', value: 'ALREADY_IN_AR_BILL' },
{ label: 'PRODUCT_ARCHIVE_MISSING', value: 'PRODUCT_ARCHIVE_MISSING' },
{ label: 'PRICE_NOT_HIT', value: 'PRICE_NOT_HIT' },
{ label: 'ZONE_PRICE_NOT_HIT', value: 'ZONE_PRICE_NOT_HIT' },
{ label: 'POSTCODE_REQUIRED', value: 'POSTCODE_REQUIRED' },
{ label: 'POSTCODE_ZONE_NOT_FOUND', value: 'POSTCODE_ZONE_NOT_FOUND' },
{ label: 'ORACLE_ORDER_NOT_FOUND', value: 'ORACLE_ORDER_NOT_FOUND' },
{ label: 'ORACLE_ORDERNO_EMPTY', value: 'ORACLE_ORDERNO_EMPTY' },
{ label: 'ORACLE_CUSTOMER_EMPTY', value: 'ORACLE_CUSTOMER_EMPTY' },
{ label: 'ORACLE_PRODUCT_EMPTY', value: 'ORACLE_PRODUCT_EMPTY' },
{ label: 'ORACLE_WEIGHT_INVALID', value: 'ORACLE_WEIGHT_INVALID' },
{ label: 'BILL_TIME_EMPTY', value: 'BILL_TIME_EMPTY' },
{ label: 'REFUND_ORDER_UNRESOLVED', value: 'REFUND_ORDER_UNRESOLVED' },
{ label: 'CUSTOMER_CODE_REQUIRED', value: 'CUSTOMER_CODE_REQUIRED' },
{ label: 'CUSTOMER_DISABLED', value: 'CUSTOMER_DISABLED' },
{ label: 'FREIGHT_MISSING', value: 'FREIGHT_MISSING' },
{ label: 'UNKNOWN', value: 'UNKNOWN' }
]
export function billTypeLabel(type) {
const found = billTypeOptions.find(item => item.value === type)
return found ? found.label : (type || '-')
}
export function statusLabel(status) {
const found = statusOptions.find(item => item.value === status)
return found ? found.label : (status || '-')
}
export function statusTag(status) {
if (status === 'SUCCESS') return 'success'
if (status === 'FAILED') return 'danger'
if (status === 'WAITING') return 'warning'
return 'info'
}
+134 -167
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<div class="app-container billing-task-page">
<div v-if="retryFloatVisible" class="retry-status-float">
<el-tooltip :content="retryFloatTooltip" placement="bottom">
<el-button class="retry-status-button" type="primary" plain size="small" @click="filterRetryRef.open()">
<span class="retry-progress-ring" :style="{ '--retry-progress': retryProgressDegree + 'deg' }" aria-hidden="true">
<span class="retry-progress-value">{{ retryProgressText }}</span>
</span>
<span class="retry-status-text">
<strong>{{ retryFloatTitle }}</strong>
<small>{{ retryFloatSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<basic-table
ref="tableRef"
:columns="columns"
@@ -15,11 +29,20 @@
type="primary"
plain
icon="Refresh"
v-hasPermi="['monitor:billingTask:retry']"
@click="filterRetryRef.open()"
>批量重新同步</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Refresh"
:disabled="!ids.length"
:loading="batchLoading"
v-hasPermi="['monitor:billingTask:retry']"
@click="handleBatchRetry(ids)"
>批量重新同步</el-button>
>同步选中</el-button>
</el-col>
</template>
<template #col-billType="{ row }">
@@ -32,7 +55,7 @@
<span class="err-text" :title="row.errorMessage || ''">{{ row.errorMessage || '-' }}</span>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleView(row)" v-hasPermi="['monitor:billingTask:list']">查看</el-button>
<el-button link type="primary" icon="View" @click="detailRef.open(row)" v-hasPermi="['monitor:billingTask:list']">查看</el-button>
<el-button
link
type="warning"
@@ -44,87 +67,38 @@
</template>
</basic-table>
<el-drawer v-model="open" size="600px" :title="drawerTitle" append-to-body destroy-on-close class="billing-task-drawer">
<div v-loading="loading" class="detail-wrap">
<el-empty v-if="!detail" description="暂无详情" />
<template v-else>
<div class="detail-header">
<el-tag :type="statusTag(detail.successStatus)" effect="light">{{ statusLabel(detail.successStatus) }}</el-tag>
<span class="detail-header-type">{{ billTypeLabel(detail.billType) }}</span>
<span class="detail-header-time">计费时间:{{ detail.billTime || '-' }}</span>
</div>
<el-descriptions :column="1" border size="small" class="detail-desc">
<el-descriptions-item label="任务ID">{{ detail.taskId }}</el-descriptions-item>
<el-descriptions-item label="客户代码">{{ detail.customerCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单号">{{ detail.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="运单号">{{ detail.trackingNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="转单号">{{ detail.transferNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="计费订单ID">{{ detail.billId || '-' }}</el-descriptions-item>
<el-descriptions-item label="失败码">{{ detail.failCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="detail.errorMessage" class="err-block">
<div class="err-block-head">
<span class="err-block-title">失败原因</span>
<el-button link type="primary" size="small" icon="CopyDocument" @click="copyError">复制</el-button>
</div>
<div class="err-block-body">{{ detail.errorMessage }}</div>
</div>
</template>
</div>
</el-drawer>
<billing-task-detail-drawer ref="detailRef" />
<billing-task-filter-retry-dialog
ref="filterRetryRef"
@progress="onFilterRetryProgress"
@success="onFilterRetrySuccess"
/>
</div>
</template>
<script setup name="BillingTaskMonitor">
import { listBillingTask, getBillingTask, retryBillingTask, retryBillingTaskBatch } from '@/api/monitor/billingTask'
import { listBillingTask, retryBillingTask, retryBillingTaskBatch } from '@/api/monitor/billingTask'
import BillingTaskDetailDrawer from './components/BillingTaskDetailDrawer.vue'
import BillingTaskFilterRetryDialog from './components/BillingTaskFilterRetryDialog.vue'
import {
statusOptions,
billTypeOptions,
failCodeOptions,
billTypeLabel,
statusLabel,
statusTag
} from './components/billingTaskOptions'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const open = ref(false)
const loading = ref(false)
const detail = ref(null)
const drawerTitle = ref('计费任务详情')
const detailRef = ref()
const filterRetryRef = ref()
const retryingId = ref(null)
const batchLoading = ref(false)
const retryFloat = ref(null)
const defaultParams = { successStatus: 'FAILED' }
const statusOptions = [
{ label: '失败', value: 'FAILED' },
{ label: '待处理', value: 'WAITING' },
{ label: '成功', value: 'SUCCESS' },
{ label: '全部', value: 'ALL' }
]
const failCodeOptions = [
{ label: 'NO_CUSTOMER_PACKAGE', value: 'NO_CUSTOMER_PACKAGE' },
{ label: 'NO_ACTIVE_PACKAGE_VERSION', value: 'NO_ACTIVE_PACKAGE_VERSION' },
{ label: 'ALREADY_IN_AR_BILL', value: 'ALREADY_IN_AR_BILL' },
{ label: 'PRODUCT_ARCHIVE_MISSING', value: 'PRODUCT_ARCHIVE_MISSING' },
{ label: 'PRICE_NOT_HIT', value: 'PRICE_NOT_HIT' },
{ label: 'ZONE_PRICE_NOT_HIT', value: 'ZONE_PRICE_NOT_HIT' },
{ label: 'POSTCODE_REQUIRED', value: 'POSTCODE_REQUIRED' },
{ label: 'POSTCODE_ZONE_NOT_FOUND', value: 'POSTCODE_ZONE_NOT_FOUND' },
{ label: 'ORACLE_ORDER_NOT_FOUND', value: 'ORACLE_ORDER_NOT_FOUND' },
{ label: 'ORACLE_ORDERNO_EMPTY', value: 'ORACLE_ORDERNO_EMPTY' },
{ label: 'ORACLE_CUSTOMER_EMPTY', value: 'ORACLE_CUSTOMER_EMPTY' },
{ label: 'ORACLE_PRODUCT_EMPTY', value: 'ORACLE_PRODUCT_EMPTY' },
{ label: 'ORACLE_WEIGHT_INVALID', value: 'ORACLE_WEIGHT_INVALID' },
{ label: 'BILL_TIME_EMPTY', value: 'BILL_TIME_EMPTY' },
{ label: 'REFUND_ORDER_UNRESOLVED', value: 'REFUND_ORDER_UNRESOLVED' },
{ label: 'CUSTOMER_CODE_REQUIRED', value: 'CUSTOMER_CODE_REQUIRED' },
{ label: 'CUSTOMER_DISABLED', value: 'CUSTOMER_DISABLED' },
{ label: 'FREIGHT_MISSING', value: 'FREIGHT_MISSING' },
{ label: 'UNKNOWN', value: 'UNKNOWN' }
]
const billTypeOptions = [
{ label: '待计费', value: 'PENDING' },
{ label: '重计费', value: 'REBILL' },
{ label: '退费', value: 'REFUND' }
]
const searchColumns = [
{ label: '状态', prop: 'successStatus', component: 'select', options: statusOptions, placeholder: '请选择状态', width: '140px', props: { clearable: true } },
{ label: '计费类型', prop: 'billType', component: 'select', options: billTypeOptions, placeholder: '请选择类型', width: '140px', props: { clearable: true } },
@@ -151,39 +125,44 @@ const columns = [
{ label: '操作', prop: 'action', align: 'center', width: 180, fixed: 'right', slot: 'action', showInToolbar: false }
]
const retryFloatVisible = computed(() => {
const p = retryFloat.value
return !!(p && (p.loading || p.finished))
})
const retryProgressDegree = computed(() => Math.round(((retryFloat.value?.percent || 0) / 100) * 360))
const retryProgressText = computed(() => (retryFloat.value?.percent || 0) + '%')
const retryFloatTitle = computed(() => {
const st = (retryFloat.value?.status || '').toUpperCase()
if (retryFloat.value?.loading) return '异步重新计费中'
if (st === 'SUCCESS') return '重新计费完成'
if (st === 'FAIL') return '重新计费失败'
return '异步重新计费'
})
const retryFloatSummary = computed(() => {
const p = retryFloat.value || {}
return '成功 ' + (p.ok || 0) + ' / 失败 ' + (p.fail || 0) + ' / 总数 ' + (p.total || 0)
})
const retryFloatTooltip = computed(() => retryFloat.value?.message || '点击查看详情')
function queryList(params) {
const p = proxy.addDateRange({ ...params }, params.dateRange)
delete p.dateRange
return listBillingTask(p)
}
function billTypeLabel(type) {
const found = billTypeOptions.find(item => item.value === type)
return found ? found.label : (type || '-')
function onFilterRetryProgress(payload) {
retryFloat.value = payload
if (payload && payload.finished && !payload.loading) {
window.setTimeout(() => {
if (retryFloat.value && retryFloat.value.finished && !retryFloat.value.loading) {
retryFloat.value = null
}
}, 8000)
}
}
function statusLabel(status) {
const found = statusOptions.find(item => item.value === status)
return found ? found.label : (status || '-')
}
function statusTag(status) {
if (status === 'SUCCESS') return 'success'
if (status === 'FAILED') return 'danger'
if (status === 'WAITING') return 'warning'
return 'info'
}
function handleView(row) {
drawerTitle.value = '计费任务 #' + row.taskId
open.value = true
loading.value = true
detail.value = null
getBillingTask(row.taskId).then(res => {
detail.value = res.data || row
}).finally(() => {
loading.value = false
})
function onFilterRetrySuccess() {
tableRef.value?.refresh()
}
function handleRetry(row) {
@@ -203,19 +182,9 @@ function handleRetry(row) {
})
}
function copyError() {
const text = detail.value?.errorMessage || ''
if (!text) return
navigator.clipboard.writeText(text).then(() => {
proxy.$modal.msgSuccess('已复制')
}).catch(() => {
proxy.$modal.msgError('复制失败')
})
}
function handleBatchRetry(ids) {
if (!ids || !ids.length) return
proxy.$modal.confirm('确认批量重新同步选中的 ' + ids.length + ' 条任务?').then(() => {
proxy.$modal.confirm('确认同步选中的 ' + ids.length + ' 条任务?').then(() => {
batchLoading.value = true
return retryBillingTaskBatch(ids)
}).then(res => {
@@ -234,73 +203,71 @@ function handleBatchRetry(ids) {
word-break: break-all;
}
.detail-wrap {
padding: 0 4px;
.billing-task-page .retry-status-float {
position: fixed;
top: 92px;
right: 24px;
z-index: 3001;
}
.detail-header {
display: flex;
.billing-task-page .retry-status-button {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
background: #f8f9fb;
border-radius: 6px;
gap: 10px;
min-width: 200px;
min-height: 68px;
padding: 6px 16px 6px 7px;
border-radius: 36px;
}
.detail-header-type {
.billing-task-page .retry-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 54px;
width: 54px;
height: 54px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-primary) 0deg var(--retry-progress, 45deg),
var(--el-color-primary-light-8) var(--retry-progress, 45deg) 360deg
);
}
.billing-task-page .retry-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.billing-task-page .retry-progress-value {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.billing-task-page .retry-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1.2;
white-space: nowrap;
}
.billing-task-page .retry-status-text strong {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.detail-header-time {
margin-left: auto;
font-size: 12px;
color: #909399;
}
.detail-desc :deep(.el-descriptions__label) {
width: 96px;
min-width: 96px;
color: #909399;
}
.detail-desc :deep(.el-descriptions__content) {
word-break: break-all;
}
.err-block {
margin-top: 16px;
border: 1px solid #fde2e2;
border-radius: 6px;
overflow: hidden;
}
.err-block-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #fef0f0;
}
.err-block-title {
font-size: 13px;
font-weight: 600;
color: #f56c6c;
}
.err-block-body {
max-height: 260px;
overflow-y: auto;
padding: 10px 12px;
font-size: 12px;
font-family: Consolas, Monaco, monospace;
line-height: 1.7;
color: #c45656;
word-break: break-all;
white-space: pre-wrap;
background: #fff;
.billing-task-page .retry-status-text small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>