feat: 增加顺丰月结列表,支持导出、结算和补填运费
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
77e8c78cb6
commit
a81f177cb6
4 files changed
+532
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type SfExpressSettlement = {
|
||||
id: string
|
||||
waybillNo: string
|
||||
createByName: string
|
||||
createTime: string
|
||||
settleStatus: string
|
||||
settleStatusLabel: string
|
||||
freightAmount: number | null
|
||||
freightAmountLabel: string
|
||||
settleTime: string
|
||||
}
|
||||
|
||||
export function fetchSfExpressSettlements(params: Record<string, unknown>) {
|
||||
return request.get<{ list: SfExpressSettlement[]; total: number }>({
|
||||
url: '/web/admin/sample-freights/sf-settlements',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSfExpressSettlementExport(params: Record<string, unknown>) {
|
||||
return request.get<{ list: SfExpressSettlement[] }>({
|
||||
url: '/web/admin/sample-freights/sf-settlements/export',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function settleSfExpressSettlements(data: { ids: string[]; freightAmount?: number }) {
|
||||
return request.post({ url: '/web/admin/sample-freights/sf-settlements/settle', data })
|
||||
}
|
||||
|
||||
export function fillSfExpressSettlementAmount(id: string, freightAmount: number) {
|
||||
return request.post({
|
||||
url: `/web/admin/sample-freights/sf-settlements/${id}/fill-amount`,
|
||||
data: { freightAmount }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { buildSfExpressExportRows } from './sf-express-settlement-export.ts'
|
||||
|
||||
test('export rows match the settlement list columns', () => {
|
||||
const rows = buildSfExpressExportRows([
|
||||
{
|
||||
waybillNo: '',
|
||||
createByName: '张三',
|
||||
createTime: '2026-10-21 09:00:00',
|
||||
settleStatusLabel: '未结算',
|
||||
freightAmountLabel: '月结',
|
||||
settleTime: ''
|
||||
}
|
||||
])
|
||||
assert.deepEqual(rows[0], ['—', '张三', '2026-10-21 09:00:00', '未结算', '月结', '—'])
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import FileSaver from 'file-saver'
|
||||
|
||||
const HEADERS = ['顺丰单号', '创建人', '创建时间', '结算状态', '运费', '结算时间']
|
||||
|
||||
export function buildSfExpressExportRows(list: Array<Record<string, string>>) {
|
||||
return list.map((row) => [
|
||||
row.waybillNo || '—',
|
||||
row.createByName || '—',
|
||||
row.createTime || '—',
|
||||
row.settleStatusLabel || '—',
|
||||
row.freightAmountLabel || '—',
|
||||
row.settleTime || '—'
|
||||
])
|
||||
}
|
||||
|
||||
export async function saveSfExpressSettlementWorkbook(list: Array<Record<string, string>>) {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet('顺丰月结')
|
||||
sheet.addRow(HEADERS)
|
||||
buildSfExpressExportRows(list).forEach((line) => sheet.addRow(line))
|
||||
const buffer = await workbook.xlsx.writeBuffer()
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const stamp = new Date()
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
const name = `顺丰月结-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}.xlsx`
|
||||
FileSaver.saveAs(blob, name)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">顺丰月结</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="顺丰单号">
|
||||
<el-input v-model="searchForm.waybillNo" placeholder="顺丰单号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="结算状态">
|
||||
<el-select
|
||||
v-model="searchForm.settleStatus"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="未结算" value="wjs" />
|
||||
<el-option label="已结算" value="yjs" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<el-date-picker
|
||||
v-model="searchForm.createTimeRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="settleDisabled"
|
||||
@click="handleSettle"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
标记已结算
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableRows"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
prop="waybillNo"
|
||||
label="顺丰单号"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.waybillNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="createByName"
|
||||
label="创建人"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.createByName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.createTime || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结算状态" min-width="110" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.settleStatus === 'yjs'" type="success" size="small">已结算</el-tag>
|
||||
<el-tag v-else-if="row.settleStatus === 'wjs'" type="info" size="small"
|
||||
>未结算</el-tag
|
||||
>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="freightAmountLabel"
|
||||
label="运费"
|
||||
min-width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.freightAmountLabel || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="settleTime"
|
||||
label="结算时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ row.settleTime || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="160"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.settleStatus === 'yjs' && row.freightAmountLabel === '月结'"
|
||||
class="btn-op-item"
|
||||
@click="openFill(row)"
|
||||
>
|
||||
<el-icon><EditPen /></el-icon>
|
||||
补填运费
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="settleVisible"
|
||||
title="标记已结算"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
运费<span class="form-label-tip">不填则保持当前展示:月结或原金额</span>
|
||||
</template>
|
||||
<el-input v-model="settleAmount" placeholder="选填,0 也是有效金额" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="settleVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:loading="settleSubmitting"
|
||||
@click="confirmSettle"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="fillVisible"
|
||||
title="补填运费"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="运费">
|
||||
<el-input v-model="fillAmount" placeholder="请填写运费,0 也是有效金额" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="fillVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="fillSubmitting" @click="confirmFill">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Download, EditPen, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchSfExpressSettlementExport,
|
||||
fetchSfExpressSettlements,
|
||||
fillSfExpressSettlementAmount,
|
||||
settleSfExpressSettlements,
|
||||
type SfExpressSettlement
|
||||
} from '@/api/sf-express-settlement'
|
||||
import { buildSettlePayload } from '@/utils/sf-express-freight'
|
||||
import { saveSfExpressSettlementWorkbook } from '@/utils/sf-express-settlement-export'
|
||||
|
||||
defineOptions({ name: 'CellStorageSfExpressSettlement' })
|
||||
|
||||
const loading = ref(false)
|
||||
const tableRows = ref<SfExpressSettlement[]>([])
|
||||
const selectedRows = ref<SfExpressSettlement[]>([])
|
||||
const searchForm = reactive({
|
||||
waybillNo: '',
|
||||
settleStatus: '',
|
||||
createTimeRange: null as [string, string] | null
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const settleVisible = ref(false)
|
||||
const settleAmount = ref('')
|
||||
const settleIds = ref<string[]>([])
|
||||
const settleSubmitting = ref(false)
|
||||
const fillVisible = ref(false)
|
||||
const fillAmount = ref('')
|
||||
const fillId = ref('')
|
||||
const fillSubmitting = ref(false)
|
||||
|
||||
const settleDisabled = computed(
|
||||
() =>
|
||||
selectedRows.value.length === 0 ||
|
||||
selectedRows.value.some((row) => row.settleStatus === 'yjs')
|
||||
)
|
||||
|
||||
function currentFilters() {
|
||||
const [from, to] = searchForm.createTimeRange || []
|
||||
const waybillNo = searchForm.waybillNo.trim()
|
||||
return {
|
||||
waybillNo: waybillNo || undefined,
|
||||
settleStatus: searchForm.settleStatus || undefined,
|
||||
createTimeFrom: from || undefined,
|
||||
createTimeTo: to || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapBody(res: unknown) {
|
||||
const record = res as { data?: { list?: unknown }; list?: unknown; code?: unknown }
|
||||
if (record?.data?.list) return record.data
|
||||
return record?.list ? record : record?.data || record
|
||||
}
|
||||
|
||||
function parseNonNegativeAmount(raw: unknown): 'blank' | 'invalid' | number {
|
||||
const text = String(raw ?? '').trim()
|
||||
if (!text) return 'blank'
|
||||
const value = Number(text)
|
||||
if (!Number.isFinite(value) || value < 0) return 'invalid'
|
||||
return value
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchSfExpressSettlements({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...currentFilters()
|
||||
})
|
||||
const data = unwrapBody(res) as { list?: SfExpressSettlement[]; total?: number }
|
||||
tableRows.value = Array.isArray(data?.list) ? data.list : []
|
||||
pagination.total = Number(data?.total || 0)
|
||||
selectedRows.value = []
|
||||
} catch {
|
||||
tableRows.value = []
|
||||
pagination.total = 0
|
||||
selectedRows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.waybillNo = ''
|
||||
searchForm.settleStatus = ''
|
||||
searchForm.createTimeRange = null
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: SfExpressSettlement[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function submitSettle(payload: { ids: string[]; freightAmount?: number }) {
|
||||
settleSubmitting.value = true
|
||||
try {
|
||||
await settleSfExpressSettlements(payload)
|
||||
settleVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
// 拦截器已提示失败原因
|
||||
} finally {
|
||||
settleSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettle() {
|
||||
if (settleDisabled.value) return
|
||||
const ids = selectedRows.value.map((row) => row.id).filter(Boolean)
|
||||
if (!ids.length) return
|
||||
if (ids.length > 1) {
|
||||
try {
|
||||
await ElMessageBox.confirm('本次仅更新结算状态和结算时间。', '标记已结算', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await submitSettle(buildSettlePayload(ids, ''))
|
||||
return
|
||||
}
|
||||
settleIds.value = ids
|
||||
settleAmount.value = ''
|
||||
settleVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmSettle() {
|
||||
const parsed = parseNonNegativeAmount(settleAmount.value)
|
||||
if (parsed === 'invalid') {
|
||||
ElMessage.warning('请填写运费')
|
||||
return
|
||||
}
|
||||
const text = parsed === 'blank' ? '' : String(parsed)
|
||||
await submitSettle(buildSettlePayload(settleIds.value, text))
|
||||
}
|
||||
|
||||
function openFill(row: SfExpressSettlement) {
|
||||
if (row.settleStatus !== 'yjs' || row.freightAmountLabel !== '月结') return
|
||||
fillId.value = row.id
|
||||
fillAmount.value = ''
|
||||
fillVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmFill() {
|
||||
const parsed = parseNonNegativeAmount(fillAmount.value)
|
||||
if (parsed === 'blank' || parsed === 'invalid') {
|
||||
ElMessage.warning('请填写运费')
|
||||
return
|
||||
}
|
||||
fillSubmitting.value = true
|
||||
try {
|
||||
await fillSfExpressSettlementAmount(fillId.value, parsed)
|
||||
fillVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
// 拦截器已提示失败原因
|
||||
} finally {
|
||||
fillSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await fetchSfExpressSettlementExport(currentFilters())
|
||||
const code = (res as { code?: unknown })?.code
|
||||
if (code != null && Number(code) !== 200) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
const body = (res as { data?: { list?: unknown } })?.data?.list
|
||||
? (res as { data?: { list?: SfExpressSettlement[] } }).data
|
||||
: (res as { list?: SfExpressSettlement[] })
|
||||
const list = body?.list || []
|
||||
if (!list.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
await saveSfExpressSettlementWorkbook(list as Array<Record<string, string>>)
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || '')
|
||||
const code = error?.code
|
||||
if (message.includes('没有可导出的数据') || (code != null && Number(code) !== 200)) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.muted {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user