feat(business): 前端业务/发货/回款管理三页

- 业务页:明细行编辑器、详情抽屉五分区(含状态时间线与毛利润卡片)
- 回款页:业务→发货批次级联指定(可清空)、已核算行禁改禁删
- 金额/比率字符串直出;毛利润卡片注明不含运费口径

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-13 12:50:10 +08:00
parent 5207d14daa
commit 06cdf4141f
7 changed files with 2848 additions and 0 deletions

View File

@ -0,0 +1,294 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 业务产品明细行(对应后端 `BusinessItemOut`)。
*
* 数量与金额一律字符串直出 —— 金额红线:不经 float、不 toFixed,
* Decimal 运算收敛在后端 service 层。
*/
export interface BusinessItemRow {
id: number
product_id: number | null
product_name: string
spec_model: string | null
unit: string | null
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */
product_category: string
/** 数字字符串,>0 */
quantity: string
sale_price: string | null
/** 行销售额 = 数量 × 销售单价,后端计算固化 */
sale_amount: string
cost_amount: string | null
guided_price: string | null
remark: string | null
}
/** 业务列表项(对应后端 `BusinessOut`;四个汇总金额均为字符串直出) */
export interface BusinessItem {
id: number
business_no: string
customer_id: number
customer_name: string | null
/** ISO 串(YYYY-MM-DD) */
business_date: string | null
/** 1执行中 2已完成 3已终止 */
status: number
owner_personnel_id: number | null
owner_personnel_name: string | null
assistant_personnel_id: number | null
assistant_personnel_name: string | null
/** 特殊业务标记:1是 0否 */
is_special: number
special_note: string | null
total_sale_amount: string | null
total_cost_amount: string | null
total_ship_amount: string | null
total_received_amount: string | null
total_freight_amount: string | null
remark: string | null
}
/** 业务详情:附产品明细行(编辑弹窗一次回显) */
export interface BusinessDetail extends BusinessItem {
items: BusinessItemRow[]
}
export interface BusinessQueryParams {
pageNum?: number
pageSize?: number
/** 业务编号/公司名称,模糊匹配 */
keyword?: string
status?: number
customer_id?: number
owner_personnel_id?: number
/** 业务日期起(含),ISO 串 */
date_from?: string
/** 业务日期止(含),ISO 串 */
date_to?: string
}
/** 明细行入参(product_id 空 = 自由录入;金额/数量为数字字符串) */
export interface BusinessItemParams {
product_id?: number | null
product_name: string
spec_model?: string | null
unit?: string | null
product_category: string
quantity: string
sale_price?: string | null
cost_amount?: string | null
guided_price?: string | null
remark?: string | null
}
/**
* 创建业务:主表信息 + 明细行(≥1)。
*
* is_special=1 时 special_note 必填(后端校验,前端同步拦截)。
*/
export interface BusinessCreateParams {
customer_id: number
business_date?: string | null
owner_personnel_id?: number | null
assistant_personnel_id?: number | null
is_special?: number
special_note?: string | null
items: BusinessItemParams[]
remark?: string | null
}
/**
* 编辑业务:明细全量替换并重算汇总。
*
* 不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`(唯一写归属
* 留痕的入口),编辑表单即使提交归属也会被后端忽略。
*/
export interface BusinessUpdateParams {
customer_id: number
business_date?: string | null
is_special?: number
special_note?: string | null
items: BusinessItemParams[]
remark?: string | null
}
/** 状态流转入参:执行中 → 2已完成 / 3已终止;终止时 reason 必填 */
export interface BusinessStatusParams {
status: number
reason?: string | null
}
/** 归属变更入参(新负责/新协助;前后值写入留痕,不回溯历史) */
export interface BusinessOwnershipParams {
new_owner_personnel_id?: number | null
new_assistant_personnel_id?: number | null
reason?: string | null
}
/** 业务归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
business_id: number
old_owner_personnel_id: number | null
new_owner_personnel_id: number | null
old_assistant_personnel_id: number | null
new_assistant_personnel_id: number | null
old_owner_name: string | null
new_owner_name: string | null
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 业务状态流转记录(对应后端 `BusinessStatusLogOut`;创建首条 old=new=1) */
export interface StatusLogItem {
id: number
business_id: number
old_status: number
new_status: number
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 毛利润视图行(对应后端 `GrossProfitRowOut`);成本缺失行派生列为 null */
export interface GrossProfitRowItem {
item_id: number
product_category: string
product_name: string
spec_model: string | null
quantity: string
sale_amount: string
cost_amount: string | null
profit_amount: string | null
profit_rate: string | null
threshold: string
converted_profit: string | null
missing_reason: string | null
}
/** 按产品类别的毛利润汇总(成本缺失行不计入聚合) */
export interface GrossProfitCategory {
product_category: string
sale_amount: string
cost_amount: string
profit_amount: string
/** 分数字符串原样(如 "997/1000"),不做数值化展示 */
profit_rate: string
threshold: string
converted_profit: string
}
/** 毛利润数据视图(对应后端 `GrossProfitOut`) */
export interface GrossProfitView {
business_id: number
/** 参数取值时点(默认今日),ISO 串 */
as_of: string
rows: GrossProfitRowItem[]
categories: GrossProfitCategory[]
missing_count: number
note: string
}
/** 业务状态下拉项(与后端值域一致;执行中=1 已完成=2 已终止=3) */
export const BUSINESS_STATUS_OPTIONS = [
{ label: '执行中', value: 1 },
{ label: '已完成', value: 2 },
{ label: '已终止', value: 3 },
] as const
/** 状态编码 → 展示名 */
export function businessStatusLabel(status: number): string {
return BUSINESS_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? `状态${status}`
}
/** 状态编码 → 标签类型(三色:执行中 primary / 已完成 success / 已终止 danger) */
export function businessStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' {
if (status === 2) return 'success'
if (status === 3) return 'danger'
return 'primary'
}
/** 分页查询业务(编号/公司名称模糊,可按状态/客户/归属/日期范围筛选) */
export function listBusinesses(params: BusinessQueryParams): Promise<PageData<BusinessItem>> {
return request({ url: '/v1/business/businesses', method: 'get', params })
}
/** 创建业务(编号自动生成,写首条状态留痕) */
export function createBusiness(data: BusinessCreateParams): Promise<BusinessItem> {
return request({ url: '/v1/business/businesses', method: 'post', data })
}
/** 业务详情(附产品明细行) */
export function getBusinessDetail(businessId: number): Promise<BusinessDetail> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'get' })
}
/** 编辑业务(明细全量替换;归属不在此端点调整) */
export function updateBusiness(
businessId: number,
data: BusinessUpdateParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'put', data })
}
/** 删除业务(有发货或回款记录时后端拒绝,应改为已终止) */
export function deleteBusiness(businessId: number): Promise<null> {
return request({ url: `/v1/business/businesses/${businessId}`, method: 'delete' })
}
/** 状态流转(终态不可再变更;终止必填原因) */
export function changeBusinessStatus(
businessId: number,
data: BusinessStatusParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}/status`, method: 'put', data })
}
/** 调整负责/协助营销人员(同事务写归属变更留痕) */
export function changeBusinessOwnership(
businessId: number,
data: BusinessOwnershipParams,
): Promise<BusinessItem> {
return request({ url: `/v1/business/businesses/${businessId}/ownership`, method: 'put', data })
}
/** 某业务的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
businessId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<OwnershipLogItem>> {
return request({
url: `/v1/business/businesses/${businessId}/ownership-logs`,
method: 'get',
params,
})
}
/** 某业务的状态流转时间线(分页,按时间正序,从创建起展示) */
export function listStatusLogs(
businessId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<StatusLogItem>> {
return request({
url: `/v1/business/businesses/${businessId}/status-logs`,
method: 'get',
params,
})
}
/** 毛利润数据视图(as_of 缺省=今日;阈值按该时点参数快照) */
export function getGrossProfit(
businessId: number,
asOf?: string,
): Promise<GrossProfitView> {
return request({
url: `/v1/business/businesses/${businessId}/gross-profit`,
method: 'get',
params: asOf ? { as_of: asOf } : undefined,
})
}

View File

@ -0,0 +1,104 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 回款列表项(对应后端 `ReceiptOut`;字段契约纯蛇形直出)。
*
* amount 字符串直出(金额红线);payment_days 为派生值:未指定发货
* 批次时为 null,指定后 = 发货日到回款日的天数(发货日计第 1 天)。
*/
export interface ReceiptItem {
id: number
receipt_batch_no: string
business_id: number
business_no: string
shipment_id: number | null
shipment_no: string | null
/** 回款到账日,ISO 串(YYYY-MM-DD);M3 核算按此日匹配参数版本 */
receipt_date: string
/** 回款金额(元),字符串直出 */
amount: string
/** 以物抵顶标记:1是 0否(绩效按比例折算) */
is_barter: number
/** 核算状态:0未核算 1已核算(已核算禁改禁删) */
settlement_status: number
payment_days: number | null
remark: string | null
}
export interface ReceiptQueryParams {
pageNum?: number
pageSize?: number
business_id?: number
/** 回款到账日起(含),ISO 串 */
date_from?: string
/** 回款到账日止(含),ISO 串 */
date_to?: string
is_barter?: number
settlement_status?: number
}
/**
* 新增回款:业务必选,发货批次可暂不指定(手动指定)。
*
* shipment_id 须与本回款同属一个业务(后端强校验,数据完整性红线)。
*/
export interface ReceiptCreateParams {
business_id: number
shipment_id?: number | null
receipt_date: string
/** 数字字符串,非负,最多两位小数 */
amount: string
is_barter?: number
remark?: string | null
}
/**
* 编辑回款:全量替换语义;不含 business_id(挂靠业务创建时定死)。
*
* shipment_id 传 null = 清除指定(回到「暂未指定」)。
*/
export interface ReceiptUpdateParams {
shipment_id?: number | null
receipt_date: string
amount: string
is_barter?: number
remark?: string | null
}
/** 以物抵顶筛选项 */
export const BARTER_OPTIONS = [
{ label: '以物抵顶', value: 1 },
{ label: '货币回款', value: 0 },
] as const
/** 核算状态筛选项(与列表标签文案一致) */
export const SETTLEMENT_OPTIONS = [
{ label: '已核算', value: 1 },
{ label: '未核算', value: 0 },
] as const
/** 分页查询回款(附业务号/发货单号与回款天数派生值) */
export function listReceipts(params: ReceiptQueryParams): Promise<PageData<ReceiptItem>> {
return request({ url: '/v1/business/receipts', method: 'get', params })
}
/** 新增回款(批次号自动生成 HK 前缀;业务须执行中) */
export function createReceipt(data: ReceiptCreateParams): Promise<ReceiptItem> {
return request({ url: '/v1/business/receipts', method: 'post', data })
}
/** 回款详情(含回款天数派生值) */
export function getReceiptDetail(receiptId: number): Promise<ReceiptItem> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'get' })
}
/** 编辑回款(全量替换;已核算回款后端拒绝) */
export function updateReceipt(receiptId: number, data: ReceiptUpdateParams): Promise<ReceiptItem> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'put', data })
}
/** 删除回款(已核算/业务非执行中时后端拒绝;业务回款合计自动回退) */
export function deleteReceipt(receiptId: number): Promise<null> {
return request({ url: `/v1/business/receipts/${receiptId}`, method: 'delete' })
}

View File

@ -0,0 +1,117 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 发货产品明细行(对应后端 `ShipmentItemOut`)。
*
* 数量/单价/行金额一律字符串直出 —— 金额红线:不经 float、不 toFixed;
* 行金额 = 数量 × 单价由后端计算固化,不信任前端传值。
*/
export interface ShipmentItemRow {
id: number
product_id: number | null
product_name: string
spec_model: string | null
unit: string | null
/** 数字字符串,>0 */
quantity: string
/** 发货单价,可空 = 待人工补价(补价前行金额记 0) */
sale_price: string | null
/** 行金额,后端按数量×单价计算固化 */
amount: string
}
/** 发货单列表项(对应后端 `ShipmentOut`;business_no 为联表附带列) */
export interface ShipmentItem {
id: number
shipment_no: string
business_id: number
business_no: string | null
/** ISO 串(YYYY-MM-DD);发货日是回款天数计算基准日(发货日计第 1 天) */
ship_date: string
/** 运费(元),仅记录展示,不计入毛利润口径 */
freight_amount: string
/** 发货总金额,后端按明细固化 */
total_amount: string
remark: string | null
}
/** 发货单详情:附产品明细行(编辑弹窗一次回显) */
export interface ShipmentDetail extends ShipmentItem {
items: ShipmentItemRow[]
}
export interface ShipmentQueryParams {
pageNum?: number
pageSize?: number
/** 业务筛选,空 = 全部 */
business_id?: number
/** 发货日期起(含),ISO 串 */
ship_date_from?: string
/** 发货日期止(含),ISO 串 */
ship_date_to?: string
}
/** 发货明细行入参(product_id 空 = 自由录入;单价可空=待人工补价) */
export interface ShipmentItemParams {
product_id?: number | null
product_name: string
spec_model?: string | null
unit?: string | null
quantity: string
sale_price?: string | null
}
/**
* 创建发货单:业务维度 + 明细行(≥1)。
*
* 业务须处于执行中(后端校验);ship_date 为回款天数计算基准日。
*/
export interface ShipmentCreateParams {
business_id: number
ship_date: string
freight_amount?: string | null
items: ShipmentItemParams[]
remark?: string | null
}
/**
* 编辑发货单:明细全量替换。
*
* 不含 business_id —— 发货单挂靠的业务创建时定死,改挂会让已指定
* 本发货单的回款语义断裂。
*/
export interface ShipmentUpdateParams {
ship_date: string
freight_amount?: string | null
items: ShipmentItemParams[]
remark?: string | null
}
/** 分页查询发货单(附业务编号,可按业务/发货日期范围筛选) */
export function listShipments(params: ShipmentQueryParams): Promise<PageData<ShipmentItem>> {
return request({ url: '/v1/business/shipments', method: 'get', params })
}
/** 创建发货单(明细 ≥1;业务须执行中) */
export function createShipment(data: ShipmentCreateParams): Promise<ShipmentItem> {
return request({ url: '/v1/business/shipments', method: 'post', data })
}
/** 发货单详情(附产品明细行) */
export function getShipmentDetail(shipmentId: number): Promise<ShipmentDetail> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'get' })
}
/** 编辑发货单(明细全量替换;终态业务的发货单后端拒绝) */
export function updateShipment(
shipmentId: number,
data: ShipmentUpdateParams,
): Promise<ShipmentItem> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'put', data })
}
/** 删除发货单(连带明细并回写业务汇总;被回款批次指定时后端拒绝) */
export function deleteShipment(shipmentId: number): Promise<null> {
return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'delete' })
}

View File

@ -89,6 +89,25 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', group: '基础数据', order: 10 },
},
{
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
path: '/business/businesses',
name: 'business-businesses',
component: () => import('@/views/business/businesses/index.vue'),
meta: { title: '业务管理', group: '业务数据', order: 11 },
},
{
path: '/business/shipments',
name: 'business-shipments',
component: () => import('@/views/business/shipments/index.vue'),
meta: { title: '发货管理', group: '业务数据', order: 12 },
},
{
path: '/business/receipts',
name: 'business-receipts',
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', group: '业务数据', order: 13 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,508 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessApi from '@/api/businesses'
import * as receiptApi from '@/api/receipts'
import type { ReceiptItem } from '@/api/receipts'
import * as shipmentApi from '@/api/shipments'
import type { ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 回款管理(需求 2.2.3:回款登记 + 发货批次级联指定 + 派生回款天数)。
*
* 与后端约定对齐:
* - 业务必选、发货批次可暂不指定;选中业务后级联加载其发货单
* (shipment_id 须与业务同源,后端强校验),可清空
* - payment_days 为派生值(发货日 → 回款日,发货日计第 1 天),
* 未指定批次时为 null,列表显示「未指定批次」
* - 已核算(settlement_status=1)行禁改禁删(按钮 disabled + tooltip)
* - 金额字符串直出与提交(金额红线:不经 float、不 toFixed)
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
business_id: undefined as number | undefined,
is_barter: undefined as number | undefined,
settlement_status: undefined as number | undefined,
})
/** 回款日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
const dateRange = ref<[string, string] | null>(null)
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<ReceiptItem, receiptApi.ReceiptQueryParams>(
receiptApi.listReceipts,
() => ({
business_id: filters.business_id,
is_barter: filters.is_barter,
settlement_status: filters.settlement_status,
date_from: dateRange.value?.[0],
date_to: dateRange.value?.[1],
}),
)
function resetFilters(): void {
filters.business_id = undefined
filters.is_barter = undefined
filters.settlement_status = undefined
dateRange.value = null
search()
}
// ==================== 业务下拉数据源 ====================
interface BusinessOption {
id: number
no: string
name: string
}
const businessOptions = ref<BusinessOption[]>([])
async function loadBusinessOptions(): Promise<void> {
try {
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({
id: b.id,
no: b.business_no,
name: b.customer_name ?? '',
}))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
function businessLabel(option: BusinessOption): string {
return option.name ? `${option.no} · ${option.name}` : option.no
}
// ==================== 新增 / 编辑 ====================
/** 金额形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
business_id: undefined as number | undefined,
shipment_id: undefined as number | undefined,
receipt_date: '',
amount: '',
is_barter: 0,
remark: '',
})
const rules = computed<FormRules>(() => ({
business_id: [{ required: true, message: '请选择业务', trigger: 'change' }],
receipt_date: [{ required: true, message: '请选择回款到账日', trigger: 'change' }],
amount: [
{ required: true, message: '请输入回款金额', trigger: 'blur' },
{
pattern: AMOUNT_PATTERN,
message: '金额须为非负数字,最多两位小数',
trigger: 'blur',
},
],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
// ---- 级联:业务 → 该业务的发货单(回款↔发货批次级联指定) ----
const shipmentOptions = ref<ShipmentItem[]>([])
const shipmentLoading = ref(false)
/** 编辑中的回款(回显业务编号/批次语义用) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
function shipmentLabel(row: ShipmentItem): string {
return `${row.shipment_no}(${row.ship_date},合计 ${row.total_amount} 元)`
}
/** 按业务级联加载发货单(列表一页 200 条足够该业务规模) */
async function loadShipmentOptions(businessId: number): Promise<void> {
shipmentLoading.value = true
try {
const data = await shipmentApi.listShipments({
business_id: businessId,
pageNum: 1,
pageSize: 200,
})
shipmentOptions.value = data.list
} catch (error) {
shipmentOptions.value = []
handledError(error) // 失败提示由请求拦截器统一完成,下拉保持空
} finally {
shipmentLoading.value = false
}
}
/** 业务切换:清空已选发货批次并重新级联(原批次极可能不再法属于新业务) */
function handleBusinessChange(value: number | undefined): void {
form.shipment_id = undefined
shipmentOptions.value = []
if (value) void loadShipmentOptions(value)
}
function openCreate(): void {
editingId.value = null
Object.assign(form, {
business_id: undefined,
shipment_id: undefined,
receipt_date: '',
amount: '',
is_barter: 0,
remark: '',
})
shipmentOptions.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function openEdit(row: ReceiptItem): Promise<void> {
editingId.value = row.id
Object.assign(form, {
business_id: row.business_id,
shipment_id: row.shipment_id ?? undefined,
receipt_date: row.receipt_date,
amount: row.amount,
is_barter: row.is_barter,
remark: row.remark ?? '',
})
dialogVisible.value = true
if (row.business_id) await loadShipmentOptions(row.business_id)
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
// 编辑全量替换:shipment_id 传 null = 清除既有指定(回到「暂未指定」)
await receiptApi.updateReceipt(editingId.value, {
shipment_id: form.shipment_id ?? null,
receipt_date: form.receipt_date,
amount: form.amount.trim(),
is_barter: form.is_barter,
remark: form.remark.trim() || null,
})
ElMessage.success('已保存')
} else {
await receiptApi.createReceipt({
business_id: form.business_id as number,
shipment_id: form.shipment_id ?? null,
receipt_date: form.receipt_date,
amount: form.amount.trim(),
is_barter: form.is_barter,
remark: form.remark.trim() || null,
})
ElMessage.success('回款已登记')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 删除 ====================
async function handleDelete(row: ReceiptItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除回款「${row.receipt_batch_no}」?删除为物理删除、不可恢复;删除后业务回款合计将自动回退。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '回款已删除',
run: () => receiptApi.deleteReceipt(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadBusinessOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 230px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="以物抵顶">
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="item in receiptApi.BARTER_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="核算状态">
<el-select
v-model="filters.settlement_status"
placeholder="全部"
clearable
style="width: 110px"
>
<el-option
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增回款</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="140">
<template #default="{ row }">{{ row.business_no }}</template>
</el-table-column>
<el-table-column label="发货单号" min-width="140">
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
</el-table-column>
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
<el-table-column label="金额(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="以物抵顶" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
以物抵顶
</el-tag>
<span v-else class="cell-muted">否</span>
</template>
</el-table-column>
<el-table-column label="回款天数" width="120" align="center">
<template #default="{ row }">
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
<span v-else class="cell-muted">未指定批次</span>
</template>
</el-table-column>
<el-table-column label="核算状态" width="90" align="center">
<template #default="{ row }">
<el-tag
:type="row.settlement_status === 1 ? 'success' : 'info'"
size="small"
disable-transitions
>
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.settlement_status === 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="danger"
size="small"
:disabled="row.settlement_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑回款' : '新增回款'"
width="620px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item v-if="!isEdit" label="业务" prop="business_id">
<el-select
v-model="form.business_id"
placeholder="搜索并选择业务(编号 / 客户名)"
filterable
style="width: 100%"
@change="handleBusinessChange"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="业务">
<!-- 挂靠业务创建时定死,编辑时只读展示 -->
<span>{{ editingRow?.business_no ?? '—' }}</span>
</el-form-item>
<el-form-item label="发货批次" prop="shipment_id">
<el-select
v-model="form.shipment_id"
placeholder="选业务后级联加载该业务发货单"
clearable
filterable
:loading="shipmentLoading"
:disabled="!form.business_id"
style="width: 100%"
>
<el-option
v-for="item in shipmentOptions"
:key="item.id"
:label="shipmentLabel(item)"
:value="item.id"
/>
</el-select>
<span class="field-hint">可清空(暂不指定);指定后回款天数方可派生(发货日计第 1 天)</span>
</el-form-item>
<el-form-item label="回款到账日" prop="receipt_date">
<el-date-picker
v-model="form.receipt_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="M3 核算按此日匹配参数版本"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="金额(元)" prop="amount">
<el-input v-model="form.amount" placeholder="非负数字,最多两位小数" />
</el-form-item>
<el-form-item label="以物抵顶" prop="is_barter">
<el-switch v-model="form.is_barter" :active-value="1" :inactive-value="0" />
<span class="switch-hint">绩效按 80% 折算,比例见参数配置</span>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.op-wrap {
display: inline-block;
}
.switch-hint {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
.field-hint {
display: block;
width: 100%;
color: var(--ys-ink-3);
font-size: 12px;
line-height: 1.6;
}
</style>

View File

@ -0,0 +1,497 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessApi from '@/api/businesses'
import * as shipmentApi from '@/api/shipments'
import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 发货管理(需求 2.2.2:发货单 + 明细行 + 运费 + 汇总回写)。
*
* 与后端约定对齐:
* - 发货单挂靠执行中的业务(后端校验状态门槛);创建绑业务后不可改挂,
* 编辑端点不含 business_id(保证已指定本单的回款语义不断裂)
* - ship_date 是回款天数计算基准日(发货日计第 1 天)—— 表单旁注提示
* - 明细行金额 = 数量 × 单价由后端固化;单价可空 = 待人工补价(行金额记 0)
* - 删除连带明细并回写业务汇总;被回款批次指定时后端拒绝
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
business_id: undefined as number | undefined,
})
/** 发货日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
const dateRange = ref<[string, string] | null>(null)
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<ShipmentItem, shipmentApi.ShipmentQueryParams>(
shipmentApi.listShipments,
() => ({
business_id: filters.business_id,
ship_date_from: dateRange.value?.[0],
ship_date_to: dateRange.value?.[1],
}),
)
function resetFilters(): void {
filters.business_id = undefined
dateRange.value = null
search()
}
// ==================== 业务下拉数据源(可搜索过滤) ====================
interface BusinessOption {
id: number
no: string
name: string
status: number
}
const businessOptions = ref<BusinessOption[]>([])
async function loadBusinessOptions(): Promise<void> {
try {
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({
id: b.id,
no: b.business_no,
name: b.customer_name ?? '',
status: b.status,
}))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
function businessLabel(option: BusinessOption): string {
const state = option.status === 1 ? '' : option.status === 2 ? '(已完成)' : '(已终止)'
return option.name ? `${option.no} · ${option.name}${state}` : `${option.no}${state}`
}
// ==================== 新增 / 编辑(含明细行编辑器) ====================
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
interface ItemDraft {
product_name: string
spec_model: string
unit: string
quantity: string
sale_price: string
}
const form = reactive({
business_id: undefined as number | undefined,
ship_date: '',
freight_amount: '',
remark: '',
})
function emptyItem(): ItemDraft {
return { product_name: '', spec_model: '', unit: '', quantity: '', sale_price: '' }
}
const itemRows = ref<ItemDraft[]>([])
const rules = computed<FormRules>(() => ({
business_id: [{ required: true, message: '请选择业务', trigger: 'change' }],
ship_date: [{ required: true, message: '请选择发货日期', trigger: 'change' }],
freight_amount: [
{ pattern: AMOUNT_PATTERN, message: '运费须为非负数字,最多两位小数', trigger: 'blur' },
],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
business_id: undefined,
ship_date: '',
freight_amount: '',
remark: '',
})
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function openEdit(row: ShipmentItem): Promise<void> {
editingId.value = row.id
dialogVisible.value = true
const detail: ShipmentDetail | null = await shipmentApi.getShipmentDetail(row.id).catch((error) => {
handledError(error) // 失败提示由请求拦截器统一完成,弹窗保持空表
return null
})
if (!detail) return
Object.assign(form, {
business_id: detail.business_id,
ship_date: detail.ship_date,
freight_amount: detail.freight_amount ?? '',
remark: detail.remark ?? '',
})
itemRows.value = detail.items.map((it) => ({
product_name: it.product_name,
spec_model: it.spec_model ?? '',
unit: it.unit ?? '',
quantity: it.quantity,
sale_price: it.sale_price ?? '',
}))
formRef.value?.clearValidate()
}
function addItemRow(): void {
itemRows.value.push(emptyItem())
}
function removeItemRow(index: number): void {
itemRows.value.splice(index, 1)
}
/** 明细行校验:返回第一处问题的提示文案,全部通过返回 null */
function validateItemRows(rows: ItemDraft[]): string | null {
if (rows.length === 0) return '请至少录入一行发货明细'
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i]
const no = `第 ${i + 1} 行`
if (!row.product_name.trim()) return `${no}的产品名称未填写`
if (!AMOUNT_PATTERN.test(row.quantity) || ZERO_PATTERN.test(row.quantity)) {
return `${no}的数量须为大于 0 的数字,最多两位小数`
}
if (row.sale_price.trim() && !AMOUNT_PATTERN.test(row.sale_price.trim())) {
return `${no}的发货单价须为非负数字,最多两位小数`
}
}
return null
}
/** 明细草稿 → 提交行(空串归一为 null,与后端「空串归一 NULL」口径一致) */
function buildItemParams(): shipmentApi.ShipmentItemParams[] {
return itemRows.value.map((row) => ({
product_name: row.product_name.trim(),
spec_model: row.spec_model.trim() || null,
unit: row.unit.trim() || null,
quantity: row.quantity.trim(),
sale_price: row.sale_price.trim() || null,
}))
}
async function doSubmit(): Promise<void> {
const items = buildItemParams()
submitting.value = true
try {
if (editingId.value !== null) {
// 编辑不含 business_id:发货单挂靠的业务创建时定死
await shipmentApi.updateShipment(editingId.value, {
ship_date: form.ship_date,
freight_amount: form.freight_amount.trim() || null,
items,
remark: form.remark.trim() || null,
})
ElMessage.success('已保存')
} else {
await shipmentApi.createShipment({
business_id: form.business_id as number,
ship_date: form.ship_date,
freight_amount: form.freight_amount.trim() || null,
items,
remark: form.remark.trim() || null,
})
ElMessage.success('发货单已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const rowError = validateItemRows(itemRows.value)
if (rowError) {
ElMessage.warning(rowError)
return
}
if (isEdit.value) {
await doSubmit() // 编辑全量替换无破坏性语义变化,直接提交
return
}
// 创建走 confirm 二次确认(提交回写入业务发货额汇总)
await confirmAndRun({
confirmText: '确定创建发货单?提交后业务发货额将同步更新。',
title: '创建确认',
confirmButtonText: '确认创建',
successText: '发货单已创建',
run: doSubmit,
})
}
// ==================== 删除 ====================
async function handleDelete(row: ShipmentItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除发货单「${row.shipment_no}」?删除将连带明细行并回写业务汇总;被回款批次指定时不可删除。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '发货单已删除',
run: () => shipmentApi.deleteShipment(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadBusinessOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="业务">
<el-select
v-model="filters.business_id"
placeholder="全部"
clearable
filterable
style="width: 240px"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="发货日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="起"
end-placeholder="止"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增发货单</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="shipment_no" label="发货单号" min-width="150" />
<el-table-column prop="business_no" label="业务编号" min-width="150">
<template #default="{ row }">{{ row.business_no ?? '—' }}</template>
</el-table-column>
<el-table-column label="发货日期" min-width="140">
<template #default="{ row }">
<span>{{ row.ship_date }}</span>
<!-- 回款天数计算基准日提示:发货日计第 1 天 -->
<el-tooltip content="本单发货日期是回款天数计算基准日(发货日计第 1 天)" placement="top">
<span class="cell-muted">(基准日)</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="运费(元)" min-width="110" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.freight_amount }}</span>
</template>
</el-table-column>
<el-table-column label="发货总额(元)" min-width="130" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑发货单' : '新增发货单'"
width="820px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item v-if="!isEdit" label="业务" prop="business_id">
<el-select
v-model="form.business_id"
placeholder="搜索或选择业务(筛选后按编号 / 客户名匹配)"
filterable
style="width: 100%"
>
<el-option
v-for="item in businessOptions"
:key="item.id"
:label="businessLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="业务">
<!-- 挂靠业务创建时定死,编辑时只读展示 -->
<span>{{ editingRow?.business_no ?? '—' }}</span>
</el-form-item>
<el-form-item label="发货日期" prop="ship_date">
<el-date-picker
v-model="form.ship_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
<span class="field-hint">回款天数计算基准日(发货日计第 1 天)</span>
</el-form-item>
<el-form-item label="运费(元)" prop="freight_amount">
<el-input v-model="form.freight_amount" placeholder="选填;仅记录展示,不计入毛利润口径" />
</el-form-item>
</el-form>
<div class="items-head">
<span class="items-title">发货明细(至少 1 行,编辑为全量替换)</span>
<el-button size="small" @click="addItemRow">添加一行</el-button>
</div>
<el-table :data="itemRows" stripe size="small" class="items-table">
<el-table-column label="产品名称" min-width="180">
<template #default="{ row }">
<el-input v-model="row.product_name" maxlength="128" placeholder="产品名称,可手填" />
</template>
</el-table-column>
<el-table-column label="规格型号" min-width="130">
<template #default="{ row }">
<el-input v-model="row.spec_model" maxlength="128" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="单位" min-width="90">
<template #default="{ row }">
<el-input v-model="row.unit" maxlength="16" placeholder="如 台" />
</template>
</el-table-column>
<el-table-column label="数量" min-width="110">
<template #default="{ row }">
<el-input v-model="row.quantity" placeholder=">0" />
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="130">
<template #default="{ row }">
<el-input v-model="row.sale_price" placeholder="可空=待人工补价" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeItemRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-form label-width="110px" class="tail-form" @submit.prevent>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.field-hint {
display: block;
width: 100%;
color: var(--ys-ink-3);
font-size: 12px;
line-height: 1.6;
}
.items-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.items-title {
font-weight: 600;
color: var(--ys-ink);
font-size: 13px;
}
.items-table {
margin-bottom: 16px;
}
.tail-form {
margin-top: 4px;
}
</style>