Przeglądaj źródła

feat: 增加顺丰月结列表,支持导出、结算和补填运费

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 tydzień temu
rodzic
commit
a81f177cb6

+ 38 - 0
web-admin/src/api/sf-express-settlement.ts

@@ -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 }
+  })
+}

+ 17 - 0
web-admin/src/utils/sf-express-settlement-export.test.ts

@@ -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', '未结算', '月结', '—'])
+})

+ 30 - 0
web-admin/src/utils/sf-express-settlement-export.ts

@@ -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)
+}

+ 447 - 0
web-admin/src/views/cell-storage/sf-express-settlement/index.vue

@@ -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>