feat(business): 前端业务/发货/回款管理三页
- 业务页:明细行编辑器、详情抽屉五分区(含状态时间线与毛利润卡片) - 回款页:业务→发货批次级联指定(可清空)、已核算行禁改禁删 - 金额/比率字符串直出;毛利润卡片注明不含运费口径 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
5207d14daa
commit
06cdf4141f
294
frontend/src/api/businesses.ts
Normal file
294
frontend/src/api/businesses.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
104
frontend/src/api/receipts.ts
Normal file
104
frontend/src/api/receipts.ts
Normal 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' })
|
||||
}
|
||||
117
frontend/src/api/shipments.ts
Normal file
117
frontend/src/api/shipments.ts
Normal 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' })
|
||||
}
|
||||
@ -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(区别于系统管理组)
|
||||
|
||||
1309
frontend/src/views/business/businesses/index.vue
Normal file
1309
frontend/src/views/business/businesses/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
508
frontend/src/views/business/receipts/index.vue
Normal file
508
frontend/src/views/business/receipts/index.vue
Normal 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>
|
||||
497
frontend/src/views/business/shipments/index.vue
Normal file
497
frontend/src/views/business/shipments/index.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user