增加查看计费历史记录

This commit is contained in:
wuweihua
2026-11-05 18:33:14 +08:00
parent 87c75922aa
commit 6ced4f237f
4 changed files with 398 additions and 1 deletions
@@ -22,9 +22,12 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.constant.HttpStatus;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.PageDomain;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.core.page.TableSupport;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.MessageUtils;
@@ -54,6 +57,11 @@ import com.bms.price.domain.vo.TmsPriceQuoteRequest;
import com.bms.price.domain.vo.TmsPriceQuoteResult;
import com.bms.price.enums.BillTaskSuccessStatus;
import com.bms.price.enums.BillTaskType;
import com.bms.price.search.BillSearchPage;
import com.bms.price.search.BillSyncSnapshot;
import com.bms.price.search.EsBizLogSupport;
import com.bms.price.search.EsLogType;
import com.bms.price.search.IBillSearchStore;
import com.bms.price.service.IPrcBillingTaskService;
import com.bms.price.service.IPrcOrderBillService;
import com.bms.price.service.ITmsPriceQuoteService;
@@ -84,6 +92,9 @@ public class BillingController extends BaseController
@Autowired
private OrderBillProductCheckService orderBillProductCheckService;
@Autowired
private IBillSearchStore billSearchStore;
/**
* Historical quote list.
*/
@@ -355,6 +366,97 @@ public class BillingController extends BaseController
return success(prcOrderBillService.getBill(billId));
}
/**
* ES BILL logs for this order (billing / upstream sync / fail snapshots).
* bizId prefers transferNo then orderNo (same as write path).
*/
@PreAuthorize("@ss.hasPermi('price:billing:bill') or @ss.hasPermi('price:orderBill:list')")
@GetMapping("/bill/{billId}/history")
public TableDataInfo billHistory(@PathVariable("billId") String billId)
{
if (StringUtils.isEmpty(billId))
{
return getDataTable(new ArrayList<BillSyncSnapshot>());
}
TmsPriceQuoteResult bill = prcOrderBillService.getBill(billId.trim());
if (bill == null)
{
TableDataInfo empty = new TableDataInfo();
empty.setCode(HttpStatus.SUCCESS);
empty.setMsg("查询成功");
empty.setRows(new ArrayList<BillSyncSnapshot>());
empty.setTotal(0L);
return empty;
}
PageDomain pageDomain = TableSupport.buildPageRequest();
int pageNum = pageDomain.getPageNum() == null ? 1 : pageDomain.getPageNum().intValue();
int pageSize = pageDomain.getPageSize() == null ? 10 : pageDomain.getPageSize().intValue();
String primary = EsBizLogSupport.billBizId(bill.getTransferNo(), bill.getOrderNo());
BillSearchPage page = billSearchStore.search(EsLogType.BILL, primary, null, pageNum, pageSize);
// Also pull by orderNo when transferNo was used as bizId, so older orderNo-keyed rows appear.
if (StringUtils.isNotEmpty(bill.getTransferNo()) && StringUtils.isNotEmpty(bill.getOrderNo())
&& !bill.getTransferNo().trim().equalsIgnoreCase(bill.getOrderNo().trim())
&& pageNum == 1)
{
BillSearchPage byOrder = billSearchStore.search(EsLogType.BILL, bill.getOrderNo().trim(), null, 1,
Math.max(pageSize, 50));
page = mergeBillHistory(page, byOrder, pageSize);
}
TableDataInfo rsp = new TableDataInfo();
rsp.setCode(HttpStatus.SUCCESS);
rsp.setMsg("查询成功");
rsp.setRows(page.getRows());
rsp.setTotal(page.getTotal());
return rsp;
}
private BillSearchPage mergeBillHistory(BillSearchPage primary, BillSearchPage secondary, int pageSize)
{
BillSearchPage out = new BillSearchPage();
Map<String, BillSyncSnapshot> byId = new HashMap<String, BillSyncSnapshot>();
if (primary != null && primary.getRows() != null)
{
for (BillSyncSnapshot row : primary.getRows())
{
if (row != null && StringUtils.isNotEmpty(row.getDocId()))
{
byId.put(row.getDocId(), row);
}
}
}
if (secondary != null && secondary.getRows() != null)
{
for (BillSyncSnapshot row : secondary.getRows())
{
if (row != null && StringUtils.isNotEmpty(row.getDocId()) && !byId.containsKey(row.getDocId()))
{
byId.put(row.getDocId(), row);
}
}
}
List<BillSyncSnapshot> merged = new ArrayList<BillSyncSnapshot>(byId.values());
merged.sort((a, b) -> {
String ta = a == null || a.getUpdateTime() == null ? "" : a.getUpdateTime();
String tb = b == null || b.getUpdateTime() == null ? "" : b.getUpdateTime();
return tb.compareTo(ta);
});
long total = Math.max(primary == null ? 0L : primary.getTotal(), merged.size());
if (secondary != null)
{
total = Math.max(total, secondary.getTotal());
}
out.setTotal(total);
if (merged.size() > pageSize)
{
out.setRows(new ArrayList<BillSyncSnapshot>(merged.subList(0, pageSize)));
}
else
{
out.setRows(merged);
}
return out;
}
/**
* Refund one receivable bill via billing task billType=REFUND.
*/
+9
View File
@@ -187,6 +187,15 @@ export function getBill(billId) {
})
}
/** ES billing history for one bill/order */
export function listBillHistory(billId, query) {
return request({
url: '/price/billing/bill/' + encodeURIComponent(billId) + '/history',
method: 'get',
params: query
})
}
/** Refund receivable bill (billType=REFUND) */
export function refundBill(billId) {
return request({
@@ -0,0 +1,265 @@
<template>
<el-drawer
:model-value="modelValue"
:title="title"
size="860px"
append-to-body
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<div class="hist-wrap" v-loading="loading">
<el-table :data="rows" border height="calc(100vh - 220px)" :empty-text="emptyText">
<el-table-column :label="colTime" width="170" align="center" prop="updateTime" />
<el-table-column :label="colAction" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="actionTag(row)">{{ actionLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colStatus" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colSet" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ priceSetOf(row) }}</template>
</el-table-column>
<el-table-column :label="colVer" width="90" align="center">
<template #default="{ row }">{{ versionOf(row) }}</template>
</el-table-column>
<el-table-column :label="colPsd" width="100" align="center">
<template #default="{ row }">{{ psdOf(row) }}</template>
</el-table-column>
<el-table-column :label="colAmount" width="110" align="right">
<template #default="{ row }">{{ amountOf(row) }}</template>
</el-table-column>
<el-table-column :label="colOps" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="query.pageNum"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="reload"
@current-change="reload"
/>
</div>
</div>
<el-drawer v-model="detailOpen" size="720px" :title="detailTitle" append-to-body destroy-on-close>
<div v-if="!active">
<el-empty :description="emptyDetail" />
</div>
<div v-else>
<el-descriptions :column="2" border size="small" class="mb12">
<el-descriptions-item :label="colTime">{{ active.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item :label="colAction">{{ actionLabel(active) }}</el-descriptions-item>
<el-descriptions-item :label="colStatus">{{ statusLabel(active.status) }}</el-descriptions-item>
<el-descriptions-item :label="colSet">{{ priceSetOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colVer">{{ versionOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colPsd">{{ psdOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="colAmount">{{ amountOf(active) }}</el-descriptions-item>
<el-descriptions-item :label="lbMatch">{{ matchMsgOf(active) }}</el-descriptions-item>
<el-descriptions-item v-if="active.errorMessage" :label="lbError" :span="2">
<span class="err">{{ active.errorMessage }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="json-panel">
<div class="json-panel__head">请求报文</div>
<pre class="code-pre">{{ formatJson(active.requestJson) }}</pre>
</div>
<div class="json-panel">
<div class="json-panel__head">返回报文</div>
<pre class="code-pre">{{ formatJson(active.responseJson) }}</pre>
</div>
</div>
</el-drawer>
</el-drawer>
</template>
<script setup name="BillBillingHistoryDrawer">
import { listBillHistory } from '@/api/price/billing'
const props = defineProps({
modelValue: { type: Boolean, default: false },
billId: { type: String, default: '' },
orderNo: { type: String, default: '' }
})
defineEmits(['update:modelValue'])
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({ pageNum: 1, pageSize: 10 })
const detailOpen = ref(false)
const active = ref(null)
const emptyText = '暂无历史计费记录'
const emptyDetail = '暂无详情'
const colTime = '写入时间'
const colAction = '类型'
const colStatus = '状态'
const colSet = '套餐'
const colVer = '版本'
const colPsd = '明细ID'
const colAmount = '应收总额'
const colOps = '操作'
const lbMatch = '命中说明'
const lbError = '错误信息'
const title = computed(() => {
const no = props.orderNo || ''
return no ? '历史计费记录 · ' + no : '历史计费记录'
})
const detailTitle = computed(() => '计费记录详情')
function parseJson(val) {
if (val == null || val === '') return {}
if (typeof val === 'object') return val
try { return JSON.parse(val) } catch (e) { return {} }
}
function respOf(row) {
return parseJson(row && row.responseJson)
}
function reqOf(row) {
return parseJson(row && row.requestJson)
}
function actionLabel(row) {
const req = reqOf(row)
if (req.action === 'UPSTREAM_SYNC') return '上游同步'
if (row && row.status === 'FAIL') return '计费失败'
const resp = respOf(row)
if (resp.matchSource === 'BILLING_FAIL' || resp.billingFailStatus) return '计费失败'
return '计费'
}
function actionTag(row) {
const label = actionLabel(row)
if (label === '上游同步') return 'info'
if (label === '计费失败') return 'danger'
return 'success'
}
function statusLabel(status) {
return status === 'FAIL' ? '失败' : '成功'
}
function statusTag(status) {
return status === 'FAIL' ? 'danger' : 'success'
}
function priceSetOf(row) {
const r = respOf(row)
if (r.priceSetName && r.priceSetCode) return r.priceSetName + ' (' + r.priceSetCode + ')'
return r.priceSetName || r.priceSetCode || (r.priceSetId != null ? String(r.priceSetId) : '-')
}
function versionOf(row) {
const r = respOf(row)
if (r.versionNo) return r.versionNo
return r.psvId != null ? ('psv=' + r.psvId) : '-'
}
function psdOf(row) {
const r = respOf(row)
return r.psdId != null ? String(r.psdId) : '-'
}
function amountOf(row) {
const r = respOf(row)
const v = r.incomeTotal != null ? r.incomeTotal : r.payableTotal
if (v == null || v === '') return '-'
const n = Number(v)
return Number.isNaN(n) ? String(v) : n.toFixed(2)
}
function matchMsgOf(row) {
return respOf(row).matchMessage || '-'
}
function formatJson(val) {
if (val == null || val === '') return '无数据'
if (typeof val === 'object') {
try { return JSON.stringify(val, null, 2) } catch (e) { return String(val) }
}
try { return JSON.stringify(JSON.parse(val), null, 2) } catch (e) { return String(val) }
}
function openDetail(row) {
active.value = row
detailOpen.value = true
}
async function reload() {
if (!props.billId) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await listBillHistory(props.billId, {
pageNum: query.pageNum,
pageSize: query.pageSize
})
rows.value = res.rows || []
total.value = Number(res.total) || 0
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.billId],
([open]) => {
if (open) {
query.pageNum = 1
detailOpen.value = false
active.value = null
reload()
}
}
)
</script>
<style scoped>
.hist-wrap { min-height: 200px; }
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.mb12 { margin-bottom: 12px; }
.err { color: #f56c6c; word-break: break-all; }
.json-panel {
margin-bottom: 12px;
background: #f7f9fb;
border: 1px solid #e8ecf0;
border-radius: 6px;
overflow: hidden;
}
.json-panel__head {
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: #606266;
background: #fafbfc;
border-bottom: 1px solid #ebeef5;
}
.code-pre {
margin: 0;
padding: 12px 14px;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -41,7 +41,17 @@
</section>
<section class="detail-block">
<div class="sec-title">计费信息</div>
<div class="sec-title">
计费信息
<el-button
v-if="detail && detail.billId"
type="primary"
link
size="small"
class="hist-btn"
@click="historyOpen = true"
>历史计费</el-button>
</div>
<div class="amount-summary">
<div class="amount-card">
<div class="amount-label">运费金额</div>
@@ -184,12 +194,18 @@
:current-charge-weight="detail?.chargeWeight"
@success="onChargeWeightUpdated"
/>
<bill-billing-history-drawer
v-model="historyOpen"
:bill-id="detail?.billId || ''"
:order-no="detail?.orderNo || ''"
/>
</el-drawer>
</template>
<script setup>
import AddIncomeFeeDialog from './AddIncomeFeeDialog.vue'
import EditChargeWeightDialog from './EditChargeWeightDialog.vue'
import BillBillingHistoryDrawer from './BillBillingHistoryDrawer.vue'
import { deleteIncomeBillFee } from '@/api/price/billing'
const { proxy } = getCurrentInstance()
@@ -205,6 +221,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'updated'])
const feeTab = ref('income')
const historyOpen = ref(false)
const addFeeOpen = ref(false)
const editFeeRow = ref(null)
const editCwOpen = ref(false)
@@ -224,6 +241,7 @@ const payFees = computed(() => {
watch(() => props.detail, () => {
feeTab.value = 'income'
historyOpen.value = false
})
function canEditManualFee(row) {
@@ -465,6 +483,9 @@ function refundLabel(status) {
border-radius: 2px;
background: var(--el-color-primary);
}
.hist-btn {
margin-left: auto;
}
.amount-summary {
display: grid;
grid-template-columns: repeat(5, 1fr);