一、需求描述
系统设计了 销售单、采购单、入库单、出库单、盘点单、移库单、生产计划单等等等等类型的单据,单据都有着 单据编号 、单据明细 、单据状态 等属性,而且每个单据都有不同的状态,但又都包含了 待审核 ,已驳回 ,已完成 等状态,且每个单据都有自己的单据号,如果被驳回审核的话,又都包含了驳回审核的原因等等。每种单据又都有自己的明细单,比如销售单对应的销售明细单等。这些明细单又分别的有数量、已完成数量等。
为了代码复用且便于维护,于是我们将所有单据抽出基类来统一实现公共的一些功能。
二、设计思路
我们需要设计一个数据结构来存储单据信息,包括单据编号、单据明细、单据总数量、单据已完成数量、单据状态等。
1. 抽象单据基类
typescript
export abstract class AbstractBaseBillEntity<D extends AbstractBaseBillDetailEntity> extends BaseEntity {
/**
* # 单据编号
*
* 所有的单据都会有单据编号的字段
*/
@Table({
orderNumber: 99,
forceShow: true,
})
@Form({
placeholder: '不填写按编码规则自动生成',
})
@Field('单据编号') billCode!: string
/**
* # 单据状态码
*
* 因为单据的状态码都不确定,所以这里定义为抽象属性
*/
abstract status: number
/**
* # 单据明细列表
*
* 因为单据明细的具体类型未知,所以这里给定泛型,并且抽象
* 子类可以按自己的设计去实现,并且标记强制数据转换的类
*/
abstract details: D[]
/**
* # 驳回原因
*
* 所有被驳回的单据都需要驳回的原因字段
*/
@Form({
textarea: true,
})
@Field('驳回原因') rejectReason!: string
}
2. 抽象单据明细基类
typescript
export abstract class AbstractBaseBillDetailEntity extends BaseEntity {
/**
* # 单据ID
*/
billId!: number
/**
* # 数量
*
* 有的叫采购数量,有的叫出库数量
*/
abstract quantity: number
/**
* # 已完成数量
*
* 有的叫已采购数量,有的叫已出库数量
*/
abstract finishQuantity: number
}
3. 抽象单据操作Service基类
因为所有的单据都会有审核、驳回、完成等操作,所以这里定义一个抽象类来统一实现这些功能。
typescript
/**
* # 单据抽象服务基类
* 完成单据的审核、驳回、完成等操作
*/
export abstract class AbstractBaseBillService<D extends AbstractBaseBillDetailEntity, B extends AbstractBaseBillEntity<D>> extends AbstractBaseService<B> {
/**
* # 审核单据
* @param bill 单据
*/
async audit(bill: B): Promise<void> {
await this.api('audit').post(bill)
}
/**
* # 驳回单据
* @param bill 单据
*/
async reject(bill: B): Promise<void> {
await this.api('reject').post(bill)
}
/**
* # 完成单据
* @param bill 单据
*/
async finish(bill: B): Promise<void> {
await this.api('finish').post(bill)
}
/**
* # 添加完成数量
* @param bill 单据
*/
async addFinish(bill: D): Promise<void> {
await this.api('addFinish').post(bill)
}
}
4. 封装单据的统一表格Hook
typescript
/**
* # 单据的表格Hook
* @param entityClass 单据实体类
* @param serviceClass 单据服务类
* @param option 配置项
*/
export function useBillTable<
D extends AbstractBaseBillDetailEntity,
B extends AbstractBaseBillEntity<D>,
S extends AbstractBaseBillService<D, B>
>(
entityClass: ClassConstructor<B>,
serviceClass: ClassConstructor<S>,
option: IUseTableOption<B> = {},
): IUseBillTableResult<D, B, S> {
const result = useAirTable(entityClass, serviceClass, option)
async function onAudit(bill: B) {
await AirConfirm.warning(`是否确认审核选择的${result.entity.getModelName()}?`)
await result.service.audit(bill)
result.onReloadData()
}
async function onFinish(bill: B) {
await AirConfirm.warning(`是否确认手动完成选择的${result.entity.getModelName()}?`)
await result.service.finish(bill)
result.onReloadData()
}
async function onReject(bill: B) {
const rejectReason: string = await AirDialog.show(BillRejectDialog, `驳回${result.entity.getModelName()}的原因`)
await AirConfirm.warning(`是否确认驳回选择的${result.entity.getModelName()}?`)
bill.rejectReason = rejectReason
await result.service.reject(bill)
result.onReloadData()
}
return {
onFinish,
onAudit,
onReject,
...result,
} as IUseBillTableResult<D, B, S>
}
typescript
/**
* # 单据表格结构体声明
* @param D 单据明细类型
* @param B 单据类型
* @param S 单据服务类型
*/
export interface IUseBillTableResult<
D extends AbstractBaseBillDetailEntity,
B extends AbstractBaseBillEntity<D>,
S extends AbstractBaseBillService<D, B>
> extends IUseTableResult<B, S> {
/**
* # 审核
* @param bill 单据
*/
onAudit: (bill: B) => void
/**
* # 驳回
* @param bill 单据
*/
onReject: (bill: B) => void
/**
* # 完成单据
* @param bill
*/
onFinish: (bill: B) => void
}
5. 部分子类的实现
接下来,我们就可以按照具体的单据类型去实现子类了,比如下面的采购单:
*
### 1). 采购单实体类
```typescript
@Model('采购单')
export class PurchaseEntity extends AbstractBaseBillEntity<PurchaseDetailEntity> {
@Field('采购单号') declare billCode: string
@Table({
nowrap: true,
})
@Form({
textarea: true,
maxLength: 80,
requiredString: true,
})
@Field('采购事由') reason!: string
@Table({
width: 150,
suffixText: '元',
align: 'right',
forceShow: true,
})
@Form({
suffixText: '元',
})
@Field('总金额') totalPrice!: number
@Table({
width: 150,
suffixText: '元',
align: 'right',
forceShow: true,
})
@Form({
suffixText: '元',
})
@Field('实际金额') totalRealPrice!: number
@Table({
width: 100,
showColor: true,
orderNumber: -80,
forceShow: true,
})
@Dictionary(PurchaseStatusDictionary)
@Search()
@Field('采购状态') status!: PurchaseStatus
@Field('采购明细')
@Type(PurchaseDetailEntity, true) details: PurchaseDetailEntity[] = []
}
```
### 2). 采购单明细实体类
```typescript
@Model('采购明细')
export class PurchaseDetailEntity extends AbstractBaseBillDetailEntity {
/**
* # 物料
*/
@Form({
requiredNumber: true,
})
@Type(MaterialEntity) material!: MaterialEntity
/**
* # 供应商
*/
@Form({
requiredNumber: true,
})
@Type(SupplierEntity) supplier!: SupplierEntity
@Field('采购单价')
@Form({
requiredNumber: true,
number: true,
})
@Table({
width: 150,
suffixText: '元',
align: 'right',
orderNumber: -1,
})
@Type(Number) price!: number
@Field('采购数量')
@Form({
requiredNumber: true,
number: true,
})
@Table({
align: 'right',
width: 150,
orderNumber: -2,
})
@Type(Number) quantity!: number
@Field('已采购数量')
@Table({
align: 'right',
width: 150,
orderNumber: -3,
})
@Type(Number) finishQuantity!: number
}
```
### 3). 采购单状态枚举和字典
这里需要按照后端给定的状态码去定义好枚举:
```typescript
/**
* # 采购单状态枚举
*/
export enum PurchaseStatus {
/**
* # 审核中
*/
AUDITING = 1,
/**
* # 已驳回
*/
REJECTED = 2,
/**
* # 采购中
*/
PURCHASING = 3,
/**
* # 已完成
*/
DONE = 4,
/**
* # 已入库
*/
FINISHED = 5,
}
```
还需要将声明的状态枚举搭配一个字典,用来翻译和显示不同的状态。
```typescript
/**
* # 采购单状态枚举字典
*/
export const PurchaseStatusDictionary = AirDictionaryArray.create([
{ key: PurchaseStatus.AUDITING, color: AirColor.WARNING, label: '审核中' },
{ key: PurchaseStatus.REJECTED, color: AirColor.DANGER, label: '已驳回' },
{ key: PurchaseStatus.PURCHASING, color: AirColor.SUCCESS, label: '采购中' },
{ key: PurchaseStatus.DONE, color: AirColor.NORMAL, label: '已完成' },
{ key: PurchaseStatus.FINISHED, color: AirColor.NORMAL, label: '已入库' },
])
```
三、使用方式
1. 列表页面 list.vue
html
<template>
<APanel>
<AToolBar
:loading="isLoading"
:entity="PurchaseEntity"
:service="PurchaseService"
show-filter
@on-add="onAdd"
@on-search="onSearch"
/>
<ATable
v-loading="isLoading"
:data-list="response.list"
:entity="PurchaseEntity"
:select-list="selectList"
:disable-edit="(row: PurchaseEntity) => row.status !== PurchaseStatus.REJECTED"
hide-delete
show-detail
:ctrl-width="160"
@on-detail="onDetail"
@on-edit="onEdit"
@on-sort-change="onSortChanged"
@on-select="onSelected"
>
<template #customRow="row">
<AButton
link-button
tooltip="审核"
type="CONFIRM"
:disabled="(row.data as PurchaseEntity).status !== PurchaseStatus.AUDITING"
@click="onAudit(row.data)"
>
审核
</AButton>
<AButton
link-button
tooltip="驳回"
type="LOCK"
:disabled="(row.data as PurchaseEntity).status !== PurchaseStatus.AUDITING"
@click="onReject(row.data)"
>
驳回
</AButton>
</template>
</ATable>
<template #footerLeft>
<APage
:response="response"
@on-change="onPageChanged"
/>
</template>
</APanel>
</template>
<script lang="ts" setup>
// import codes here
const {
isLoading,
response,
selectList,
onSearch,
onAdd,
onEdit,
onPageChanged,
onSortChanged,
onSelected,
onDetail,
onReject,
onAudit,
} = useBillTable(PurchaseEntity, PurchaseService, {
editView: PurchaseEditor,
detailView: PurchaseDetail,
})
</script>
2. 新增、修改页面 edit.vue
html
<template>
<ADialog
:title="title + PurchaseEntity.getModelName()"
:form-ref="formRef"
:loading="isLoading"
width="80%"
height="80%"
@on-confirm="onSubmit()"
@on-cancel="onCancel()"
>
<el-form
ref="formRef"
:model="formData"
label-width="120px"
:rules="rules"
@submit.prevent
>
<AGroup
title="采购单"
:column="2"
>
<el-form-item
:label="PurchaseEntity.getFieldName('billCode')"
prop="billCode"
>
<AInput
v-model.billCode="formData.billCode"
:entity="PurchaseEntity"
/>
</el-form-item>
<el-form-item
style="width: 100%;"
:label="PurchaseEntity.getFieldName('reason')"
prop="reason"
>
<AInput
v-model.reason="formData.reason"
:entity="PurchaseEntity"
/>
</el-form-item>
</AGroup>
<AGroup title="采购明细">
<ATable
:entity="PurchaseDetailEntity"
:data-list="formData.details"
:field-list="PurchaseDetailEntity.getTableFieldConfigList().filter(item => !['createTime'].includes(item.key))"
hide-edit
hide-delete
>
<template #addButton>
<AButton
type="ADD"
@click="addDetail()"
>
添加{{ PurchaseEntity.getFieldName('details') }}
</AButton>
</template>
<template #customRow="row">
<AButton
type="DELETE"
danger
icon-button
@click="deleteDetail(row.index)"
/>
</template>
</ATable>
</AGroup>
</el-form>
</ADialog>
</template>
<script lang="ts" setup>
// import codes here...
const props = defineProps(airPropsParam(new PurchaseEntity()))
const {
title, formData, rules, formRef, isLoading,
onSubmit,
} = useAirEditor(props, PurchaseEntity, PurchaseService, {
afterGetDetail(detailData) {
return detailData
},
beforeSubmit(submitData) {
if (submitData.details.length === 0) {
AirNotification.warning('请添加明细后再提交')
return null
}
return submitData
},
})
async function addDetail() {
const detail: PurchaseDetailEntity = await AirDialog.show(PurchaseDetailEditor)
formData.value.details.push(detail)
}
async function deleteDetail(index: number) {
await AirConfirm.warning('是否删除选中行的采购明细?')
formData.value.details.splice(index, 1)
}
</script>
四、总结
按上述的设计,我们实现了一个简单的采购管理模块,包括采购单的列表、新增、修改、审核等功能。
来吧,你给上述的封装思路和代码打几分?在评论区留下你的看法吧~
基于装饰器的更多文章,可以阅读我的专栏 "用TypeScript写前端"。
1. 相关的源代码:
- Github: github.com/s-pms/SPMS-...
- Gitee: gitee.com/s-pms/SPMS-...
2. 使用的基础库:
- Github: github.com/HammCn/AirP...
- Gitee: gitee.com/air-power/A...