From 305ab154361c3a598e2299574224a35c305d03a7 Mon Sep 17 00:00:00 2001 From: zhangfei Date: Sat, 25 Apr 2026 22:04:36 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=A2=9E=E5=BC=BAPlaywright=20E2E?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=B9=E6=A1=88=20-=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=89=8B=E6=9C=AF=E8=AE=A1=E8=B4=B9/=E5=8C=BB=E7=94=9F?= =?UTF-8?q?=E7=AB=99/=E5=B9=B6=E5=8F=91=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增页面对象: SurgeryBillingPage, DoctorStationPage - 新增测试用例: 手术计费防重复(#437), 签发耗材验证(#443), 并发操作测试 - 增强登录测试: 多场景覆盖 - 完善测试数据工具: 支持多角色用户配置 - 清理冗余备份文件 --- ...NurseBillingAppService.java.backup_443_fix | 815 ------------------ ...seBillingAppService.java.backup_443_fix_v2 | 815 ------------------ ...NurseBillingAppService.java.backup_pre_fix | 815 ------------------ .../tests/e2e/pages/DoctorStationPage.ts | 32 + openhis-ui-vue3/tests/e2e/pages/LoginPage.ts | 30 +- .../tests/e2e/pages/SurgeryBillingPage.ts | 49 ++ .../tests/e2e/specs/bug-regression.spec.ts | 76 +- .../tests/e2e/specs/concurrency.spec.ts | 59 ++ .../tests/e2e/specs/doctor-station.spec.ts | 36 + openhis-ui-vue3/tests/e2e/specs/login.spec.ts | 15 +- .../tests/e2e/specs/surgery-billing.spec.ts | 43 + openhis-ui-vue3/tests/e2e/utils/test-data.ts | 42 +- openhis-ui-vue3/tests/playwright.config.ts | 37 +- 13 files changed, 361 insertions(+), 2503 deletions(-) delete mode 100644 openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix delete mode 100644 openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix_v2 delete mode 100644 openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_pre_fix create mode 100644 openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts create mode 100644 openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts create mode 100644 openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts create mode 100644 openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts create mode 100644 openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix deleted file mode 100644 index 3c8bf7a9..00000000 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix +++ /dev/null @@ -1,815 +0,0 @@ -package com.openhis.web.inhospitalnursestation.appservice.impl; - -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.core.common.core.domain.R; -import com.core.common.core.domain.model.LoginUser; -import com.core.common.exception.NonCaptureException; -import com.core.common.exception.ServiceException; -import com.core.common.utils.*; -import com.openhis.administration.domain.ChargeItem; -import com.openhis.administration.dto.CostDetailDto; -import com.openhis.administration.dto.CostDetailSearchParam; -import com.openhis.administration.service.IChargeItemService; -import com.openhis.administration.service.IOrganizationService; -import com.openhis.clinical.domain.Procedure; -import com.openhis.clinical.service.IProcedureService; -import com.openhis.common.constant.CommonConstants; -import com.openhis.common.constant.PromptMsgConstant; -import com.openhis.common.enums.*; -import com.openhis.common.utils.EnumUtils; -import com.openhis.common.utils.HisQueryUtils; -import com.openhis.web.doctorstation.dto.ActivityChildrenJsonParams; -import com.openhis.web.doctorstation.dto.AdviceSaveDto; -import com.openhis.web.doctorstation.utils.AdviceUtils; -import com.openhis.web.inhospitalnursestation.appservice.INurseBillingAppService; -import com.openhis.web.inhospitalnursestation.dto.CostDetailExcelOutDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceParam; -import com.openhis.web.inhospitalnursestation.mapper.NurseBillingAppMapper; -import com.openhis.web.regdoctorstation.dto.AdviceBatchOpParam; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveDto; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveParam; -import com.openhis.workflow.domain.ActivityDefinition; -import com.openhis.workflow.domain.DeviceRequest; -import com.openhis.workflow.domain.ServiceRequest; -import com.openhis.workflow.service.IActivityDefinitionService; -import com.openhis.workflow.service.IDeviceDispenseService; -import com.openhis.workflow.service.IDeviceRequestService; -import com.openhis.workflow.service.IServiceRequestService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; - -/** - * 住院护士站划价服务实现类 核心职责: 1. 临时耗材划价签发(含耗材请求生成、发放记录创建、费用项关联) 2. - * 诊疗活动划价签发(含服务请求生成、子项处理、费用项关联) 3. 划价参数校验(非空校验、库房校验等) 4. 已收费项目删除限制(避免误删已结算数据) 5. - * 关联数据一致性管理(请求表、执行表、费用表、发放表联动) - */ -@Service -public class NurseBillingAppService implements INurseBillingAppService { - - // ======================== 常量定义(关联表名/编码规则)======================== - /** - * 耗材请求服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_DEVICE = CommonConstants.TableName.WOR_DEVICE_REQUEST; - /** - * 诊疗活动服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_SERVICE = CommonConstants.TableName.WOR_SERVICE_REQUEST; - /** - * 耗材产品定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_DEVICE = CommonConstants.TableName.ADM_DEVICE_DEFINITION; - /** - * 诊疗活动定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_ACTIVITY = CommonConstants.TableName.WOR_ACTIVITY_DEFINITION; - /** - * 耗材发放表名(用于费用项关联发放记录) - */ - private static final String WOR_DEVICE_DISPENSE = CommonConstants.TableName.WOR_DEVICE_DISPENSE; - - /** - * 费用项业务编号前缀(统一编码规则,便于追溯) - */ - private static final String CHARGE_ITEM_BUS_NO_PREFIX = AssignSeqEnum.CHARGE_ITEM_NO.getPrefix(); - /** - * 耗材申请单号序列号长度(按日生成,每日从0001开始递增) - */ - private static final int DEVICE_RES_NO_SEQ_LENGTH = 4; - /** - * 医嘱签发编码序列号长度(全局唯一,用于关联同一批次划价的医嘱) - */ - private static final int ADVICE_SIGN_SEQ_LENGTH = 10; - - // ======================== 依赖注入(业务服务/工具类)======================== - /** - * 诊疗活动定义服务(查询诊疗活动基础信息及子项配置) - */ - @Resource - IActivityDefinitionService iActivityDefinitionService; - /** - * 医嘱处理工具类(含诊疗子项处理等通用逻辑) - */ - @Resource - private AdviceUtils adviceUtils; - /** - * 序列生成工具类(用于生成业务编号、签发编码等唯一标识) - */ - @Resource - private AssignSeqUtil assignSeqUtil; - /** - * 耗材请求服务(CRUD耗材请求记录WOR_DEVICE_REQUEST) - */ - @Resource - private IDeviceRequestService iDeviceRequestService; - /** - * 服务请求服务(CRUD诊疗活动请求记录WOR_SERVICE_REQUEST) - */ - @Resource - private IServiceRequestService iServiceRequestService; - /** - * 费用项服务(CRUD费用记录ADM_CHARGE_ITEM,含收费状态管理) - */ - @Resource - private IChargeItemService iChargeItemService; - /** - * 耗材发放服务(生成耗材发放记录WOR_DEVICE_DISPENSE,管理发放状态) - */ - @Resource - private IDeviceDispenseService iDeviceDispenseService; - /** - * 执行记录服务(生成医嘱执行记录CLIN_PROCEDURE,记录执行状态/人员/时间) - */ - @Resource - private IProcedureService iProcedureService; - @Resource - private NurseBillingAppMapper nurseBillingAppMapper; - @Resource - private IOrganizationService organizationService; - - // ======================== 核心业务方法(划价新增)======================== - /** - * 新增住院护士站划价(核心入口方法) 完整流程:参数初始化 → 入参校验 → 医嘱分类 → 生成全局签发编码 → 分类处理划价 → 返回结果 - * 事务特性:所有操作原子化,任一环节失败则整体回滚(避免数据不一致) - * - * @param regAdviceSaveParam 划价请求参数体 - * 包含:患者ID、就诊ID、住院科室ID、医嘱列表(耗材/诊疗活动)、操作时间等核心数据 - * @return R 划价结果响应 成功:返回操作成功提示(编码M00002) 失败:返回具体错误信息(如参数缺失、库房为空等) - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R addInNurseBilling(RegAdviceSaveParam regAdviceSaveParam) { - // 获取当前登录用户信息(包含护士ID、所属科室等) - LoginUser loginUser = SecurityUtils.getLoginUser(); - - // 1. 时间参数初始化:优先使用入参指定时间,无则默认当前系统时间 - Date curDate = new Date(); - Date startTime = regAdviceSaveParam.getStartTime() == null ? curDate : regAdviceSaveParam.getStartTime(); - Date authoredTime - = regAdviceSaveParam.getAuthoredTime() == null ? curDate : regAdviceSaveParam.getAuthoredTime(); - - // 2. 入参校验:校验不通过直接返回错误响应(避免后续无效处理) - R checkResult = checkNurseBillingParam(regAdviceSaveParam); - if (checkResult.getCode() != R.SUCCESS) { - return checkResult; - } - - // 3. 提取核心业务参数:住院科室ID(优先入参,无则取登录用户所属科室) - Long organizationId = regAdviceSaveParam.getOrganizationId() == null ? loginUser.getOrgId() - : regAdviceSaveParam.getOrganizationId(); - // 待处理医嘱列表(含耗材、诊疗活动两种类型) - List allAdviceList = regAdviceSaveParam.getRegAdviceSaveList(); - - // 4. 医嘱分类:按类型拆分为耗材类和诊疗活动类(分别执行不同划价逻辑) - List deviceAdviceList = filterDeviceAdvice(allAdviceList); - List activityAdviceList = filterActivityAdvice(allAdviceList); - - // 5. 生成全局唯一签发编码:关联同一批次划价的所有医嘱(便于追溯) - String signCode = assignSeqUtil.getSeq(AssignSeqEnum.ADVICE_SIGN.getPrefix(), ADVICE_SIGN_SEQ_LENGTH); - - // 6. 分类处理划价:耗材类、诊疗活动类分别执行对应逻辑 - if (!deviceAdviceList.isEmpty()) { - handleAddDeviceBilling(deviceAdviceList, signCode, organizationId, curDate); - } - if (!activityAdviceList.isEmpty()) { - handleAddActivityBilling(activityAdviceList, signCode, organizationId, curDate, startTime, authoredTime); - } - - // 7. 划价成功:返回统一成功提示(通过国际化工具类拼接提示信息) - return R.ok(null, MessageUtils.createMessage(PromptMsgConstant.Common.M00002, new Object[]{"住院护士划价"})); - } - - /** - * 删除住院划价记录(核心实现) 流程:参数校验 → 分类筛选医嘱 → 已收费状态校验 → 级联删除关联数据 → 返回结果 - * 事务特性:所有删除操作原子化,任一环节失败整体回滚 - * - * @return R 删除结果响应 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R deleteInNurseBilling(List paramList) { - - // TODO 撤销前校验 - // 诊疗ids - List activityRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - activityRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.ACTIVITY.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - // 耗材ids - List deviceRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - deviceRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.DEVICE.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - this.handleDel(deviceRequestIds, SERVICE_TABLE_DEVICE); - this.handleDel(activityRequestIds, SERVICE_TABLE_SERVICE); - return R.ok("删除成功"); - } - - /** - * 住院患者医嘱查询 - * - * @param inpatientAdviceParam 查询条件 - * @param pageNo 当前页码 - * @param pageSize 查询条数 - * @return 住院患者医 - */ - @Override - public R getInNurseBillingPage(InpatientAdviceParam inpatientAdviceParam, Integer pageNo, Integer pageSize, - LocalDateTime startTime, LocalDateTime endTime) { - // 初始化查询参数 - String encounterIds = inpatientAdviceParam.getEncounterIds(); - inpatientAdviceParam.setEncounterIds(null); - Integer exeStatus = inpatientAdviceParam.getExeStatus(); - inpatientAdviceParam.setExeStatus(null); - // 构建查询条件 - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(inpatientAdviceParam, null, null, null); - - // 手动拼接住院患者id条件 - if (encounterIds != null && !encounterIds.isEmpty()) { - List encounterIdList - = Arrays.stream(encounterIds.split(CommonConstants.Common.COMMA)).map(Long::parseLong).toList(); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIdList); - } - // 患者医嘱分页列表 - Page inpatientAdvicePage - = nurseBillingAppMapper.getInNurseBillingPage(new Page<>(pageNo, pageSize), queryWrapper, - CommonConstants.TableName.WOR_DEVICE_REQUEST, CommonConstants.TableName.WOR_SERVICE_REQUEST, - RequestStatus.DRAFT.getValue(), EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode(), - ChargeItemStatus.BILLABLE.getValue(), ChargeItemStatus.BILLED.getValue(), - ChargeItemStatus.REFUNDED.getValue(), EncounterClass.IMP.getValue(), - GenerateSource.NURSE_PRICING.getValue(), startTime, endTime); - inpatientAdvicePage.getRecords().forEach(e -> { - // 医嘱类型 - e.setTherapyEnum_enumText(EnumUtils.getInfoByValue(TherapyTimeType.class, e.getTherapyEnum())); - // 请求状态 - e.setRequestStatus_enumText(EnumUtils.getInfoByValue(RequestStatus.class, e.getRequestStatus())); - // 性别枚举 - e.setGenderEnum_enumText(EnumUtils.getInfoByValue(AdministrativeGender.class, e.getGenderEnum())); - // 计算年龄 - if (e.getBirthDate() != null) { - e.setAge(AgeCalculatorUtil.getAge(e.getBirthDate())); - } - }); - return R.ok(inpatientAdvicePage); - } - - // ======================== 入参校验方法 ======================== - /** - * 划价入参校验(通用校验逻辑) 校验规则:1. 参数非空 2. 住院科室ID非空 3. 医嘱列表非空 4. 隐含库房校验(耗材类单独校验) - * 校验失败直接返回错误响应,不进入后续业务逻辑 - * - * @param regAdviceSaveParam 划价请求参数体 - * @return R 校验结果:成功返回R.ok(),失败返回R.fail(错误信息) - */ - private R checkNurseBillingParam(RegAdviceSaveParam regAdviceSaveParam) { - // 1. 整体参数非空校验:请求体为null直接返回失败 - if (regAdviceSaveParam == null) { - return R.fail("划价请求失败:未获取到有效请求数据,请重新提交"); - } - - // 2. 核心字段非空校验:患者住院科室ID不能为空 - if (regAdviceSaveParam.getOrganizationId() == null) { - return R.fail("划价请求失败:患者住院科室信息缺失,请确认患者科室后重试"); - } - - // 3. 医嘱列表非空校验:必须选择至少一个待划价项目 - List adviceList = regAdviceSaveParam.getRegAdviceSaveList(); - if (adviceList == null || adviceList.isEmpty()) { - return R.fail("划价请求失败:未选择任何待划价项目,请添加医嘱后提交"); - } - - // 4. 库存校验:临时注释(当前需求:划价不校验库存,实际发放时校验) - // 若后续需要恢复库存校验,可解除以下注释 - /* - List needCheckInventoryList = adviceList.stream() - .filter(advice -> TherapyTimeType.TEMPORARY.getValue().equals(advice.getTherapyEnum()) - && !DbOpType.DELETE.getCode().equals(advice.getDbOpType()) - && !ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - - String inventoryTip = adviceUtils.checkInventory(new ArrayList<>(needCheckInventoryList)); - if (inventoryTip != null) { - return R.fail("划价失败:" + inventoryTip + ",请联系库房确认库存或调整申请数量"); - } - */ - // 所有校验通过 - return R.ok(); - } - // ======================== 医嘱分类工具方法 ======================== - - /** - * 筛选耗材类医嘱 筛选规则:按医嘱类型枚举(ItemType.DEVICE)匹配,仅保留耗材相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的耗材类医嘱列表 - */ - private List filterDeviceAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.DEVICE.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - /** - * 筛选诊疗活动类医嘱 筛选规则:按医嘱类型枚举(ItemType.ACTIVITY)匹配,仅保留诊疗活动相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的诊疗活动类医嘱列表 - */ - private List filterActivityAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - // ======================== 耗材类划价处理 ======================== - /** - * 处理耗材类医嘱划价(核心子流程) 流程:筛选临时耗材 → 校验发放库房 → 生成耗材请求 → 生成执行记录 → 生成耗材发放 → 生成费用项 - * 特殊规则:临时耗材划价不校验库存、不预减库存(仅记录发放需求,实际发放时扣库) - * - * @param deviceAdviceList 耗材类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间(用于填充创建时间/执行时间) - */ - private void handleAddDeviceBilling(List deviceAdviceList, String signCode, Long organizationId, - Date curDate) { - // 1. 筛选临时类型耗材:仅处理临时医嘱(TherapyTimeType.TEMPORARY),且请求ID不为空 - List tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList()); - - // 2. 校验发放库房:必须指定耗材发放库房(locationId),否则抛出业务异常 - if (tempDeviceList.stream().anyMatch(t -> t.getLocationId() == null)) { - throw new ServiceException("耗材划价失败:发放库房为空,请重新选择"); - } - - // 3. 循环处理每个临时耗材医嘱(逐条生成关联数据) - for (AdviceSaveDto adviceDto : tempDeviceList) { - // 3.1 生成耗材请求记录(WOR_DEVICE_REQUEST):状态设为激活,来源标记为护士划价 - DeviceRequest deviceRequest = buildDeviceRequest(adviceDto, curDate); - iDeviceRequestService.saveOrUpdate(deviceRequest); - - // 3.2 生成医嘱执行记录(CLIN_PROCEDURE):记录执行状态、执行科室、执行时间等 - Long procedureId = this.addProcedureRecord(deviceRequest.getEncounterId(), // 就诊ID - deviceRequest.getPatientId(), // 患者ID - deviceRequest.getId(), // 耗材请求ID(关联执行记录与请求) - SERVICE_TABLE_DEVICE, // 关联表名(耗材请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - deviceRequest.getLocationId(), // 执行位置(发放库房) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3.3 生成耗材发放记录(WOR_DEVICE_DISPENSE):状态设为待发放,关联执行记录 - Long dispenseId = iDeviceDispenseService.generateDeviceDispense(deviceRequest, procedureId, - deviceRequest.getLocationId(), curDate); - - // 3.4 生成费用项记录(ADM_CHARGE_ITEM):关联耗材请求、发放记录、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, deviceRequest.getBusNo(), deviceRequest.getId(), - SERVICE_TABLE_DEVICE, PRODUCT_TABLE_DEVICE, curDate, procedureId, dispenseId, WOR_DEVICE_DISPENSE); - iChargeItemService.saveOrUpdate(chargeItem); - } - } - - // ======================== 诊疗活动类划价处理 ======================== - /** - * 处理诊疗活动类医嘱划价(核心子流程) 流程:生成服务请求 → 生成执行记录 → 生成费用项 → 处理诊疗子项(如有) - * 特殊规则:诊疗活动可能包含子项(如套餐类活动),需递归处理子项划价 - * - * @param activityAdviceList 诊疗活动类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - */ - private void handleAddActivityBilling(List activityAdviceList, String signCode, - Long organizationId, Date curDate, Date startTime, Date authoredTime) { - // 循环处理每个诊疗活动医嘱 - for (AdviceSaveDto adviceDto : activityAdviceList) { - // 1. 生成诊疗活动请求记录(WOR_SERVICE_REQUEST):状态设为激活,来源标记为护士划价 - ServiceRequest serviceRequest - = this.buildActivityRequest(adviceDto, signCode, organizationId, curDate, startTime, authoredTime); - - // 2. 生成医嘱执行记录(CLIN_PROCEDURE):关联服务请求,记录执行状态 - Long procedureId = this.addProcedureRecord(serviceRequest.getEncounterId(), // 就诊ID - serviceRequest.getPatientId(), // 患者ID - serviceRequest.getId(), // 服务请求ID(关联执行记录与请求) - SERVICE_TABLE_SERVICE, // 关联表名(服务请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - serviceRequest.getLocationId(), // 执行位置(执行科室) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3. 生成费用项记录(ADM_CHARGE_ITEM):关联服务请求、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, serviceRequest.getBusNo(), serviceRequest.getId(), - SERVICE_TABLE_SERVICE, PRODUCT_TABLE_ACTIVITY, curDate, procedureId, null, null); - iChargeItemService.saveOrUpdate(chargeItem); - - // 4. 处理诊疗子项(如活动包含子项配置,递归生成子项的划价数据) - this.buidActivityRequestChild(serviceRequest, chargeItem.getId(), adviceDto, organizationId); - } - } - - // ======================== 执行记录工具方法 ======================== - /** - * 生成医嘱执行记录(通用方法,支持耗材/诊疗活动两种类型) 功能:调用执行记录服务,创建CLIN_PROCEDURE表记录,关联请求数据与执行信息 - * - * @param encounterId 就诊ID(关联患者就诊记录) - * @param patientId 患者ID(关联患者基本信息) - * @param requestId 请求ID(关联耗材/服务请求主记录) - * @param requestTable 关联表名(标记是耗材请求还是服务请求) - * @param eventStatus 执行状态(如已完成、待执行等) - * @param procedureCategory 执行种类(如住院护士医嘱、医生医嘱等) - * @param locationId 执行位置(执行科室/发放库房ID) - * @param exeDate 执行时间 - * @param groupId 组号(批量执行时用于关联同一组医嘱) - * @param refundId 取消执行ID(取消执行时关联原执行记录) - * @return Long 执行记录ID(CLIN_PROCEDURE表主键) - */ - private Long addProcedureRecord(Long encounterId, Long patientId, Long requestId, String requestTable, - EventStatus eventStatus, ProcedureCategory procedureCategory, Long locationId, Date exeDate, Long groupId, - Long refundId) { - // 调用执行记录服务创建记录,返回执行记录主键ID - return iProcedureService.addProcedureRecord(encounterId, patientId, requestId, requestTable, eventStatus, - procedureCategory, locationId, exeDate, exeDate, groupId, refundId); - } - - // ======================== 实体构建工具方法(请求/费用项)======================== - /** - * 构建耗材请求实体(DeviceRequest) 功能:将医嘱DTO参数映射为耗材请求实体,填充默认配置(状态、业务编号、来源等) - * - * @param adviceDto 耗材医嘱DTO(含请求参数:数量、单位、耗材ID等) - * @param curDate 当前操作时间 - * @return DeviceRequest 构建完成的耗材请求实体(可直接保存) - */ - private DeviceRequest buildDeviceRequest(AdviceSaveDto adviceDto, Date curDate) { - LoginUser loginUser = SecurityUtils.getLoginUser(); - DeviceRequest deviceRequest = new DeviceRequest(); - - // 基础配置:主键(新增为null,修改为已有ID)、状态、业务编号 - deviceRequest.setId(adviceDto.getRequestId()); - deviceRequest.setTenantId(loginUser.getTenantId()); // 显式设置租户ID - // 业务编号:按日生成,前缀+4位序列号(确保每日唯一) - deviceRequest - .setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.DEVICE_RES_NO.getPrefix(), DEVICE_RES_NO_SEQ_LENGTH)); - // deviceRequest.setPrescriptionNo(null);//处方号 - // deviceRequest.setActivityId(null);//诊疗ID - // deviceRequest.setPackageId(null);//组套id - // deviceRequest.setIntentCode(null); // 请求意图 - deviceRequest.setCategoryEnum(adviceDto.getCategoryEnum()); // 请求类型(枚举,如常规请求) - // deviceRequest.setPerformFlag(null);//优先级 - // deviceRequest.setPriorityEnum(null);//是否停止执行 - // deviceRequest.setGroupNo(null);//分组编号 - // deviceRequest.setDeviceTypeCode(null);//器材类型 - deviceRequest.setQuantity(adviceDto.getQuantity()); // 耗材请求数量 - deviceRequest.setUnitCode(adviceDto.getUnitCode()); // 单位编码(如"个"、"盒") - deviceRequest.setLotNumber(adviceDto.getLotNumber()); // 产品批号(可选,耗材批次管理) - deviceRequest.setStatusEnum(RequestStatus.COMPLETED.getValue()); // 状态:已完成(划价即生效) - deviceRequest.setDeviceDefId(adviceDto.getAdviceDefinitionId()); // 耗材定义ID(关联ADM_DEVICE_DEFINITION) - // deviceRequest.setDeviceSpecifications(null)//器材规格 - deviceRequest.setRequesterId( - adviceDto.getPractitionerId() == null ? loginUser.getPractitionerId() : adviceDto.getPractitionerId());// 请求发起人 - deviceRequest - .setOrgId(adviceDto.getFounderOrgId() == null ? loginUser.getOrgId() : adviceDto.getFounderOrgId());// 请求发起的科室 - deviceRequest.setLocationId(adviceDto.getLocationId());// 默认器材房 - deviceRequest.setPerformLocation(adviceDto.getLocationId()); // 发放库房ID(关联耗材发放位置) - deviceRequest.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联患者本次住院记录) - deviceRequest.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者信息) - // deviceRequest.setRateCode(null);//用药频次 - // deviceRequest.setUseTime();//预计使用时间 - // deviceRequest.setUseStartTime();//预计使用时间 - // deviceRequest.setUseEndTime();//预计使用结束时间 - // deviceRequest.setUseTiming();//预计使用周期时间 - deviceRequest.setReqAuthoredTime(curDate); // 请求开始时间(当前操作时间) - // deviceRequest.setPerformerEnum();//执行人类型 - // deviceRequest.setPerformerId();//执行人 - // deviceRequest.setPerformOrgId();//执行科室 - // deviceRequest.setConditionIdJson(); // 相关诊断 - // deviceRequest.setObservationIdJson();//相关观测 - // deviceRequest.setAsNeedFlag();//是否可以按需给出 - // deviceRequest.setAsNeedReason();//按需使用原因 - // deviceRequest.setContractCode();//合同id - // deviceRequest.setSupportInfo();//支持用药信息 - // deviceRequest.setRequesterId();//退药id - deviceRequest.setContentJson(adviceDto.getContentJson());// 请求内容json - // deviceRequest.setYbClassEnum();//类别医保编码 - // deviceRequest.setTraceNo()//追溯码 - deviceRequest.setConditionId(adviceDto.getConditionId());// 诊断id - deviceRequest.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId());// 就诊诊断id - // deviceRequest.setBasedOnTable();//请求基于什么 - deviceRequest.setBasedOnId(adviceDto.getBasedOnId());// 请求基于什么的ID - deviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - return deviceRequest; - } - - /** - * 构建诊疗活动请求实体(ServiceRequest) 功能:将诊疗活动医嘱DTO映射为服务请求实体,填充默认配置和业务参数 - * - * @param activityDto 诊疗活动医嘱DTO(含活动ID、数量、执行科室等) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - * @return ServiceRequest 构建完成的诊疗活动请求实体(已保存到数据库) - */ - private ServiceRequest buildActivityRequest(AdviceSaveDto activityDto, String signCode, Long organizationId, - Date curDate, Date startTime, Date authoredTime) { - ServiceRequest serviceRequest = new ServiceRequest(); - - // 基础配置:主键、状态、业务编号、签发编码 - serviceRequest.setId(activityDto.getRequestId()); // 主键ID(新增为null,修改为已有ID) - serviceRequest.setStatusEnum(RequestStatus.ACTIVE.getValue()); // 状态:激活(划价即生效) - serviceRequest.setTenantId(SecurityUtils.getLoginUser().getTenantId()); // 显式设置租户ID - serviceRequest.setAuthoredTime(authoredTime); // 医嘱签发时间 - serviceRequest.setSignCode(signCode); // 全局签发编码(关联同一批次划价的医嘱) - serviceRequest.setOccurrenceStartTime(startTime); // 医嘱开始执行时间 - // 业务编号:按日生成,前缀+4位序列号(每日唯一) - serviceRequest.setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.SERVICE_RES_NO.getPrefix(), 4)); - serviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:从DTO提取核心参数 - serviceRequest.setQuantity(activityDto.getQuantity()); // 请求数量(如诊疗活动执行次数) - serviceRequest.setUnitCode(activityDto.getUnitCode()); // 单位编码(如"次"、"疗程") - serviceRequest.setCategoryEnum(activityDto.getCategoryEnum()); // 请求类型(枚举,如常规诊疗) - serviceRequest.setTherapyEnum(activityDto.getTherapyEnum()); // 治疗类型(如临时、长期,前端传入) - serviceRequest.setActivityId(activityDto.getAdviceDefinitionId()); // 诊疗活动定义ID(关联WOR_ACTIVITY_DEFINITION) - serviceRequest.setPatientId(activityDto.getPatientId()); // 患者ID(关联患者信息) - serviceRequest.setRequesterId(activityDto.getPractitionerId()); // 开方医生ID(诊疗活动的开单医生) - serviceRequest.setEncounterId(activityDto.getEncounterId()); // 就诊ID(关联本次住院记录) - serviceRequest.setAuthoredTime(curDate); // 请求签发时间(当前操作时间) - serviceRequest.setOrgId(activityDto.getPositionId()); // 执行科室ID(诊疗活动的执行科室) - serviceRequest.setContentJson(activityDto.getContentJson()); // 扩展信息JSON(额外配置) - serviceRequest.setYbClassEnum(activityDto.getYbClassEnum()); // 医保类别编码(关联医保报销) - serviceRequest.setConditionId(activityDto.getConditionId()); // 诊断ID(关联患者诊断) - serviceRequest.setEncounterDiagnosisId(activityDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - - // 保存诊疗活动请求记录到数据库 - iServiceRequestService.saveOrUpdate(serviceRequest); - return serviceRequest; - } - - /** - * 处理诊疗活动子项划价(如诊疗活动是套餐,包含多个子项) 功能:解析诊疗活动的子项配置JSON,递归生成子项的划价数据(请求、执行记录、费用项) - * - * @param serviceRequest 父诊疗活动请求实体(关联子项) - * @param chargeItemId 父诊疗活动的费用项ID(关联子项费用) - * @param activityDto 诊疗活动医嘱DTO(含子项配置JSON) - * @param organizationId 住院科室ID - */ - private void buidActivityRequestChild(ServiceRequest serviceRequest, Long chargeItemId, AdviceSaveDto activityDto, - Long organizationId) { - // 1. 查询诊疗活动定义信息(获取子项配置JSON) - ActivityDefinition activityDefinition = iActivityDefinitionService.getById(activityDto.getAdviceDefinitionId()); - String childrenJson = activityDefinition.getChildrenJson(); - - // 2. 若存在子项配置,构建子项参数并调用工具类处理 - if (childrenJson != null) { - ActivityChildrenJsonParams activityChildrenJsonParams = new ActivityChildrenJsonParams(); - // 子项治疗类型:默认临时(与父项一致) - activityChildrenJsonParams.setTherapyEnum(TherapyTimeType.TEMPORARY.getValue()); - activityChildrenJsonParams.setPatientId(serviceRequest.getPatientId()); // 患者ID(继承父项) - activityChildrenJsonParams.setEncounterId(serviceRequest.getEncounterId()); // 就诊ID(继承父项) - activityChildrenJsonParams.setAccountId(activityDto.getAccountId()); // 患者账户ID(关联费用结算) - activityChildrenJsonParams.setChargeItemId(chargeItemId); // 父费用项ID(关联子项费用) - activityChildrenJsonParams.setParentId(serviceRequest.getId()); // 父诊疗请求ID(关联子项与父项) - activityChildrenJsonParams.setEncounterDiagnosisId(serviceRequest.getEncounterDiagnosisId()); - // 调用工具类处理子项:递归生成子项的请求、执行记录、费用项 - adviceUtils.handleActivityChild(childrenJson, organizationId, activityChildrenJsonParams); - } - } - - /** - * 构建费用项实体(ChargeItem) 功能:关联请求记录(耗材/诊疗)与费用信息,生成待结算的费用项(ADM_CHARGE_ITEM表) - * 通用性:支持耗材、诊疗活动两种类型的费用项构建 - * - * @param adviceDto 医嘱DTO(含费用相关参数:单价、总价等) - * @param requestBusNo 关联请求的业务编号(耗材/服务请求的busNo) - * @param requestId 关联请求的ID(耗材/服务请求的主键) - * @param serviceTable 关联服务表名(标记是耗材请求还是服务请求) - * @param productTable 关联产品表名(标记产品类型:耗材/诊疗活动) - * @param curDate 当前操作时间(费用开立时间) - * @param procedureId 执行记录ID(关联CLIN_PROCEDURE表) - * @param dispenseId 发放记录ID(关联耗材发放表,诊疗活动为null) - * @param dispenseTable 发放表名(耗材为WOR_DEVICE_DISPENSE,诊疗活动为null) - * @return ChargeItem 构建完成的费用项实体(可直接保存) - */ - private ChargeItem buildChargeItem(AdviceSaveDto adviceDto, String requestBusNo, Long requestId, - String serviceTable, String productTable, Date curDate, Long procedureId, Long dispenseId, - String dispenseTable) { - ChargeItem chargeItem = new ChargeItem(); - // TODO1、是否需跨批次 2、金额 精确到小数点后6位 、数量 计算 - // 基础配置:主键、状态、业务编号 - chargeItem.setId(adviceDto.getChargeItemId()); // 费用项ID(新增为null,修改为已有ID) - chargeItem.setStatusEnum(ChargeItemStatus.BILLABLE.getValue()); // 状态:待结算(未收费) - // 业务编号:费用项前缀+关联请求的业务编号(确保与请求一一对应,便于追溯) - chargeItem.setBusNo(CHARGE_ITEM_BUS_NO_PREFIX.concat(requestBusNo)); - chargeItem.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:患者、就诊、定价相关信息 - chargeItem.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者) - chargeItem.setContextEnum(adviceDto.getAdviceType()); // 费用类型(与医嘱类型一致:耗材/诊疗) - chargeItem.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联本次住院) - chargeItem.setDefinitionId(adviceDto.getDefinitionId()); // 费用定价ID(关联定价规则) - chargeItem.setDefDetailId(adviceDto.getDefinitionDetailId()); // 定价子表ID(明细定价,如规格对应的单价) - chargeItem.setEntererId(adviceDto.getPractitionerId()); // 开立人ID(开方医生/护士) - chargeItem.setRequestingOrgId(SecurityUtils.getLoginUser().getOrgId()); // 开立科室ID(当前登录用户科室) - chargeItem.setEnteredDate(curDate); // 开立时间(当前操作时间) - chargeItem.setServiceTable(serviceTable); // 关联服务表名(标记数据源) - chargeItem.setServiceId(requestId); // 关联服务ID(耗材/服务请求的主键) - chargeItem.setProductTable(productTable); // 关联产品表名(标记产品类型) - chargeItem.setProductId(adviceDto.getAdviceDefinitionId()); // 产品ID(耗材/诊疗活动定义ID) - chargeItem.setAccountId(adviceDto.getAccountId()); // 患者账户ID(关联费用结算账户) - chargeItem.setConditionId(adviceDto.getConditionId()); // 诊断ID(关联患者诊断) - chargeItem.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - chargeItem.setProductId(procedureId); // 执行记录ID(关联执行记录) - chargeItem.setDispenseId(dispenseId); // 发放记录ID(耗材专属,诊疗活动为null) - chargeItem.setDispenseTable(dispenseTable); // 发放表名(耗材专属,诊疗活动为null) - - // 费用核心属性:数量、单位、单价、总价(与医嘱保持一致) - chargeItem.setQuantityValue(adviceDto.getQuantity()); // 数量(与请求数量一致) - chargeItem.setQuantityUnit(adviceDto.getUnitCode()); // 单位(与请求单位一致) - chargeItem.setUnitPrice(adviceDto.getUnitPrice()); // 单价(从DTO传入,已定价) - chargeItem.setTotalPrice(adviceDto.getTotalPrice()); // 总价(数量×单价,DTO已计算,避免重复计算) - - return chargeItem; - } - - // ======================== 耗材删除相关方法 ======================== - /** - * 处理耗材删除逻辑(级联删除关联数据) 核心规则:已收费的耗材项目不允许删除,未收费项目级联删除关联数据 级联删除顺序:耗材请求表 → 耗材发放表 - * → 费用项表 - * - * @param requestIds 待删除的耗材医嘱列表(可为null) - * @param serviceTable 关联服务表名(此处为耗材请求表) - */ - private void handleDel(List requestIds, String serviceTable) { - // 空列表直接返回,避免无效循环 - if (requestIds == null || requestIds.isEmpty()) { - return; - } - // 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除 - checkDeletedDeviceChargeStatus(requestIds); - // 软删除执行记录 - List procedureList = iProcedureService.getProcedureRecords(requestIds, serviceTable); - List procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象 - .map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录(按需添加) - .collect(Collectors.toList()); - // 批量删除执行记录 - iProcedureService.removeBatchByIds(procedureIds); - - // 不想循环删除 - for (Long requestId : requestIds) { - if (serviceTable.equals(SERVICE_TABLE_DEVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iDeviceRequestService.removeById(requestId); - // 删除关联的耗材发放记录(WOR_DEVICE_DISPENSE) - iDeviceDispenseService.deleteDeviceDispense(requestId); - } - if (serviceTable.equals(SERVICE_TABLE_SERVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iServiceRequestService.removeById(requestId); - } - // 删除关联的费用项记录(ADM_CHARGE_ITEM,按服务表+服务ID关联) - iChargeItemService.deleteByServiceTableAndId(serviceTable, requestId); - } - - } - - /** - * 校验待删除耗材的费用状态(防止删除已收费项目) 逻辑:查询待删除耗材对应的费用项,若存在"已收费"状态则抛出业务异常 - * - * @param requestIds 待删除的耗材请求ID列表 - */ - private void checkDeletedDeviceChargeStatus(List requestIds) { - if (requestIds.isEmpty()) { - return; - } - - // 1. 查询待删除耗材对应的费用项列表 - List chargeItemList = iChargeItemService.getChargeItemInfoByReqId(requestIds); - if (chargeItemList == null || chargeItemList.isEmpty()) { - return; // 无关联费用项,允许删除 - } - - // 2. 校验是否存在已收费项(状态为BILLED) - boolean hasBilledItem - = chargeItemList.stream().anyMatch(ci -> ChargeItemStatus.BILLED.getValue().equals(ci.getStatusEnum())); - if (hasBilledItem) { - throw new ServiceException("删除失败:部分项目已完成收费(结算),不支持直接删除,请联系收费人员处理后重试"); - } - } - - // ======================== 未实现接口方法(保留签名,待扩展)======================== - /** - * 新增订单划价(待实现) 功能:针对订单类型的划价(如患者自主购买耗材/服务),生成对应的费用项 - * - * @return R 划价结果响应 - */ - @Override - public R addOrderBilling() { - // 待实现:需接收订单相关参数,构建订单划价逻辑(类似耗材/诊疗划价,差异在于来源类型) - return null; - } - - /** - * 删除订单划价(待实现) 功能:删除未收费的订单划价记录,级联删除关联数据 - * - * @return R 删除结果响应 - */ - @Override - public R deleteOrderBilling() { - // 待实现:类似住院划价删除逻辑,需校验订单划价的费用状态 - return null; - } - - /** - * 修改订单划价(待实现) 功能:支持修改未收费订单划价的数量、单价等信息,同步更新费用项 - * - * @return R 修改结果响应 - */ - @Override - public R updateOrderBilling() { - // 待实现:需接收修改后的订单划价参数,更新请求记录和费用项 - return null; - } - - /** - * 费用明细查询 - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @return 住院患者费用明细 - */ - @Override - public R> getCostDetails(CostDetailSearchParam costDetailSearchParam, - HttpServletRequest request) { - List encounterIds = costDetailSearchParam.getEncounterIds(); - if (encounterIds == null || encounterIds.isEmpty()) { - return R.fail("就诊ID不能为空"); - } - costDetailSearchParam.setEncounterIds(null); - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(costDetailSearchParam, null, null, request); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIds); - List list = iChargeItemService.getCostDetails(queryWrapper, ChargeItemStatus.BILLABLE.getValue(), - ChargeItemStatus.BILLED.getValue(), ChargeItemStatus.REFUNDED.getValue(), - EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode()); - return R.ok(list); - } - - /** - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @param response response响应 - */ - @Override - public void makeExcelFile(CostDetailSearchParam costDetailSearchParam, HttpServletRequest request, - HttpServletResponse response) { - R> costDetails = getCostDetails(costDetailSearchParam, request); - if (costDetails.getData() != null) { - List dataList = costDetails.getData(); - // 设置执行科室 - dataList.forEach(costDetailDto -> { - Long orgId = costDetailDto.getOrgId(); - costDetailDto.setOrgName(organizationService.getById(orgId).getName()); - }); - // 根据EncounterId分组 - Map> map - = dataList.stream().collect(Collectors.groupingBy(CostDetailDto::getEncounterId)); - map.forEach((key, value) -> { - // 新加一条小计 - value.add(new CostDetailDto().setEncounterId(key).setChargeName("小计").setTotalPrice( - value.stream().map(CostDetailDto::getTotalPrice).reduce(BigDecimal.ZERO, BigDecimal::add))); - }); - // 收集要导出的数据 - List excelOutList - = map.entrySet().stream().map(entry -> new CostDetailExcelOutDto(entry.getKey(), entry.getValue(), - entry.getValue().get(0).getPatientName())).toList(); - try { - // 住院记账-费用明细 导出 - NewExcelUtil util = new NewExcelUtil<>(CostDetailExcelOutDto.class); - util.exportExcel(response, excelOutList, CommonConstants.SheetName.COST_DETAILS); - } catch (Exception e) { - throw new NonCaptureException(StringUtils.format("导出excel失败"), e); - } - } - } - -} diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix_v2 b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix_v2 deleted file mode 100644 index 3c8bf7a9..00000000 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_443_fix_v2 +++ /dev/null @@ -1,815 +0,0 @@ -package com.openhis.web.inhospitalnursestation.appservice.impl; - -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.core.common.core.domain.R; -import com.core.common.core.domain.model.LoginUser; -import com.core.common.exception.NonCaptureException; -import com.core.common.exception.ServiceException; -import com.core.common.utils.*; -import com.openhis.administration.domain.ChargeItem; -import com.openhis.administration.dto.CostDetailDto; -import com.openhis.administration.dto.CostDetailSearchParam; -import com.openhis.administration.service.IChargeItemService; -import com.openhis.administration.service.IOrganizationService; -import com.openhis.clinical.domain.Procedure; -import com.openhis.clinical.service.IProcedureService; -import com.openhis.common.constant.CommonConstants; -import com.openhis.common.constant.PromptMsgConstant; -import com.openhis.common.enums.*; -import com.openhis.common.utils.EnumUtils; -import com.openhis.common.utils.HisQueryUtils; -import com.openhis.web.doctorstation.dto.ActivityChildrenJsonParams; -import com.openhis.web.doctorstation.dto.AdviceSaveDto; -import com.openhis.web.doctorstation.utils.AdviceUtils; -import com.openhis.web.inhospitalnursestation.appservice.INurseBillingAppService; -import com.openhis.web.inhospitalnursestation.dto.CostDetailExcelOutDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceParam; -import com.openhis.web.inhospitalnursestation.mapper.NurseBillingAppMapper; -import com.openhis.web.regdoctorstation.dto.AdviceBatchOpParam; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveDto; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveParam; -import com.openhis.workflow.domain.ActivityDefinition; -import com.openhis.workflow.domain.DeviceRequest; -import com.openhis.workflow.domain.ServiceRequest; -import com.openhis.workflow.service.IActivityDefinitionService; -import com.openhis.workflow.service.IDeviceDispenseService; -import com.openhis.workflow.service.IDeviceRequestService; -import com.openhis.workflow.service.IServiceRequestService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; - -/** - * 住院护士站划价服务实现类 核心职责: 1. 临时耗材划价签发(含耗材请求生成、发放记录创建、费用项关联) 2. - * 诊疗活动划价签发(含服务请求生成、子项处理、费用项关联) 3. 划价参数校验(非空校验、库房校验等) 4. 已收费项目删除限制(避免误删已结算数据) 5. - * 关联数据一致性管理(请求表、执行表、费用表、发放表联动) - */ -@Service -public class NurseBillingAppService implements INurseBillingAppService { - - // ======================== 常量定义(关联表名/编码规则)======================== - /** - * 耗材请求服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_DEVICE = CommonConstants.TableName.WOR_DEVICE_REQUEST; - /** - * 诊疗活动服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_SERVICE = CommonConstants.TableName.WOR_SERVICE_REQUEST; - /** - * 耗材产品定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_DEVICE = CommonConstants.TableName.ADM_DEVICE_DEFINITION; - /** - * 诊疗活动定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_ACTIVITY = CommonConstants.TableName.WOR_ACTIVITY_DEFINITION; - /** - * 耗材发放表名(用于费用项关联发放记录) - */ - private static final String WOR_DEVICE_DISPENSE = CommonConstants.TableName.WOR_DEVICE_DISPENSE; - - /** - * 费用项业务编号前缀(统一编码规则,便于追溯) - */ - private static final String CHARGE_ITEM_BUS_NO_PREFIX = AssignSeqEnum.CHARGE_ITEM_NO.getPrefix(); - /** - * 耗材申请单号序列号长度(按日生成,每日从0001开始递增) - */ - private static final int DEVICE_RES_NO_SEQ_LENGTH = 4; - /** - * 医嘱签发编码序列号长度(全局唯一,用于关联同一批次划价的医嘱) - */ - private static final int ADVICE_SIGN_SEQ_LENGTH = 10; - - // ======================== 依赖注入(业务服务/工具类)======================== - /** - * 诊疗活动定义服务(查询诊疗活动基础信息及子项配置) - */ - @Resource - IActivityDefinitionService iActivityDefinitionService; - /** - * 医嘱处理工具类(含诊疗子项处理等通用逻辑) - */ - @Resource - private AdviceUtils adviceUtils; - /** - * 序列生成工具类(用于生成业务编号、签发编码等唯一标识) - */ - @Resource - private AssignSeqUtil assignSeqUtil; - /** - * 耗材请求服务(CRUD耗材请求记录WOR_DEVICE_REQUEST) - */ - @Resource - private IDeviceRequestService iDeviceRequestService; - /** - * 服务请求服务(CRUD诊疗活动请求记录WOR_SERVICE_REQUEST) - */ - @Resource - private IServiceRequestService iServiceRequestService; - /** - * 费用项服务(CRUD费用记录ADM_CHARGE_ITEM,含收费状态管理) - */ - @Resource - private IChargeItemService iChargeItemService; - /** - * 耗材发放服务(生成耗材发放记录WOR_DEVICE_DISPENSE,管理发放状态) - */ - @Resource - private IDeviceDispenseService iDeviceDispenseService; - /** - * 执行记录服务(生成医嘱执行记录CLIN_PROCEDURE,记录执行状态/人员/时间) - */ - @Resource - private IProcedureService iProcedureService; - @Resource - private NurseBillingAppMapper nurseBillingAppMapper; - @Resource - private IOrganizationService organizationService; - - // ======================== 核心业务方法(划价新增)======================== - /** - * 新增住院护士站划价(核心入口方法) 完整流程:参数初始化 → 入参校验 → 医嘱分类 → 生成全局签发编码 → 分类处理划价 → 返回结果 - * 事务特性:所有操作原子化,任一环节失败则整体回滚(避免数据不一致) - * - * @param regAdviceSaveParam 划价请求参数体 - * 包含:患者ID、就诊ID、住院科室ID、医嘱列表(耗材/诊疗活动)、操作时间等核心数据 - * @return R 划价结果响应 成功:返回操作成功提示(编码M00002) 失败:返回具体错误信息(如参数缺失、库房为空等) - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R addInNurseBilling(RegAdviceSaveParam regAdviceSaveParam) { - // 获取当前登录用户信息(包含护士ID、所属科室等) - LoginUser loginUser = SecurityUtils.getLoginUser(); - - // 1. 时间参数初始化:优先使用入参指定时间,无则默认当前系统时间 - Date curDate = new Date(); - Date startTime = regAdviceSaveParam.getStartTime() == null ? curDate : regAdviceSaveParam.getStartTime(); - Date authoredTime - = regAdviceSaveParam.getAuthoredTime() == null ? curDate : regAdviceSaveParam.getAuthoredTime(); - - // 2. 入参校验:校验不通过直接返回错误响应(避免后续无效处理) - R checkResult = checkNurseBillingParam(regAdviceSaveParam); - if (checkResult.getCode() != R.SUCCESS) { - return checkResult; - } - - // 3. 提取核心业务参数:住院科室ID(优先入参,无则取登录用户所属科室) - Long organizationId = regAdviceSaveParam.getOrganizationId() == null ? loginUser.getOrgId() - : regAdviceSaveParam.getOrganizationId(); - // 待处理医嘱列表(含耗材、诊疗活动两种类型) - List allAdviceList = regAdviceSaveParam.getRegAdviceSaveList(); - - // 4. 医嘱分类:按类型拆分为耗材类和诊疗活动类(分别执行不同划价逻辑) - List deviceAdviceList = filterDeviceAdvice(allAdviceList); - List activityAdviceList = filterActivityAdvice(allAdviceList); - - // 5. 生成全局唯一签发编码:关联同一批次划价的所有医嘱(便于追溯) - String signCode = assignSeqUtil.getSeq(AssignSeqEnum.ADVICE_SIGN.getPrefix(), ADVICE_SIGN_SEQ_LENGTH); - - // 6. 分类处理划价:耗材类、诊疗活动类分别执行对应逻辑 - if (!deviceAdviceList.isEmpty()) { - handleAddDeviceBilling(deviceAdviceList, signCode, organizationId, curDate); - } - if (!activityAdviceList.isEmpty()) { - handleAddActivityBilling(activityAdviceList, signCode, organizationId, curDate, startTime, authoredTime); - } - - // 7. 划价成功:返回统一成功提示(通过国际化工具类拼接提示信息) - return R.ok(null, MessageUtils.createMessage(PromptMsgConstant.Common.M00002, new Object[]{"住院护士划价"})); - } - - /** - * 删除住院划价记录(核心实现) 流程:参数校验 → 分类筛选医嘱 → 已收费状态校验 → 级联删除关联数据 → 返回结果 - * 事务特性:所有删除操作原子化,任一环节失败整体回滚 - * - * @return R 删除结果响应 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R deleteInNurseBilling(List paramList) { - - // TODO 撤销前校验 - // 诊疗ids - List activityRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - activityRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.ACTIVITY.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - // 耗材ids - List deviceRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - deviceRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.DEVICE.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - this.handleDel(deviceRequestIds, SERVICE_TABLE_DEVICE); - this.handleDel(activityRequestIds, SERVICE_TABLE_SERVICE); - return R.ok("删除成功"); - } - - /** - * 住院患者医嘱查询 - * - * @param inpatientAdviceParam 查询条件 - * @param pageNo 当前页码 - * @param pageSize 查询条数 - * @return 住院患者医 - */ - @Override - public R getInNurseBillingPage(InpatientAdviceParam inpatientAdviceParam, Integer pageNo, Integer pageSize, - LocalDateTime startTime, LocalDateTime endTime) { - // 初始化查询参数 - String encounterIds = inpatientAdviceParam.getEncounterIds(); - inpatientAdviceParam.setEncounterIds(null); - Integer exeStatus = inpatientAdviceParam.getExeStatus(); - inpatientAdviceParam.setExeStatus(null); - // 构建查询条件 - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(inpatientAdviceParam, null, null, null); - - // 手动拼接住院患者id条件 - if (encounterIds != null && !encounterIds.isEmpty()) { - List encounterIdList - = Arrays.stream(encounterIds.split(CommonConstants.Common.COMMA)).map(Long::parseLong).toList(); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIdList); - } - // 患者医嘱分页列表 - Page inpatientAdvicePage - = nurseBillingAppMapper.getInNurseBillingPage(new Page<>(pageNo, pageSize), queryWrapper, - CommonConstants.TableName.WOR_DEVICE_REQUEST, CommonConstants.TableName.WOR_SERVICE_REQUEST, - RequestStatus.DRAFT.getValue(), EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode(), - ChargeItemStatus.BILLABLE.getValue(), ChargeItemStatus.BILLED.getValue(), - ChargeItemStatus.REFUNDED.getValue(), EncounterClass.IMP.getValue(), - GenerateSource.NURSE_PRICING.getValue(), startTime, endTime); - inpatientAdvicePage.getRecords().forEach(e -> { - // 医嘱类型 - e.setTherapyEnum_enumText(EnumUtils.getInfoByValue(TherapyTimeType.class, e.getTherapyEnum())); - // 请求状态 - e.setRequestStatus_enumText(EnumUtils.getInfoByValue(RequestStatus.class, e.getRequestStatus())); - // 性别枚举 - e.setGenderEnum_enumText(EnumUtils.getInfoByValue(AdministrativeGender.class, e.getGenderEnum())); - // 计算年龄 - if (e.getBirthDate() != null) { - e.setAge(AgeCalculatorUtil.getAge(e.getBirthDate())); - } - }); - return R.ok(inpatientAdvicePage); - } - - // ======================== 入参校验方法 ======================== - /** - * 划价入参校验(通用校验逻辑) 校验规则:1. 参数非空 2. 住院科室ID非空 3. 医嘱列表非空 4. 隐含库房校验(耗材类单独校验) - * 校验失败直接返回错误响应,不进入后续业务逻辑 - * - * @param regAdviceSaveParam 划价请求参数体 - * @return R 校验结果:成功返回R.ok(),失败返回R.fail(错误信息) - */ - private R checkNurseBillingParam(RegAdviceSaveParam regAdviceSaveParam) { - // 1. 整体参数非空校验:请求体为null直接返回失败 - if (regAdviceSaveParam == null) { - return R.fail("划价请求失败:未获取到有效请求数据,请重新提交"); - } - - // 2. 核心字段非空校验:患者住院科室ID不能为空 - if (regAdviceSaveParam.getOrganizationId() == null) { - return R.fail("划价请求失败:患者住院科室信息缺失,请确认患者科室后重试"); - } - - // 3. 医嘱列表非空校验:必须选择至少一个待划价项目 - List adviceList = regAdviceSaveParam.getRegAdviceSaveList(); - if (adviceList == null || adviceList.isEmpty()) { - return R.fail("划价请求失败:未选择任何待划价项目,请添加医嘱后提交"); - } - - // 4. 库存校验:临时注释(当前需求:划价不校验库存,实际发放时校验) - // 若后续需要恢复库存校验,可解除以下注释 - /* - List needCheckInventoryList = adviceList.stream() - .filter(advice -> TherapyTimeType.TEMPORARY.getValue().equals(advice.getTherapyEnum()) - && !DbOpType.DELETE.getCode().equals(advice.getDbOpType()) - && !ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - - String inventoryTip = adviceUtils.checkInventory(new ArrayList<>(needCheckInventoryList)); - if (inventoryTip != null) { - return R.fail("划价失败:" + inventoryTip + ",请联系库房确认库存或调整申请数量"); - } - */ - // 所有校验通过 - return R.ok(); - } - // ======================== 医嘱分类工具方法 ======================== - - /** - * 筛选耗材类医嘱 筛选规则:按医嘱类型枚举(ItemType.DEVICE)匹配,仅保留耗材相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的耗材类医嘱列表 - */ - private List filterDeviceAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.DEVICE.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - /** - * 筛选诊疗活动类医嘱 筛选规则:按医嘱类型枚举(ItemType.ACTIVITY)匹配,仅保留诊疗活动相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的诊疗活动类医嘱列表 - */ - private List filterActivityAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - // ======================== 耗材类划价处理 ======================== - /** - * 处理耗材类医嘱划价(核心子流程) 流程:筛选临时耗材 → 校验发放库房 → 生成耗材请求 → 生成执行记录 → 生成耗材发放 → 生成费用项 - * 特殊规则:临时耗材划价不校验库存、不预减库存(仅记录发放需求,实际发放时扣库) - * - * @param deviceAdviceList 耗材类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间(用于填充创建时间/执行时间) - */ - private void handleAddDeviceBilling(List deviceAdviceList, String signCode, Long organizationId, - Date curDate) { - // 1. 筛选临时类型耗材:仅处理临时医嘱(TherapyTimeType.TEMPORARY),且请求ID不为空 - List tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList()); - - // 2. 校验发放库房:必须指定耗材发放库房(locationId),否则抛出业务异常 - if (tempDeviceList.stream().anyMatch(t -> t.getLocationId() == null)) { - throw new ServiceException("耗材划价失败:发放库房为空,请重新选择"); - } - - // 3. 循环处理每个临时耗材医嘱(逐条生成关联数据) - for (AdviceSaveDto adviceDto : tempDeviceList) { - // 3.1 生成耗材请求记录(WOR_DEVICE_REQUEST):状态设为激活,来源标记为护士划价 - DeviceRequest deviceRequest = buildDeviceRequest(adviceDto, curDate); - iDeviceRequestService.saveOrUpdate(deviceRequest); - - // 3.2 生成医嘱执行记录(CLIN_PROCEDURE):记录执行状态、执行科室、执行时间等 - Long procedureId = this.addProcedureRecord(deviceRequest.getEncounterId(), // 就诊ID - deviceRequest.getPatientId(), // 患者ID - deviceRequest.getId(), // 耗材请求ID(关联执行记录与请求) - SERVICE_TABLE_DEVICE, // 关联表名(耗材请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - deviceRequest.getLocationId(), // 执行位置(发放库房) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3.3 生成耗材发放记录(WOR_DEVICE_DISPENSE):状态设为待发放,关联执行记录 - Long dispenseId = iDeviceDispenseService.generateDeviceDispense(deviceRequest, procedureId, - deviceRequest.getLocationId(), curDate); - - // 3.4 生成费用项记录(ADM_CHARGE_ITEM):关联耗材请求、发放记录、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, deviceRequest.getBusNo(), deviceRequest.getId(), - SERVICE_TABLE_DEVICE, PRODUCT_TABLE_DEVICE, curDate, procedureId, dispenseId, WOR_DEVICE_DISPENSE); - iChargeItemService.saveOrUpdate(chargeItem); - } - } - - // ======================== 诊疗活动类划价处理 ======================== - /** - * 处理诊疗活动类医嘱划价(核心子流程) 流程:生成服务请求 → 生成执行记录 → 生成费用项 → 处理诊疗子项(如有) - * 特殊规则:诊疗活动可能包含子项(如套餐类活动),需递归处理子项划价 - * - * @param activityAdviceList 诊疗活动类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - */ - private void handleAddActivityBilling(List activityAdviceList, String signCode, - Long organizationId, Date curDate, Date startTime, Date authoredTime) { - // 循环处理每个诊疗活动医嘱 - for (AdviceSaveDto adviceDto : activityAdviceList) { - // 1. 生成诊疗活动请求记录(WOR_SERVICE_REQUEST):状态设为激活,来源标记为护士划价 - ServiceRequest serviceRequest - = this.buildActivityRequest(adviceDto, signCode, organizationId, curDate, startTime, authoredTime); - - // 2. 生成医嘱执行记录(CLIN_PROCEDURE):关联服务请求,记录执行状态 - Long procedureId = this.addProcedureRecord(serviceRequest.getEncounterId(), // 就诊ID - serviceRequest.getPatientId(), // 患者ID - serviceRequest.getId(), // 服务请求ID(关联执行记录与请求) - SERVICE_TABLE_SERVICE, // 关联表名(服务请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - serviceRequest.getLocationId(), // 执行位置(执行科室) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3. 生成费用项记录(ADM_CHARGE_ITEM):关联服务请求、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, serviceRequest.getBusNo(), serviceRequest.getId(), - SERVICE_TABLE_SERVICE, PRODUCT_TABLE_ACTIVITY, curDate, procedureId, null, null); - iChargeItemService.saveOrUpdate(chargeItem); - - // 4. 处理诊疗子项(如活动包含子项配置,递归生成子项的划价数据) - this.buidActivityRequestChild(serviceRequest, chargeItem.getId(), adviceDto, organizationId); - } - } - - // ======================== 执行记录工具方法 ======================== - /** - * 生成医嘱执行记录(通用方法,支持耗材/诊疗活动两种类型) 功能:调用执行记录服务,创建CLIN_PROCEDURE表记录,关联请求数据与执行信息 - * - * @param encounterId 就诊ID(关联患者就诊记录) - * @param patientId 患者ID(关联患者基本信息) - * @param requestId 请求ID(关联耗材/服务请求主记录) - * @param requestTable 关联表名(标记是耗材请求还是服务请求) - * @param eventStatus 执行状态(如已完成、待执行等) - * @param procedureCategory 执行种类(如住院护士医嘱、医生医嘱等) - * @param locationId 执行位置(执行科室/发放库房ID) - * @param exeDate 执行时间 - * @param groupId 组号(批量执行时用于关联同一组医嘱) - * @param refundId 取消执行ID(取消执行时关联原执行记录) - * @return Long 执行记录ID(CLIN_PROCEDURE表主键) - */ - private Long addProcedureRecord(Long encounterId, Long patientId, Long requestId, String requestTable, - EventStatus eventStatus, ProcedureCategory procedureCategory, Long locationId, Date exeDate, Long groupId, - Long refundId) { - // 调用执行记录服务创建记录,返回执行记录主键ID - return iProcedureService.addProcedureRecord(encounterId, patientId, requestId, requestTable, eventStatus, - procedureCategory, locationId, exeDate, exeDate, groupId, refundId); - } - - // ======================== 实体构建工具方法(请求/费用项)======================== - /** - * 构建耗材请求实体(DeviceRequest) 功能:将医嘱DTO参数映射为耗材请求实体,填充默认配置(状态、业务编号、来源等) - * - * @param adviceDto 耗材医嘱DTO(含请求参数:数量、单位、耗材ID等) - * @param curDate 当前操作时间 - * @return DeviceRequest 构建完成的耗材请求实体(可直接保存) - */ - private DeviceRequest buildDeviceRequest(AdviceSaveDto adviceDto, Date curDate) { - LoginUser loginUser = SecurityUtils.getLoginUser(); - DeviceRequest deviceRequest = new DeviceRequest(); - - // 基础配置:主键(新增为null,修改为已有ID)、状态、业务编号 - deviceRequest.setId(adviceDto.getRequestId()); - deviceRequest.setTenantId(loginUser.getTenantId()); // 显式设置租户ID - // 业务编号:按日生成,前缀+4位序列号(确保每日唯一) - deviceRequest - .setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.DEVICE_RES_NO.getPrefix(), DEVICE_RES_NO_SEQ_LENGTH)); - // deviceRequest.setPrescriptionNo(null);//处方号 - // deviceRequest.setActivityId(null);//诊疗ID - // deviceRequest.setPackageId(null);//组套id - // deviceRequest.setIntentCode(null); // 请求意图 - deviceRequest.setCategoryEnum(adviceDto.getCategoryEnum()); // 请求类型(枚举,如常规请求) - // deviceRequest.setPerformFlag(null);//优先级 - // deviceRequest.setPriorityEnum(null);//是否停止执行 - // deviceRequest.setGroupNo(null);//分组编号 - // deviceRequest.setDeviceTypeCode(null);//器材类型 - deviceRequest.setQuantity(adviceDto.getQuantity()); // 耗材请求数量 - deviceRequest.setUnitCode(adviceDto.getUnitCode()); // 单位编码(如"个"、"盒") - deviceRequest.setLotNumber(adviceDto.getLotNumber()); // 产品批号(可选,耗材批次管理) - deviceRequest.setStatusEnum(RequestStatus.COMPLETED.getValue()); // 状态:已完成(划价即生效) - deviceRequest.setDeviceDefId(adviceDto.getAdviceDefinitionId()); // 耗材定义ID(关联ADM_DEVICE_DEFINITION) - // deviceRequest.setDeviceSpecifications(null)//器材规格 - deviceRequest.setRequesterId( - adviceDto.getPractitionerId() == null ? loginUser.getPractitionerId() : adviceDto.getPractitionerId());// 请求发起人 - deviceRequest - .setOrgId(adviceDto.getFounderOrgId() == null ? loginUser.getOrgId() : adviceDto.getFounderOrgId());// 请求发起的科室 - deviceRequest.setLocationId(adviceDto.getLocationId());// 默认器材房 - deviceRequest.setPerformLocation(adviceDto.getLocationId()); // 发放库房ID(关联耗材发放位置) - deviceRequest.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联患者本次住院记录) - deviceRequest.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者信息) - // deviceRequest.setRateCode(null);//用药频次 - // deviceRequest.setUseTime();//预计使用时间 - // deviceRequest.setUseStartTime();//预计使用时间 - // deviceRequest.setUseEndTime();//预计使用结束时间 - // deviceRequest.setUseTiming();//预计使用周期时间 - deviceRequest.setReqAuthoredTime(curDate); // 请求开始时间(当前操作时间) - // deviceRequest.setPerformerEnum();//执行人类型 - // deviceRequest.setPerformerId();//执行人 - // deviceRequest.setPerformOrgId();//执行科室 - // deviceRequest.setConditionIdJson(); // 相关诊断 - // deviceRequest.setObservationIdJson();//相关观测 - // deviceRequest.setAsNeedFlag();//是否可以按需给出 - // deviceRequest.setAsNeedReason();//按需使用原因 - // deviceRequest.setContractCode();//合同id - // deviceRequest.setSupportInfo();//支持用药信息 - // deviceRequest.setRequesterId();//退药id - deviceRequest.setContentJson(adviceDto.getContentJson());// 请求内容json - // deviceRequest.setYbClassEnum();//类别医保编码 - // deviceRequest.setTraceNo()//追溯码 - deviceRequest.setConditionId(adviceDto.getConditionId());// 诊断id - deviceRequest.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId());// 就诊诊断id - // deviceRequest.setBasedOnTable();//请求基于什么 - deviceRequest.setBasedOnId(adviceDto.getBasedOnId());// 请求基于什么的ID - deviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - return deviceRequest; - } - - /** - * 构建诊疗活动请求实体(ServiceRequest) 功能:将诊疗活动医嘱DTO映射为服务请求实体,填充默认配置和业务参数 - * - * @param activityDto 诊疗活动医嘱DTO(含活动ID、数量、执行科室等) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - * @return ServiceRequest 构建完成的诊疗活动请求实体(已保存到数据库) - */ - private ServiceRequest buildActivityRequest(AdviceSaveDto activityDto, String signCode, Long organizationId, - Date curDate, Date startTime, Date authoredTime) { - ServiceRequest serviceRequest = new ServiceRequest(); - - // 基础配置:主键、状态、业务编号、签发编码 - serviceRequest.setId(activityDto.getRequestId()); // 主键ID(新增为null,修改为已有ID) - serviceRequest.setStatusEnum(RequestStatus.ACTIVE.getValue()); // 状态:激活(划价即生效) - serviceRequest.setTenantId(SecurityUtils.getLoginUser().getTenantId()); // 显式设置租户ID - serviceRequest.setAuthoredTime(authoredTime); // 医嘱签发时间 - serviceRequest.setSignCode(signCode); // 全局签发编码(关联同一批次划价的医嘱) - serviceRequest.setOccurrenceStartTime(startTime); // 医嘱开始执行时间 - // 业务编号:按日生成,前缀+4位序列号(每日唯一) - serviceRequest.setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.SERVICE_RES_NO.getPrefix(), 4)); - serviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:从DTO提取核心参数 - serviceRequest.setQuantity(activityDto.getQuantity()); // 请求数量(如诊疗活动执行次数) - serviceRequest.setUnitCode(activityDto.getUnitCode()); // 单位编码(如"次"、"疗程") - serviceRequest.setCategoryEnum(activityDto.getCategoryEnum()); // 请求类型(枚举,如常规诊疗) - serviceRequest.setTherapyEnum(activityDto.getTherapyEnum()); // 治疗类型(如临时、长期,前端传入) - serviceRequest.setActivityId(activityDto.getAdviceDefinitionId()); // 诊疗活动定义ID(关联WOR_ACTIVITY_DEFINITION) - serviceRequest.setPatientId(activityDto.getPatientId()); // 患者ID(关联患者信息) - serviceRequest.setRequesterId(activityDto.getPractitionerId()); // 开方医生ID(诊疗活动的开单医生) - serviceRequest.setEncounterId(activityDto.getEncounterId()); // 就诊ID(关联本次住院记录) - serviceRequest.setAuthoredTime(curDate); // 请求签发时间(当前操作时间) - serviceRequest.setOrgId(activityDto.getPositionId()); // 执行科室ID(诊疗活动的执行科室) - serviceRequest.setContentJson(activityDto.getContentJson()); // 扩展信息JSON(额外配置) - serviceRequest.setYbClassEnum(activityDto.getYbClassEnum()); // 医保类别编码(关联医保报销) - serviceRequest.setConditionId(activityDto.getConditionId()); // 诊断ID(关联患者诊断) - serviceRequest.setEncounterDiagnosisId(activityDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - - // 保存诊疗活动请求记录到数据库 - iServiceRequestService.saveOrUpdate(serviceRequest); - return serviceRequest; - } - - /** - * 处理诊疗活动子项划价(如诊疗活动是套餐,包含多个子项) 功能:解析诊疗活动的子项配置JSON,递归生成子项的划价数据(请求、执行记录、费用项) - * - * @param serviceRequest 父诊疗活动请求实体(关联子项) - * @param chargeItemId 父诊疗活动的费用项ID(关联子项费用) - * @param activityDto 诊疗活动医嘱DTO(含子项配置JSON) - * @param organizationId 住院科室ID - */ - private void buidActivityRequestChild(ServiceRequest serviceRequest, Long chargeItemId, AdviceSaveDto activityDto, - Long organizationId) { - // 1. 查询诊疗活动定义信息(获取子项配置JSON) - ActivityDefinition activityDefinition = iActivityDefinitionService.getById(activityDto.getAdviceDefinitionId()); - String childrenJson = activityDefinition.getChildrenJson(); - - // 2. 若存在子项配置,构建子项参数并调用工具类处理 - if (childrenJson != null) { - ActivityChildrenJsonParams activityChildrenJsonParams = new ActivityChildrenJsonParams(); - // 子项治疗类型:默认临时(与父项一致) - activityChildrenJsonParams.setTherapyEnum(TherapyTimeType.TEMPORARY.getValue()); - activityChildrenJsonParams.setPatientId(serviceRequest.getPatientId()); // 患者ID(继承父项) - activityChildrenJsonParams.setEncounterId(serviceRequest.getEncounterId()); // 就诊ID(继承父项) - activityChildrenJsonParams.setAccountId(activityDto.getAccountId()); // 患者账户ID(关联费用结算) - activityChildrenJsonParams.setChargeItemId(chargeItemId); // 父费用项ID(关联子项费用) - activityChildrenJsonParams.setParentId(serviceRequest.getId()); // 父诊疗请求ID(关联子项与父项) - activityChildrenJsonParams.setEncounterDiagnosisId(serviceRequest.getEncounterDiagnosisId()); - // 调用工具类处理子项:递归生成子项的请求、执行记录、费用项 - adviceUtils.handleActivityChild(childrenJson, organizationId, activityChildrenJsonParams); - } - } - - /** - * 构建费用项实体(ChargeItem) 功能:关联请求记录(耗材/诊疗)与费用信息,生成待结算的费用项(ADM_CHARGE_ITEM表) - * 通用性:支持耗材、诊疗活动两种类型的费用项构建 - * - * @param adviceDto 医嘱DTO(含费用相关参数:单价、总价等) - * @param requestBusNo 关联请求的业务编号(耗材/服务请求的busNo) - * @param requestId 关联请求的ID(耗材/服务请求的主键) - * @param serviceTable 关联服务表名(标记是耗材请求还是服务请求) - * @param productTable 关联产品表名(标记产品类型:耗材/诊疗活动) - * @param curDate 当前操作时间(费用开立时间) - * @param procedureId 执行记录ID(关联CLIN_PROCEDURE表) - * @param dispenseId 发放记录ID(关联耗材发放表,诊疗活动为null) - * @param dispenseTable 发放表名(耗材为WOR_DEVICE_DISPENSE,诊疗活动为null) - * @return ChargeItem 构建完成的费用项实体(可直接保存) - */ - private ChargeItem buildChargeItem(AdviceSaveDto adviceDto, String requestBusNo, Long requestId, - String serviceTable, String productTable, Date curDate, Long procedureId, Long dispenseId, - String dispenseTable) { - ChargeItem chargeItem = new ChargeItem(); - // TODO1、是否需跨批次 2、金额 精确到小数点后6位 、数量 计算 - // 基础配置:主键、状态、业务编号 - chargeItem.setId(adviceDto.getChargeItemId()); // 费用项ID(新增为null,修改为已有ID) - chargeItem.setStatusEnum(ChargeItemStatus.BILLABLE.getValue()); // 状态:待结算(未收费) - // 业务编号:费用项前缀+关联请求的业务编号(确保与请求一一对应,便于追溯) - chargeItem.setBusNo(CHARGE_ITEM_BUS_NO_PREFIX.concat(requestBusNo)); - chargeItem.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:患者、就诊、定价相关信息 - chargeItem.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者) - chargeItem.setContextEnum(adviceDto.getAdviceType()); // 费用类型(与医嘱类型一致:耗材/诊疗) - chargeItem.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联本次住院) - chargeItem.setDefinitionId(adviceDto.getDefinitionId()); // 费用定价ID(关联定价规则) - chargeItem.setDefDetailId(adviceDto.getDefinitionDetailId()); // 定价子表ID(明细定价,如规格对应的单价) - chargeItem.setEntererId(adviceDto.getPractitionerId()); // 开立人ID(开方医生/护士) - chargeItem.setRequestingOrgId(SecurityUtils.getLoginUser().getOrgId()); // 开立科室ID(当前登录用户科室) - chargeItem.setEnteredDate(curDate); // 开立时间(当前操作时间) - chargeItem.setServiceTable(serviceTable); // 关联服务表名(标记数据源) - chargeItem.setServiceId(requestId); // 关联服务ID(耗材/服务请求的主键) - chargeItem.setProductTable(productTable); // 关联产品表名(标记产品类型) - chargeItem.setProductId(adviceDto.getAdviceDefinitionId()); // 产品ID(耗材/诊疗活动定义ID) - chargeItem.setAccountId(adviceDto.getAccountId()); // 患者账户ID(关联费用结算账户) - chargeItem.setConditionId(adviceDto.getConditionId()); // 诊断ID(关联患者诊断) - chargeItem.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - chargeItem.setProductId(procedureId); // 执行记录ID(关联执行记录) - chargeItem.setDispenseId(dispenseId); // 发放记录ID(耗材专属,诊疗活动为null) - chargeItem.setDispenseTable(dispenseTable); // 发放表名(耗材专属,诊疗活动为null) - - // 费用核心属性:数量、单位、单价、总价(与医嘱保持一致) - chargeItem.setQuantityValue(adviceDto.getQuantity()); // 数量(与请求数量一致) - chargeItem.setQuantityUnit(adviceDto.getUnitCode()); // 单位(与请求单位一致) - chargeItem.setUnitPrice(adviceDto.getUnitPrice()); // 单价(从DTO传入,已定价) - chargeItem.setTotalPrice(adviceDto.getTotalPrice()); // 总价(数量×单价,DTO已计算,避免重复计算) - - return chargeItem; - } - - // ======================== 耗材删除相关方法 ======================== - /** - * 处理耗材删除逻辑(级联删除关联数据) 核心规则:已收费的耗材项目不允许删除,未收费项目级联删除关联数据 级联删除顺序:耗材请求表 → 耗材发放表 - * → 费用项表 - * - * @param requestIds 待删除的耗材医嘱列表(可为null) - * @param serviceTable 关联服务表名(此处为耗材请求表) - */ - private void handleDel(List requestIds, String serviceTable) { - // 空列表直接返回,避免无效循环 - if (requestIds == null || requestIds.isEmpty()) { - return; - } - // 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除 - checkDeletedDeviceChargeStatus(requestIds); - // 软删除执行记录 - List procedureList = iProcedureService.getProcedureRecords(requestIds, serviceTable); - List procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象 - .map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录(按需添加) - .collect(Collectors.toList()); - // 批量删除执行记录 - iProcedureService.removeBatchByIds(procedureIds); - - // 不想循环删除 - for (Long requestId : requestIds) { - if (serviceTable.equals(SERVICE_TABLE_DEVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iDeviceRequestService.removeById(requestId); - // 删除关联的耗材发放记录(WOR_DEVICE_DISPENSE) - iDeviceDispenseService.deleteDeviceDispense(requestId); - } - if (serviceTable.equals(SERVICE_TABLE_SERVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iServiceRequestService.removeById(requestId); - } - // 删除关联的费用项记录(ADM_CHARGE_ITEM,按服务表+服务ID关联) - iChargeItemService.deleteByServiceTableAndId(serviceTable, requestId); - } - - } - - /** - * 校验待删除耗材的费用状态(防止删除已收费项目) 逻辑:查询待删除耗材对应的费用项,若存在"已收费"状态则抛出业务异常 - * - * @param requestIds 待删除的耗材请求ID列表 - */ - private void checkDeletedDeviceChargeStatus(List requestIds) { - if (requestIds.isEmpty()) { - return; - } - - // 1. 查询待删除耗材对应的费用项列表 - List chargeItemList = iChargeItemService.getChargeItemInfoByReqId(requestIds); - if (chargeItemList == null || chargeItemList.isEmpty()) { - return; // 无关联费用项,允许删除 - } - - // 2. 校验是否存在已收费项(状态为BILLED) - boolean hasBilledItem - = chargeItemList.stream().anyMatch(ci -> ChargeItemStatus.BILLED.getValue().equals(ci.getStatusEnum())); - if (hasBilledItem) { - throw new ServiceException("删除失败:部分项目已完成收费(结算),不支持直接删除,请联系收费人员处理后重试"); - } - } - - // ======================== 未实现接口方法(保留签名,待扩展)======================== - /** - * 新增订单划价(待实现) 功能:针对订单类型的划价(如患者自主购买耗材/服务),生成对应的费用项 - * - * @return R 划价结果响应 - */ - @Override - public R addOrderBilling() { - // 待实现:需接收订单相关参数,构建订单划价逻辑(类似耗材/诊疗划价,差异在于来源类型) - return null; - } - - /** - * 删除订单划价(待实现) 功能:删除未收费的订单划价记录,级联删除关联数据 - * - * @return R 删除结果响应 - */ - @Override - public R deleteOrderBilling() { - // 待实现:类似住院划价删除逻辑,需校验订单划价的费用状态 - return null; - } - - /** - * 修改订单划价(待实现) 功能:支持修改未收费订单划价的数量、单价等信息,同步更新费用项 - * - * @return R 修改结果响应 - */ - @Override - public R updateOrderBilling() { - // 待实现:需接收修改后的订单划价参数,更新请求记录和费用项 - return null; - } - - /** - * 费用明细查询 - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @return 住院患者费用明细 - */ - @Override - public R> getCostDetails(CostDetailSearchParam costDetailSearchParam, - HttpServletRequest request) { - List encounterIds = costDetailSearchParam.getEncounterIds(); - if (encounterIds == null || encounterIds.isEmpty()) { - return R.fail("就诊ID不能为空"); - } - costDetailSearchParam.setEncounterIds(null); - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(costDetailSearchParam, null, null, request); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIds); - List list = iChargeItemService.getCostDetails(queryWrapper, ChargeItemStatus.BILLABLE.getValue(), - ChargeItemStatus.BILLED.getValue(), ChargeItemStatus.REFUNDED.getValue(), - EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode()); - return R.ok(list); - } - - /** - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @param response response响应 - */ - @Override - public void makeExcelFile(CostDetailSearchParam costDetailSearchParam, HttpServletRequest request, - HttpServletResponse response) { - R> costDetails = getCostDetails(costDetailSearchParam, request); - if (costDetails.getData() != null) { - List dataList = costDetails.getData(); - // 设置执行科室 - dataList.forEach(costDetailDto -> { - Long orgId = costDetailDto.getOrgId(); - costDetailDto.setOrgName(organizationService.getById(orgId).getName()); - }); - // 根据EncounterId分组 - Map> map - = dataList.stream().collect(Collectors.groupingBy(CostDetailDto::getEncounterId)); - map.forEach((key, value) -> { - // 新加一条小计 - value.add(new CostDetailDto().setEncounterId(key).setChargeName("小计").setTotalPrice( - value.stream().map(CostDetailDto::getTotalPrice).reduce(BigDecimal.ZERO, BigDecimal::add))); - }); - // 收集要导出的数据 - List excelOutList - = map.entrySet().stream().map(entry -> new CostDetailExcelOutDto(entry.getKey(), entry.getValue(), - entry.getValue().get(0).getPatientName())).toList(); - try { - // 住院记账-费用明细 导出 - NewExcelUtil util = new NewExcelUtil<>(CostDetailExcelOutDto.class); - util.exportExcel(response, excelOutList, CommonConstants.SheetName.COST_DETAILS); - } catch (Exception e) { - throw new NonCaptureException(StringUtils.format("导出excel失败"), e); - } - } - } - -} diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_pre_fix b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_pre_fix deleted file mode 100644 index 3c8bf7a9..00000000 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/inhospitalnursestation/appservice/impl/NurseBillingAppService.java.backup_pre_fix +++ /dev/null @@ -1,815 +0,0 @@ -package com.openhis.web.inhospitalnursestation.appservice.impl; - -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.core.common.core.domain.R; -import com.core.common.core.domain.model.LoginUser; -import com.core.common.exception.NonCaptureException; -import com.core.common.exception.ServiceException; -import com.core.common.utils.*; -import com.openhis.administration.domain.ChargeItem; -import com.openhis.administration.dto.CostDetailDto; -import com.openhis.administration.dto.CostDetailSearchParam; -import com.openhis.administration.service.IChargeItemService; -import com.openhis.administration.service.IOrganizationService; -import com.openhis.clinical.domain.Procedure; -import com.openhis.clinical.service.IProcedureService; -import com.openhis.common.constant.CommonConstants; -import com.openhis.common.constant.PromptMsgConstant; -import com.openhis.common.enums.*; -import com.openhis.common.utils.EnumUtils; -import com.openhis.common.utils.HisQueryUtils; -import com.openhis.web.doctorstation.dto.ActivityChildrenJsonParams; -import com.openhis.web.doctorstation.dto.AdviceSaveDto; -import com.openhis.web.doctorstation.utils.AdviceUtils; -import com.openhis.web.inhospitalnursestation.appservice.INurseBillingAppService; -import com.openhis.web.inhospitalnursestation.dto.CostDetailExcelOutDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceDto; -import com.openhis.web.inhospitalnursestation.dto.InpatientAdviceParam; -import com.openhis.web.inhospitalnursestation.mapper.NurseBillingAppMapper; -import com.openhis.web.regdoctorstation.dto.AdviceBatchOpParam; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveDto; -import com.openhis.web.regdoctorstation.dto.RegAdviceSaveParam; -import com.openhis.workflow.domain.ActivityDefinition; -import com.openhis.workflow.domain.DeviceRequest; -import com.openhis.workflow.domain.ServiceRequest; -import com.openhis.workflow.service.IActivityDefinitionService; -import com.openhis.workflow.service.IDeviceDispenseService; -import com.openhis.workflow.service.IDeviceRequestService; -import com.openhis.workflow.service.IServiceRequestService; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; - -/** - * 住院护士站划价服务实现类 核心职责: 1. 临时耗材划价签发(含耗材请求生成、发放记录创建、费用项关联) 2. - * 诊疗活动划价签发(含服务请求生成、子项处理、费用项关联) 3. 划价参数校验(非空校验、库房校验等) 4. 已收费项目删除限制(避免误删已结算数据) 5. - * 关联数据一致性管理(请求表、执行表、费用表、发放表联动) - */ -@Service -public class NurseBillingAppService implements INurseBillingAppService { - - // ======================== 常量定义(关联表名/编码规则)======================== - /** - * 耗材请求服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_DEVICE = CommonConstants.TableName.WOR_DEVICE_REQUEST; - /** - * 诊疗活动服务关联表名(用于费用项关联数据源) - */ - private static final String SERVICE_TABLE_SERVICE = CommonConstants.TableName.WOR_SERVICE_REQUEST; - /** - * 耗材产品定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_DEVICE = CommonConstants.TableName.ADM_DEVICE_DEFINITION; - /** - * 诊疗活动定义表名(用于费用项关联产品信息) - */ - private static final String PRODUCT_TABLE_ACTIVITY = CommonConstants.TableName.WOR_ACTIVITY_DEFINITION; - /** - * 耗材发放表名(用于费用项关联发放记录) - */ - private static final String WOR_DEVICE_DISPENSE = CommonConstants.TableName.WOR_DEVICE_DISPENSE; - - /** - * 费用项业务编号前缀(统一编码规则,便于追溯) - */ - private static final String CHARGE_ITEM_BUS_NO_PREFIX = AssignSeqEnum.CHARGE_ITEM_NO.getPrefix(); - /** - * 耗材申请单号序列号长度(按日生成,每日从0001开始递增) - */ - private static final int DEVICE_RES_NO_SEQ_LENGTH = 4; - /** - * 医嘱签发编码序列号长度(全局唯一,用于关联同一批次划价的医嘱) - */ - private static final int ADVICE_SIGN_SEQ_LENGTH = 10; - - // ======================== 依赖注入(业务服务/工具类)======================== - /** - * 诊疗活动定义服务(查询诊疗活动基础信息及子项配置) - */ - @Resource - IActivityDefinitionService iActivityDefinitionService; - /** - * 医嘱处理工具类(含诊疗子项处理等通用逻辑) - */ - @Resource - private AdviceUtils adviceUtils; - /** - * 序列生成工具类(用于生成业务编号、签发编码等唯一标识) - */ - @Resource - private AssignSeqUtil assignSeqUtil; - /** - * 耗材请求服务(CRUD耗材请求记录WOR_DEVICE_REQUEST) - */ - @Resource - private IDeviceRequestService iDeviceRequestService; - /** - * 服务请求服务(CRUD诊疗活动请求记录WOR_SERVICE_REQUEST) - */ - @Resource - private IServiceRequestService iServiceRequestService; - /** - * 费用项服务(CRUD费用记录ADM_CHARGE_ITEM,含收费状态管理) - */ - @Resource - private IChargeItemService iChargeItemService; - /** - * 耗材发放服务(生成耗材发放记录WOR_DEVICE_DISPENSE,管理发放状态) - */ - @Resource - private IDeviceDispenseService iDeviceDispenseService; - /** - * 执行记录服务(生成医嘱执行记录CLIN_PROCEDURE,记录执行状态/人员/时间) - */ - @Resource - private IProcedureService iProcedureService; - @Resource - private NurseBillingAppMapper nurseBillingAppMapper; - @Resource - private IOrganizationService organizationService; - - // ======================== 核心业务方法(划价新增)======================== - /** - * 新增住院护士站划价(核心入口方法) 完整流程:参数初始化 → 入参校验 → 医嘱分类 → 生成全局签发编码 → 分类处理划价 → 返回结果 - * 事务特性:所有操作原子化,任一环节失败则整体回滚(避免数据不一致) - * - * @param regAdviceSaveParam 划价请求参数体 - * 包含:患者ID、就诊ID、住院科室ID、医嘱列表(耗材/诊疗活动)、操作时间等核心数据 - * @return R 划价结果响应 成功:返回操作成功提示(编码M00002) 失败:返回具体错误信息(如参数缺失、库房为空等) - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R addInNurseBilling(RegAdviceSaveParam regAdviceSaveParam) { - // 获取当前登录用户信息(包含护士ID、所属科室等) - LoginUser loginUser = SecurityUtils.getLoginUser(); - - // 1. 时间参数初始化:优先使用入参指定时间,无则默认当前系统时间 - Date curDate = new Date(); - Date startTime = regAdviceSaveParam.getStartTime() == null ? curDate : regAdviceSaveParam.getStartTime(); - Date authoredTime - = regAdviceSaveParam.getAuthoredTime() == null ? curDate : regAdviceSaveParam.getAuthoredTime(); - - // 2. 入参校验:校验不通过直接返回错误响应(避免后续无效处理) - R checkResult = checkNurseBillingParam(regAdviceSaveParam); - if (checkResult.getCode() != R.SUCCESS) { - return checkResult; - } - - // 3. 提取核心业务参数:住院科室ID(优先入参,无则取登录用户所属科室) - Long organizationId = regAdviceSaveParam.getOrganizationId() == null ? loginUser.getOrgId() - : regAdviceSaveParam.getOrganizationId(); - // 待处理医嘱列表(含耗材、诊疗活动两种类型) - List allAdviceList = regAdviceSaveParam.getRegAdviceSaveList(); - - // 4. 医嘱分类:按类型拆分为耗材类和诊疗活动类(分别执行不同划价逻辑) - List deviceAdviceList = filterDeviceAdvice(allAdviceList); - List activityAdviceList = filterActivityAdvice(allAdviceList); - - // 5. 生成全局唯一签发编码:关联同一批次划价的所有医嘱(便于追溯) - String signCode = assignSeqUtil.getSeq(AssignSeqEnum.ADVICE_SIGN.getPrefix(), ADVICE_SIGN_SEQ_LENGTH); - - // 6. 分类处理划价:耗材类、诊疗活动类分别执行对应逻辑 - if (!deviceAdviceList.isEmpty()) { - handleAddDeviceBilling(deviceAdviceList, signCode, organizationId, curDate); - } - if (!activityAdviceList.isEmpty()) { - handleAddActivityBilling(activityAdviceList, signCode, organizationId, curDate, startTime, authoredTime); - } - - // 7. 划价成功:返回统一成功提示(通过国际化工具类拼接提示信息) - return R.ok(null, MessageUtils.createMessage(PromptMsgConstant.Common.M00002, new Object[]{"住院护士划价"})); - } - - /** - * 删除住院划价记录(核心实现) 流程:参数校验 → 分类筛选医嘱 → 已收费状态校验 → 级联删除关联数据 → 返回结果 - * 事务特性:所有删除操作原子化,任一环节失败整体回滚 - * - * @return R 删除结果响应 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R deleteInNurseBilling(List paramList) { - - // TODO 撤销前校验 - // 诊疗ids - List activityRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - activityRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.ACTIVITY.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - // 耗材ids - List deviceRequestIds = Collections.emptyList(); - if (paramList != null && !paramList.isEmpty()) { - deviceRequestIds = paramList.stream().filter(e -> e != null // 避免单个参数对象为null - && ItemType.DEVICE.getValue().equals(e.getAdviceType()) && e.getRequestId() != null) // 避免requestId为null(按需添加) - .map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList()); - } - this.handleDel(deviceRequestIds, SERVICE_TABLE_DEVICE); - this.handleDel(activityRequestIds, SERVICE_TABLE_SERVICE); - return R.ok("删除成功"); - } - - /** - * 住院患者医嘱查询 - * - * @param inpatientAdviceParam 查询条件 - * @param pageNo 当前页码 - * @param pageSize 查询条数 - * @return 住院患者医 - */ - @Override - public R getInNurseBillingPage(InpatientAdviceParam inpatientAdviceParam, Integer pageNo, Integer pageSize, - LocalDateTime startTime, LocalDateTime endTime) { - // 初始化查询参数 - String encounterIds = inpatientAdviceParam.getEncounterIds(); - inpatientAdviceParam.setEncounterIds(null); - Integer exeStatus = inpatientAdviceParam.getExeStatus(); - inpatientAdviceParam.setExeStatus(null); - // 构建查询条件 - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(inpatientAdviceParam, null, null, null); - - // 手动拼接住院患者id条件 - if (encounterIds != null && !encounterIds.isEmpty()) { - List encounterIdList - = Arrays.stream(encounterIds.split(CommonConstants.Common.COMMA)).map(Long::parseLong).toList(); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIdList); - } - // 患者医嘱分页列表 - Page inpatientAdvicePage - = nurseBillingAppMapper.getInNurseBillingPage(new Page<>(pageNo, pageSize), queryWrapper, - CommonConstants.TableName.WOR_DEVICE_REQUEST, CommonConstants.TableName.WOR_SERVICE_REQUEST, - RequestStatus.DRAFT.getValue(), EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode(), - ChargeItemStatus.BILLABLE.getValue(), ChargeItemStatus.BILLED.getValue(), - ChargeItemStatus.REFUNDED.getValue(), EncounterClass.IMP.getValue(), - GenerateSource.NURSE_PRICING.getValue(), startTime, endTime); - inpatientAdvicePage.getRecords().forEach(e -> { - // 医嘱类型 - e.setTherapyEnum_enumText(EnumUtils.getInfoByValue(TherapyTimeType.class, e.getTherapyEnum())); - // 请求状态 - e.setRequestStatus_enumText(EnumUtils.getInfoByValue(RequestStatus.class, e.getRequestStatus())); - // 性别枚举 - e.setGenderEnum_enumText(EnumUtils.getInfoByValue(AdministrativeGender.class, e.getGenderEnum())); - // 计算年龄 - if (e.getBirthDate() != null) { - e.setAge(AgeCalculatorUtil.getAge(e.getBirthDate())); - } - }); - return R.ok(inpatientAdvicePage); - } - - // ======================== 入参校验方法 ======================== - /** - * 划价入参校验(通用校验逻辑) 校验规则:1. 参数非空 2. 住院科室ID非空 3. 医嘱列表非空 4. 隐含库房校验(耗材类单独校验) - * 校验失败直接返回错误响应,不进入后续业务逻辑 - * - * @param regAdviceSaveParam 划价请求参数体 - * @return R 校验结果:成功返回R.ok(),失败返回R.fail(错误信息) - */ - private R checkNurseBillingParam(RegAdviceSaveParam regAdviceSaveParam) { - // 1. 整体参数非空校验:请求体为null直接返回失败 - if (regAdviceSaveParam == null) { - return R.fail("划价请求失败:未获取到有效请求数据,请重新提交"); - } - - // 2. 核心字段非空校验:患者住院科室ID不能为空 - if (regAdviceSaveParam.getOrganizationId() == null) { - return R.fail("划价请求失败:患者住院科室信息缺失,请确认患者科室后重试"); - } - - // 3. 医嘱列表非空校验:必须选择至少一个待划价项目 - List adviceList = regAdviceSaveParam.getRegAdviceSaveList(); - if (adviceList == null || adviceList.isEmpty()) { - return R.fail("划价请求失败:未选择任何待划价项目,请添加医嘱后提交"); - } - - // 4. 库存校验:临时注释(当前需求:划价不校验库存,实际发放时校验) - // 若后续需要恢复库存校验,可解除以下注释 - /* - List needCheckInventoryList = adviceList.stream() - .filter(advice -> TherapyTimeType.TEMPORARY.getValue().equals(advice.getTherapyEnum()) - && !DbOpType.DELETE.getCode().equals(advice.getDbOpType()) - && !ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - - String inventoryTip = adviceUtils.checkInventory(new ArrayList<>(needCheckInventoryList)); - if (inventoryTip != null) { - return R.fail("划价失败:" + inventoryTip + ",请联系库房确认库存或调整申请数量"); - } - */ - // 所有校验通过 - return R.ok(); - } - // ======================== 医嘱分类工具方法 ======================== - - /** - * 筛选耗材类医嘱 筛选规则:按医嘱类型枚举(ItemType.DEVICE)匹配,仅保留耗材相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的耗材类医嘱列表 - */ - private List filterDeviceAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.DEVICE.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - /** - * 筛选诊疗活动类医嘱 筛选规则:按医嘱类型枚举(ItemType.ACTIVITY)匹配,仅保留诊疗活动相关医嘱 - * - * @param allAdviceList 所有待处理医嘱列表 - * @return List 筛选后的诊疗活动类医嘱列表 - */ - private List filterActivityAdvice(List allAdviceList) { - return allAdviceList.stream().filter(advice -> ItemType.ACTIVITY.getValue().equals(advice.getAdviceType())) - .collect(Collectors.toList()); - } - - // ======================== 耗材类划价处理 ======================== - /** - * 处理耗材类医嘱划价(核心子流程) 流程:筛选临时耗材 → 校验发放库房 → 生成耗材请求 → 生成执行记录 → 生成耗材发放 → 生成费用项 - * 特殊规则:临时耗材划价不校验库存、不预减库存(仅记录发放需求,实际发放时扣库) - * - * @param deviceAdviceList 耗材类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间(用于填充创建时间/执行时间) - */ - private void handleAddDeviceBilling(List deviceAdviceList, String signCode, Long organizationId, - Date curDate) { - // 1. 筛选临时类型耗材:仅处理临时医嘱(TherapyTimeType.TEMPORARY),且请求ID不为空 - List tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList()); - - // 2. 校验发放库房:必须指定耗材发放库房(locationId),否则抛出业务异常 - if (tempDeviceList.stream().anyMatch(t -> t.getLocationId() == null)) { - throw new ServiceException("耗材划价失败:发放库房为空,请重新选择"); - } - - // 3. 循环处理每个临时耗材医嘱(逐条生成关联数据) - for (AdviceSaveDto adviceDto : tempDeviceList) { - // 3.1 生成耗材请求记录(WOR_DEVICE_REQUEST):状态设为激活,来源标记为护士划价 - DeviceRequest deviceRequest = buildDeviceRequest(adviceDto, curDate); - iDeviceRequestService.saveOrUpdate(deviceRequest); - - // 3.2 生成医嘱执行记录(CLIN_PROCEDURE):记录执行状态、执行科室、执行时间等 - Long procedureId = this.addProcedureRecord(deviceRequest.getEncounterId(), // 就诊ID - deviceRequest.getPatientId(), // 患者ID - deviceRequest.getId(), // 耗材请求ID(关联执行记录与请求) - SERVICE_TABLE_DEVICE, // 关联表名(耗材请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - deviceRequest.getLocationId(), // 执行位置(发放库房) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3.3 生成耗材发放记录(WOR_DEVICE_DISPENSE):状态设为待发放,关联执行记录 - Long dispenseId = iDeviceDispenseService.generateDeviceDispense(deviceRequest, procedureId, - deviceRequest.getLocationId(), curDate); - - // 3.4 生成费用项记录(ADM_CHARGE_ITEM):关联耗材请求、发放记录、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, deviceRequest.getBusNo(), deviceRequest.getId(), - SERVICE_TABLE_DEVICE, PRODUCT_TABLE_DEVICE, curDate, procedureId, dispenseId, WOR_DEVICE_DISPENSE); - iChargeItemService.saveOrUpdate(chargeItem); - } - } - - // ======================== 诊疗活动类划价处理 ======================== - /** - * 处理诊疗活动类医嘱划价(核心子流程) 流程:生成服务请求 → 生成执行记录 → 生成费用项 → 处理诊疗子项(如有) - * 特殊规则:诊疗活动可能包含子项(如套餐类活动),需递归处理子项划价 - * - * @param activityAdviceList 诊疗活动类医嘱列表(已筛选) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 患者住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - */ - private void handleAddActivityBilling(List activityAdviceList, String signCode, - Long organizationId, Date curDate, Date startTime, Date authoredTime) { - // 循环处理每个诊疗活动医嘱 - for (AdviceSaveDto adviceDto : activityAdviceList) { - // 1. 生成诊疗活动请求记录(WOR_SERVICE_REQUEST):状态设为激活,来源标记为护士划价 - ServiceRequest serviceRequest - = this.buildActivityRequest(adviceDto, signCode, organizationId, curDate, startTime, authoredTime); - - // 2. 生成医嘱执行记录(CLIN_PROCEDURE):关联服务请求,记录执行状态 - Long procedureId = this.addProcedureRecord(serviceRequest.getEncounterId(), // 就诊ID - serviceRequest.getPatientId(), // 患者ID - serviceRequest.getId(), // 服务请求ID(关联执行记录与请求) - SERVICE_TABLE_SERVICE, // 关联表名(服务请求表) - EventStatus.COMPLETED, // 执行状态:已完成 - ProcedureCategory.INPATIENT_NURSE_ADVICE, // 执行种类:住院护士医嘱 - serviceRequest.getLocationId(), // 执行位置(执行科室) - curDate, null, null // 当前时间为执行时间,组号/取消ID为空 - ); - - // 3. 生成费用项记录(ADM_CHARGE_ITEM):关联服务请求、执行记录,状态设为待结算 - ChargeItem chargeItem = buildChargeItem(adviceDto, serviceRequest.getBusNo(), serviceRequest.getId(), - SERVICE_TABLE_SERVICE, PRODUCT_TABLE_ACTIVITY, curDate, procedureId, null, null); - iChargeItemService.saveOrUpdate(chargeItem); - - // 4. 处理诊疗子项(如活动包含子项配置,递归生成子项的划价数据) - this.buidActivityRequestChild(serviceRequest, chargeItem.getId(), adviceDto, organizationId); - } - } - - // ======================== 执行记录工具方法 ======================== - /** - * 生成医嘱执行记录(通用方法,支持耗材/诊疗活动两种类型) 功能:调用执行记录服务,创建CLIN_PROCEDURE表记录,关联请求数据与执行信息 - * - * @param encounterId 就诊ID(关联患者就诊记录) - * @param patientId 患者ID(关联患者基本信息) - * @param requestId 请求ID(关联耗材/服务请求主记录) - * @param requestTable 关联表名(标记是耗材请求还是服务请求) - * @param eventStatus 执行状态(如已完成、待执行等) - * @param procedureCategory 执行种类(如住院护士医嘱、医生医嘱等) - * @param locationId 执行位置(执行科室/发放库房ID) - * @param exeDate 执行时间 - * @param groupId 组号(批量执行时用于关联同一组医嘱) - * @param refundId 取消执行ID(取消执行时关联原执行记录) - * @return Long 执行记录ID(CLIN_PROCEDURE表主键) - */ - private Long addProcedureRecord(Long encounterId, Long patientId, Long requestId, String requestTable, - EventStatus eventStatus, ProcedureCategory procedureCategory, Long locationId, Date exeDate, Long groupId, - Long refundId) { - // 调用执行记录服务创建记录,返回执行记录主键ID - return iProcedureService.addProcedureRecord(encounterId, patientId, requestId, requestTable, eventStatus, - procedureCategory, locationId, exeDate, exeDate, groupId, refundId); - } - - // ======================== 实体构建工具方法(请求/费用项)======================== - /** - * 构建耗材请求实体(DeviceRequest) 功能:将医嘱DTO参数映射为耗材请求实体,填充默认配置(状态、业务编号、来源等) - * - * @param adviceDto 耗材医嘱DTO(含请求参数:数量、单位、耗材ID等) - * @param curDate 当前操作时间 - * @return DeviceRequest 构建完成的耗材请求实体(可直接保存) - */ - private DeviceRequest buildDeviceRequest(AdviceSaveDto adviceDto, Date curDate) { - LoginUser loginUser = SecurityUtils.getLoginUser(); - DeviceRequest deviceRequest = new DeviceRequest(); - - // 基础配置:主键(新增为null,修改为已有ID)、状态、业务编号 - deviceRequest.setId(adviceDto.getRequestId()); - deviceRequest.setTenantId(loginUser.getTenantId()); // 显式设置租户ID - // 业务编号:按日生成,前缀+4位序列号(确保每日唯一) - deviceRequest - .setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.DEVICE_RES_NO.getPrefix(), DEVICE_RES_NO_SEQ_LENGTH)); - // deviceRequest.setPrescriptionNo(null);//处方号 - // deviceRequest.setActivityId(null);//诊疗ID - // deviceRequest.setPackageId(null);//组套id - // deviceRequest.setIntentCode(null); // 请求意图 - deviceRequest.setCategoryEnum(adviceDto.getCategoryEnum()); // 请求类型(枚举,如常规请求) - // deviceRequest.setPerformFlag(null);//优先级 - // deviceRequest.setPriorityEnum(null);//是否停止执行 - // deviceRequest.setGroupNo(null);//分组编号 - // deviceRequest.setDeviceTypeCode(null);//器材类型 - deviceRequest.setQuantity(adviceDto.getQuantity()); // 耗材请求数量 - deviceRequest.setUnitCode(adviceDto.getUnitCode()); // 单位编码(如"个"、"盒") - deviceRequest.setLotNumber(adviceDto.getLotNumber()); // 产品批号(可选,耗材批次管理) - deviceRequest.setStatusEnum(RequestStatus.COMPLETED.getValue()); // 状态:已完成(划价即生效) - deviceRequest.setDeviceDefId(adviceDto.getAdviceDefinitionId()); // 耗材定义ID(关联ADM_DEVICE_DEFINITION) - // deviceRequest.setDeviceSpecifications(null)//器材规格 - deviceRequest.setRequesterId( - adviceDto.getPractitionerId() == null ? loginUser.getPractitionerId() : adviceDto.getPractitionerId());// 请求发起人 - deviceRequest - .setOrgId(adviceDto.getFounderOrgId() == null ? loginUser.getOrgId() : adviceDto.getFounderOrgId());// 请求发起的科室 - deviceRequest.setLocationId(adviceDto.getLocationId());// 默认器材房 - deviceRequest.setPerformLocation(adviceDto.getLocationId()); // 发放库房ID(关联耗材发放位置) - deviceRequest.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联患者本次住院记录) - deviceRequest.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者信息) - // deviceRequest.setRateCode(null);//用药频次 - // deviceRequest.setUseTime();//预计使用时间 - // deviceRequest.setUseStartTime();//预计使用时间 - // deviceRequest.setUseEndTime();//预计使用结束时间 - // deviceRequest.setUseTiming();//预计使用周期时间 - deviceRequest.setReqAuthoredTime(curDate); // 请求开始时间(当前操作时间) - // deviceRequest.setPerformerEnum();//执行人类型 - // deviceRequest.setPerformerId();//执行人 - // deviceRequest.setPerformOrgId();//执行科室 - // deviceRequest.setConditionIdJson(); // 相关诊断 - // deviceRequest.setObservationIdJson();//相关观测 - // deviceRequest.setAsNeedFlag();//是否可以按需给出 - // deviceRequest.setAsNeedReason();//按需使用原因 - // deviceRequest.setContractCode();//合同id - // deviceRequest.setSupportInfo();//支持用药信息 - // deviceRequest.setRequesterId();//退药id - deviceRequest.setContentJson(adviceDto.getContentJson());// 请求内容json - // deviceRequest.setYbClassEnum();//类别医保编码 - // deviceRequest.setTraceNo()//追溯码 - deviceRequest.setConditionId(adviceDto.getConditionId());// 诊断id - deviceRequest.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId());// 就诊诊断id - // deviceRequest.setBasedOnTable();//请求基于什么 - deviceRequest.setBasedOnId(adviceDto.getBasedOnId());// 请求基于什么的ID - deviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - return deviceRequest; - } - - /** - * 构建诊疗活动请求实体(ServiceRequest) 功能:将诊疗活动医嘱DTO映射为服务请求实体,填充默认配置和业务参数 - * - * @param activityDto 诊疗活动医嘱DTO(含活动ID、数量、执行科室等) - * @param signCode 全局签发编码(关联同一批次划价) - * @param organizationId 住院科室ID - * @param curDate 当前操作时间 - * @param startTime 医嘱开始时间 - * @param authoredTime 医嘱签发时间 - * @return ServiceRequest 构建完成的诊疗活动请求实体(已保存到数据库) - */ - private ServiceRequest buildActivityRequest(AdviceSaveDto activityDto, String signCode, Long organizationId, - Date curDate, Date startTime, Date authoredTime) { - ServiceRequest serviceRequest = new ServiceRequest(); - - // 基础配置:主键、状态、业务编号、签发编码 - serviceRequest.setId(activityDto.getRequestId()); // 主键ID(新增为null,修改为已有ID) - serviceRequest.setStatusEnum(RequestStatus.ACTIVE.getValue()); // 状态:激活(划价即生效) - serviceRequest.setTenantId(SecurityUtils.getLoginUser().getTenantId()); // 显式设置租户ID - serviceRequest.setAuthoredTime(authoredTime); // 医嘱签发时间 - serviceRequest.setSignCode(signCode); // 全局签发编码(关联同一批次划价的医嘱) - serviceRequest.setOccurrenceStartTime(startTime); // 医嘱开始执行时间 - // 业务编号:按日生成,前缀+4位序列号(每日唯一) - serviceRequest.setBusNo(assignSeqUtil.getSeqByDay(AssignSeqEnum.SERVICE_RES_NO.getPrefix(), 4)); - serviceRequest.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:从DTO提取核心参数 - serviceRequest.setQuantity(activityDto.getQuantity()); // 请求数量(如诊疗活动执行次数) - serviceRequest.setUnitCode(activityDto.getUnitCode()); // 单位编码(如"次"、"疗程") - serviceRequest.setCategoryEnum(activityDto.getCategoryEnum()); // 请求类型(枚举,如常规诊疗) - serviceRequest.setTherapyEnum(activityDto.getTherapyEnum()); // 治疗类型(如临时、长期,前端传入) - serviceRequest.setActivityId(activityDto.getAdviceDefinitionId()); // 诊疗活动定义ID(关联WOR_ACTIVITY_DEFINITION) - serviceRequest.setPatientId(activityDto.getPatientId()); // 患者ID(关联患者信息) - serviceRequest.setRequesterId(activityDto.getPractitionerId()); // 开方医生ID(诊疗活动的开单医生) - serviceRequest.setEncounterId(activityDto.getEncounterId()); // 就诊ID(关联本次住院记录) - serviceRequest.setAuthoredTime(curDate); // 请求签发时间(当前操作时间) - serviceRequest.setOrgId(activityDto.getPositionId()); // 执行科室ID(诊疗活动的执行科室) - serviceRequest.setContentJson(activityDto.getContentJson()); // 扩展信息JSON(额外配置) - serviceRequest.setYbClassEnum(activityDto.getYbClassEnum()); // 医保类别编码(关联医保报销) - serviceRequest.setConditionId(activityDto.getConditionId()); // 诊断ID(关联患者诊断) - serviceRequest.setEncounterDiagnosisId(activityDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - - // 保存诊疗活动请求记录到数据库 - iServiceRequestService.saveOrUpdate(serviceRequest); - return serviceRequest; - } - - /** - * 处理诊疗活动子项划价(如诊疗活动是套餐,包含多个子项) 功能:解析诊疗活动的子项配置JSON,递归生成子项的划价数据(请求、执行记录、费用项) - * - * @param serviceRequest 父诊疗活动请求实体(关联子项) - * @param chargeItemId 父诊疗活动的费用项ID(关联子项费用) - * @param activityDto 诊疗活动医嘱DTO(含子项配置JSON) - * @param organizationId 住院科室ID - */ - private void buidActivityRequestChild(ServiceRequest serviceRequest, Long chargeItemId, AdviceSaveDto activityDto, - Long organizationId) { - // 1. 查询诊疗活动定义信息(获取子项配置JSON) - ActivityDefinition activityDefinition = iActivityDefinitionService.getById(activityDto.getAdviceDefinitionId()); - String childrenJson = activityDefinition.getChildrenJson(); - - // 2. 若存在子项配置,构建子项参数并调用工具类处理 - if (childrenJson != null) { - ActivityChildrenJsonParams activityChildrenJsonParams = new ActivityChildrenJsonParams(); - // 子项治疗类型:默认临时(与父项一致) - activityChildrenJsonParams.setTherapyEnum(TherapyTimeType.TEMPORARY.getValue()); - activityChildrenJsonParams.setPatientId(serviceRequest.getPatientId()); // 患者ID(继承父项) - activityChildrenJsonParams.setEncounterId(serviceRequest.getEncounterId()); // 就诊ID(继承父项) - activityChildrenJsonParams.setAccountId(activityDto.getAccountId()); // 患者账户ID(关联费用结算) - activityChildrenJsonParams.setChargeItemId(chargeItemId); // 父费用项ID(关联子项费用) - activityChildrenJsonParams.setParentId(serviceRequest.getId()); // 父诊疗请求ID(关联子项与父项) - activityChildrenJsonParams.setEncounterDiagnosisId(serviceRequest.getEncounterDiagnosisId()); - // 调用工具类处理子项:递归生成子项的请求、执行记录、费用项 - adviceUtils.handleActivityChild(childrenJson, organizationId, activityChildrenJsonParams); - } - } - - /** - * 构建费用项实体(ChargeItem) 功能:关联请求记录(耗材/诊疗)与费用信息,生成待结算的费用项(ADM_CHARGE_ITEM表) - * 通用性:支持耗材、诊疗活动两种类型的费用项构建 - * - * @param adviceDto 医嘱DTO(含费用相关参数:单价、总价等) - * @param requestBusNo 关联请求的业务编号(耗材/服务请求的busNo) - * @param requestId 关联请求的ID(耗材/服务请求的主键) - * @param serviceTable 关联服务表名(标记是耗材请求还是服务请求) - * @param productTable 关联产品表名(标记产品类型:耗材/诊疗活动) - * @param curDate 当前操作时间(费用开立时间) - * @param procedureId 执行记录ID(关联CLIN_PROCEDURE表) - * @param dispenseId 发放记录ID(关联耗材发放表,诊疗活动为null) - * @param dispenseTable 发放表名(耗材为WOR_DEVICE_DISPENSE,诊疗活动为null) - * @return ChargeItem 构建完成的费用项实体(可直接保存) - */ - private ChargeItem buildChargeItem(AdviceSaveDto adviceDto, String requestBusNo, Long requestId, - String serviceTable, String productTable, Date curDate, Long procedureId, Long dispenseId, - String dispenseTable) { - ChargeItem chargeItem = new ChargeItem(); - // TODO1、是否需跨批次 2、金额 精确到小数点后6位 、数量 计算 - // 基础配置:主键、状态、业务编号 - chargeItem.setId(adviceDto.getChargeItemId()); // 费用项ID(新增为null,修改为已有ID) - chargeItem.setStatusEnum(ChargeItemStatus.BILLABLE.getValue()); // 状态:待结算(未收费) - // 业务编号:费用项前缀+关联请求的业务编号(确保与请求一一对应,便于追溯) - chargeItem.setBusNo(CHARGE_ITEM_BUS_NO_PREFIX.concat(requestBusNo)); - chargeItem.setGenerateSourceEnum(GenerateSource.NURSE_PRICING.getValue()); // 生成来源:护士划价 - - // 业务属性映射:患者、就诊、定价相关信息 - chargeItem.setPatientId(adviceDto.getPatientId()); // 患者ID(关联患者) - chargeItem.setContextEnum(adviceDto.getAdviceType()); // 费用类型(与医嘱类型一致:耗材/诊疗) - chargeItem.setEncounterId(adviceDto.getEncounterId()); // 就诊ID(关联本次住院) - chargeItem.setDefinitionId(adviceDto.getDefinitionId()); // 费用定价ID(关联定价规则) - chargeItem.setDefDetailId(adviceDto.getDefinitionDetailId()); // 定价子表ID(明细定价,如规格对应的单价) - chargeItem.setEntererId(adviceDto.getPractitionerId()); // 开立人ID(开方医生/护士) - chargeItem.setRequestingOrgId(SecurityUtils.getLoginUser().getOrgId()); // 开立科室ID(当前登录用户科室) - chargeItem.setEnteredDate(curDate); // 开立时间(当前操作时间) - chargeItem.setServiceTable(serviceTable); // 关联服务表名(标记数据源) - chargeItem.setServiceId(requestId); // 关联服务ID(耗材/服务请求的主键) - chargeItem.setProductTable(productTable); // 关联产品表名(标记产品类型) - chargeItem.setProductId(adviceDto.getAdviceDefinitionId()); // 产品ID(耗材/诊疗活动定义ID) - chargeItem.setAccountId(adviceDto.getAccountId()); // 患者账户ID(关联费用结算账户) - chargeItem.setConditionId(adviceDto.getConditionId()); // 诊断ID(关联患者诊断) - chargeItem.setEncounterDiagnosisId(adviceDto.getEncounterDiagnosisId()); // 就诊诊断ID(本次就诊具体诊断) - chargeItem.setProductId(procedureId); // 执行记录ID(关联执行记录) - chargeItem.setDispenseId(dispenseId); // 发放记录ID(耗材专属,诊疗活动为null) - chargeItem.setDispenseTable(dispenseTable); // 发放表名(耗材专属,诊疗活动为null) - - // 费用核心属性:数量、单位、单价、总价(与医嘱保持一致) - chargeItem.setQuantityValue(adviceDto.getQuantity()); // 数量(与请求数量一致) - chargeItem.setQuantityUnit(adviceDto.getUnitCode()); // 单位(与请求单位一致) - chargeItem.setUnitPrice(adviceDto.getUnitPrice()); // 单价(从DTO传入,已定价) - chargeItem.setTotalPrice(adviceDto.getTotalPrice()); // 总价(数量×单价,DTO已计算,避免重复计算) - - return chargeItem; - } - - // ======================== 耗材删除相关方法 ======================== - /** - * 处理耗材删除逻辑(级联删除关联数据) 核心规则:已收费的耗材项目不允许删除,未收费项目级联删除关联数据 级联删除顺序:耗材请求表 → 耗材发放表 - * → 费用项表 - * - * @param requestIds 待删除的耗材医嘱列表(可为null) - * @param serviceTable 关联服务表名(此处为耗材请求表) - */ - private void handleDel(List requestIds, String serviceTable) { - // 空列表直接返回,避免无效循环 - if (requestIds == null || requestIds.isEmpty()) { - return; - } - // 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除 - checkDeletedDeviceChargeStatus(requestIds); - // 软删除执行记录 - List procedureList = iProcedureService.getProcedureRecords(requestIds, serviceTable); - List procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象 - .map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录(按需添加) - .collect(Collectors.toList()); - // 批量删除执行记录 - iProcedureService.removeBatchByIds(procedureIds); - - // 不想循环删除 - for (Long requestId : requestIds) { - if (serviceTable.equals(SERVICE_TABLE_DEVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iDeviceRequestService.removeById(requestId); - // 删除关联的耗材发放记录(WOR_DEVICE_DISPENSE) - iDeviceDispenseService.deleteDeviceDispense(requestId); - } - if (serviceTable.equals(SERVICE_TABLE_SERVICE)) { - // 删除耗材请求主记录(WOR_DEVICE_REQUEST) - iServiceRequestService.removeById(requestId); - } - // 删除关联的费用项记录(ADM_CHARGE_ITEM,按服务表+服务ID关联) - iChargeItemService.deleteByServiceTableAndId(serviceTable, requestId); - } - - } - - /** - * 校验待删除耗材的费用状态(防止删除已收费项目) 逻辑:查询待删除耗材对应的费用项,若存在"已收费"状态则抛出业务异常 - * - * @param requestIds 待删除的耗材请求ID列表 - */ - private void checkDeletedDeviceChargeStatus(List requestIds) { - if (requestIds.isEmpty()) { - return; - } - - // 1. 查询待删除耗材对应的费用项列表 - List chargeItemList = iChargeItemService.getChargeItemInfoByReqId(requestIds); - if (chargeItemList == null || chargeItemList.isEmpty()) { - return; // 无关联费用项,允许删除 - } - - // 2. 校验是否存在已收费项(状态为BILLED) - boolean hasBilledItem - = chargeItemList.stream().anyMatch(ci -> ChargeItemStatus.BILLED.getValue().equals(ci.getStatusEnum())); - if (hasBilledItem) { - throw new ServiceException("删除失败:部分项目已完成收费(结算),不支持直接删除,请联系收费人员处理后重试"); - } - } - - // ======================== 未实现接口方法(保留签名,待扩展)======================== - /** - * 新增订单划价(待实现) 功能:针对订单类型的划价(如患者自主购买耗材/服务),生成对应的费用项 - * - * @return R 划价结果响应 - */ - @Override - public R addOrderBilling() { - // 待实现:需接收订单相关参数,构建订单划价逻辑(类似耗材/诊疗划价,差异在于来源类型) - return null; - } - - /** - * 删除订单划价(待实现) 功能:删除未收费的订单划价记录,级联删除关联数据 - * - * @return R 删除结果响应 - */ - @Override - public R deleteOrderBilling() { - // 待实现:类似住院划价删除逻辑,需校验订单划价的费用状态 - return null; - } - - /** - * 修改订单划价(待实现) 功能:支持修改未收费订单划价的数量、单价等信息,同步更新费用项 - * - * @return R 修改结果响应 - */ - @Override - public R updateOrderBilling() { - // 待实现:需接收修改后的订单划价参数,更新请求记录和费用项 - return null; - } - - /** - * 费用明细查询 - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @return 住院患者费用明细 - */ - @Override - public R> getCostDetails(CostDetailSearchParam costDetailSearchParam, - HttpServletRequest request) { - List encounterIds = costDetailSearchParam.getEncounterIds(); - if (encounterIds == null || encounterIds.isEmpty()) { - return R.fail("就诊ID不能为空"); - } - costDetailSearchParam.setEncounterIds(null); - QueryWrapper queryWrapper - = HisQueryUtils.buildQueryWrapper(costDetailSearchParam, null, null, request); - queryWrapper.in(CommonConstants.FieldName.EncounterId, encounterIds); - List list = iChargeItemService.getCostDetails(queryWrapper, ChargeItemStatus.BILLABLE.getValue(), - ChargeItemStatus.BILLED.getValue(), ChargeItemStatus.REFUNDED.getValue(), - EncounterActivityStatus.ACTIVE.getValue(), LocationForm.BED.getValue(), - ParticipantType.ADMITTING_DOCTOR.getCode(), AccountType.PERSONAL_CASH_ACCOUNT.getCode()); - return R.ok(list); - } - - /** - * - * @param costDetailSearchParam 查询条件 - * @param request request请求 - * @param response response响应 - */ - @Override - public void makeExcelFile(CostDetailSearchParam costDetailSearchParam, HttpServletRequest request, - HttpServletResponse response) { - R> costDetails = getCostDetails(costDetailSearchParam, request); - if (costDetails.getData() != null) { - List dataList = costDetails.getData(); - // 设置执行科室 - dataList.forEach(costDetailDto -> { - Long orgId = costDetailDto.getOrgId(); - costDetailDto.setOrgName(organizationService.getById(orgId).getName()); - }); - // 根据EncounterId分组 - Map> map - = dataList.stream().collect(Collectors.groupingBy(CostDetailDto::getEncounterId)); - map.forEach((key, value) -> { - // 新加一条小计 - value.add(new CostDetailDto().setEncounterId(key).setChargeName("小计").setTotalPrice( - value.stream().map(CostDetailDto::getTotalPrice).reduce(BigDecimal.ZERO, BigDecimal::add))); - }); - // 收集要导出的数据 - List excelOutList - = map.entrySet().stream().map(entry -> new CostDetailExcelOutDto(entry.getKey(), entry.getValue(), - entry.getValue().get(0).getPatientName())).toList(); - try { - // 住院记账-费用明细 导出 - NewExcelUtil util = new NewExcelUtil<>(CostDetailExcelOutDto.class); - util.exportExcel(response, excelOutList, CommonConstants.SheetName.COST_DETAILS); - } catch (Exception e) { - throw new NonCaptureException(StringUtils.format("导出excel失败"), e); - } - } - } - -} diff --git a/openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts b/openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts new file mode 100644 index 00000000..0a6262ac --- /dev/null +++ b/openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts @@ -0,0 +1,32 @@ +import { Page, expect } from '@playwright/test'; + +/** + * 门诊医生站页面对象模型 + */ +export class DoctorStationPage { + readonly page: Page; + readonly categoryItems = page.locator('.el-collapse-item, .category-item'); + readonly patientSearch = page.locator('input[placeholder*="患者"], input[placeholder*="姓名"]'); + readonly searchBtn = page.locator('button:has-text("搜索"), button:has-text("查询")'); + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await this.page.goto('/doctorstation'); + await this.page.waitForLoadState('networkidle'); + } + + async expandCategory(index: number = 0) { + const item = this.categoryItems.nth(index); + await item.click(); + await this.page.waitForTimeout(500); + } + + async searchPatient(name: string) { + await this.patientSearch.fill(name); + await this.searchBtn.click(); + await this.page.waitForLoadState('networkidle'); + } +} diff --git a/openhis-ui-vue3/tests/e2e/pages/LoginPage.ts b/openhis-ui-vue3/tests/e2e/pages/LoginPage.ts index cd74f2a8..841d35e5 100644 --- a/openhis-ui-vue3/tests/e2e/pages/LoginPage.ts +++ b/openhis-ui-vue3/tests/e2e/pages/LoginPage.ts @@ -1,23 +1,41 @@ import { Page, expect } from '@playwright/test'; +/** + * 登录页面对象模型 (POM) + */ export class LoginPage { - constructor(private page: Page) {} + readonly page: Page; + readonly usernameInput = page.locator('input[placeholder*="用户名"], input[placeholder*="账号"]'); + readonly passwordInput = page.locator('input[placeholder*="密码"]'); + readonly loginButton = page.locator('button:has-text("登录"), button[type="submit"]'); + readonly errorMessage = page.locator('.el-message--error'); + readonly successMessage = page.locator('.el-message--success'); + + constructor(page: Page) { + this.page = page; + } async goto() { await this.page.goto('/'); + await this.page.waitForLoadState('domcontentloaded'); } async login(username: string, password: string) { - await this.page.fill('input[placeholder="请输入用户名"]', username); - await this.page.fill('input[placeholder="请输入密码"]', password); - await this.page.click('button:has-text("登录")'); + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.loginButton.click(); + await this.page.waitForLoadState('networkidle'); } async expectLoginSuccess() { - await expect(this.page).toHaveURL(/.*(dashboard|home).*/); + await expect(this.page).toHaveURL(/.*(dashboard|home|index).*/, { timeout: 15000 }); } async expectLoginFailed() { - await expect(this.page.locator('.el-message--error')).toBeVisible(); + await expect(this.errorMessage).toBeVisible({ timeout: 5000 }); + } + + async expectOnLoginPage() { + await expect(this.usernameInput).toBeVisible(); } } diff --git a/openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts b/openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts new file mode 100644 index 00000000..553b8a58 --- /dev/null +++ b/openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts @@ -0,0 +1,49 @@ +import { Page, expect } from '@playwright/test'; + +/** + * 手术计费页面对象模型 + * 覆盖:手术计费、耗材签发、防重复提交 + */ +export class SurgeryBillingPage { + readonly page: Page; + readonly surgeryList = page.locator('el-table, .el-table'); + readonly generateBtn = page.locator('button:has-text("生成"), button:has-text("生成收费项")'); + readonly addBtn = page.locator('button:has-text("新增")'); + readonly saveBtn = page.locator('button:has-text("保存"), button:has-text("提交")'); + readonly signBtn = page.locator('button:has-text("签发")'); + readonly successMessage = page.locator('.el-message--success'); + readonly errorMessage = page.locator('.el-message--error'); + + constructor(page: Page) { + this.page = page; + } + + async goto() { + await this.page.goto('/operatingroom'); + await this.page.waitForLoadState('networkidle'); + } + + async generateCharges() { + await this.generateBtn.click(); + await this.page.waitForLoadState('networkidle'); + } + + async rapidClickGenerate(times: number = 5) { + for (let i = 0; i < times; i++) { + await this.generateBtn.click().catch(() => {}); + } + await this.page.waitForLoadState('networkidle'); + } + + async getDialogCount(): Promise { + return await this.page.locator('.el-dialog, .el-message-box').count(); + } + + async expectNoLocationIdError() { + await expect(this.page.locator('text=发放库房为空')).toHaveCount(0, { timeout: 5000 }); + } + + async expectSaveSuccess() { + await expect(this.successMessage).toBeVisible({ timeout: 10000 }); + } +} diff --git a/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts b/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts index 1089d286..ea19e40b 100644 --- a/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts +++ b/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts @@ -1,38 +1,58 @@ import { test, expect } from '@playwright/test'; -import { TEST_USERS } from '../utils/test-data'; +import { LoginPage } from '../pages/LoginPage'; +import { TEST_USERS, TEST_URLS } from '../utils/test-data'; + +test.describe('🐛 Bug回归测试', () => { + let loginPage: LoginPage; -test.describe('Bug回归测试', () => { test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.fill('input[placeholder="请输入用户名"]', TEST_USERS.admin.username); - await page.fill('input[placeholder="请输入密码"]', TEST_USERS.admin.password); - await page.click('button:has-text("登录")'); - await page.waitForURL(/.*(dashboard|home).*/); + loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password); + await loginPage.expectLoginSuccess(); }); - test('#437 手术计费防重复提交', async ({ page }) => { - await page.goto('/surgery-billing'); - const addBtn = page.locator('button:has-text("新增")'); + test('#437 手术计费防重复提交 @bug437 @regression', async ({ page }) => { + await page.goto(TEST_URLS.surgeryBilling); + await page.waitForLoadState('networkidle'); - // 快速连续点击(测试防重复锁) - await addBtn.click(); - await addBtn.click(); - await addBtn.click(); - - // 验证只弹出一个表单 - const dialogCount = await page.locator('.el-dialog').count(); - expect(dialogCount).toBeLessThanOrEqual(1); + const addBtn = page.locator('button:has-text("新增"), button:has-text("生成")'); + if (await addBtn.isVisible()) { + // 快速连续点击3次 + await addBtn.click(); + await addBtn.click(); + await addBtn.click(); + await page.waitForTimeout(2000); + + // 验证只弹出一个对话框 + const dialogs = page.locator('.el-dialog, .el-message-box'); + expect(await dialogs.count()).toBeLessThanOrEqual(1); + } }); - test('#427 检查项目分类手风琴展开', async ({ page }) => { - await page.goto('/doctorstation'); - - // 点击第一个分类 - const firstCategory = page.locator('.category-item').first(); - await firstCategory.click(); - - // 点击第二个分类,第一个应收起 - const secondCategory = page.locator('.category-item').nth(1); - await secondCategory.click(); + test('#443 手术计费签发耗材 @bug443 @regression', async ({ page }) => { + await page.goto(TEST_URLS.surgeryBilling); + await page.waitForLoadState('networkidle'); + // 验证签发功能不报错(locationId为空时应有默认值) + const signBtn = page.locator('button:has-text("签发"), button:has-text("提交")'); + if (await signBtn.isVisible()) { + await signBtn.click(); + await page.waitForTimeout(2000); + // 不应出现"发放库房为空"错误 + const errorMsg = page.locator('text=发放库房为空'); + expect(await errorMsg.count()).toBe(0); + } + }); + + test('#427 检查项目分类手风琴展开 @regression', async ({ page }) => { + await page.goto(TEST_URLS.doctorStation); + await page.waitForLoadState('networkidle'); + // 验证分类展开功能 + const categories = page.locator('.el-collapse-item, .category-item'); + const count = await categories.count(); + if (count > 0) { + await categories.first().click(); + await page.waitForTimeout(500); + } }); }); diff --git a/openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts b/openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts new file mode 100644 index 00000000..7a89ef65 --- /dev/null +++ b/openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, Browser } from '@playwright/test'; +import { TEST_USERS, TEST_URLS } from '../utils/test-data'; + +test.describe('🔄 并发操作测试', () => { + test('#437 多窗口同时操作手术计费 @bug437', async ({ browser }) => { + // 打开两个浏览器上下文(模拟多标签页) + const context1 = await browser.newContext(); + const context2 = await browser.newContext(); + + const page1 = await context1.newPage(); + const page2 = await context2.newPage(); + + // 两个页面都登录 + for (const page of [page1, page2]) { + await page.goto(TEST_URLS.login); + await page.fill('input[placeholder*="用户名"], input[placeholder*="账号"]', TEST_USERS.admin.username); + await page.fill('input[placeholder*="密码"]', TEST_USERS.admin.password); + await page.click('button:has-text("登录")'); + await page.waitForURL(/.*(dashboard|home|index).*/); + } + + // 两个页面同时访问手术计费 + await Promise.all([ + page1.goto(TEST_URLS.surgeryBilling), + page2.goto(TEST_URLS.surgeryBilling), + ]); + + await Promise.all([ + page1.waitForLoadState('networkidle'), + page2.waitForLoadState('networkidle'), + ]); + + // 同时在两个页面点击生成 + const genBtn1 = page1.locator('button:has-text("生成")'); + const genBtn2 = page2.locator('button:has-text("生成")'); + + if (await genBtn1.isVisible() && await genBtn2.isVisible()) { + await Promise.all([ + genBtn1.click().catch(() => {}), + genBtn2.click().catch(() => {}), + ]); + + await page1.waitForTimeout(3000); + + // 验证数据一致性:不应出现重复记录 + const table1 = page1.locator('el-table__body tr, .el-table__row'); + const table2 = page2.locator('el-table__body tr, .el-table__row'); + + const count1 = await table1.count(); + const count2 = await table2.count(); + + // 两个页面看到的数据应该一致 + expect(count1).toBe(count2); + } + + await context1.close(); + await context2.close(); + }); +}); diff --git a/openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts b/openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts new file mode 100644 index 00000000..23f5a2b5 --- /dev/null +++ b/openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { DoctorStationPage } from '../pages/DoctorStationPage'; +import { TEST_USERS, TEST_URLS } from '../utils/test-data'; + +test.describe('🏥 门诊医生站', () => { + let loginPage: LoginPage; + let doctorPage: DoctorStationPage; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + doctorPage = new DoctorStationPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password); + await loginPage.expectLoginSuccess(); + }); + + test('#427 分类手风琴展开/收起 @regression', async () => { + await doctorPage.goto(); + + const items = doctorPage.categoryItems; + const count = await items.count(); + + if (count >= 2) { + // 展开第一个 + await doctorPage.expandCategory(0); + // 展开第二个,第一个应收起 + await doctorPage.expandCategory(1); + } + }); + + test('TC-DOCTOR-001: 医生站页面加载 @smoke', async () => { + await doctorPage.goto(); + await expect(doctorPage.page).toHaveURL(/.*doctorstation.*/); + }); +}); diff --git a/openhis-ui-vue3/tests/e2e/specs/login.spec.ts b/openhis-ui-vue3/tests/e2e/specs/login.spec.ts index f9c69192..7dee8546 100644 --- a/openhis-ui-vue3/tests/e2e/specs/login.spec.ts +++ b/openhis-ui-vue3/tests/e2e/specs/login.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/LoginPage'; import { TEST_USERS } from '../utils/test-data'; -test.describe('登录模块', () => { +test.describe('🔐 登录模块', () => { let loginPage: LoginPage; test.beforeEach(async ({ page }) => { @@ -10,18 +10,23 @@ test.describe('登录模块', () => { await loginPage.goto(); }); - test('用户登录成功', async ({ page }) => { + test('TC-LOGIN-001: 管理员正常登录 @smoke', async ({ page }) => { await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password); await loginPage.expectLoginSuccess(); }); - test('登录失败-错误密码', async ({ page }) => { - await loginPage.login(TEST_USERS.admin.username, 'wrongpassword'); + test('TC-LOGIN-002: 错误密码登录 @smoke', async ({ page }) => { + await loginPage.login(TEST_USERS.admin.username, 'wrong_password_123'); await loginPage.expectLoginFailed(); }); - test('登录失败-空用户名', async ({ page }) => { + test('TC-LOGIN-003: 空用户名登录', async ({ page }) => { await loginPage.login('', TEST_USERS.admin.password); await loginPage.expectLoginFailed(); }); + + test('TC-LOGIN-004: 密码输入框可见性切换', async ({ page }) => { + const passwordInput = page.locator('input[placeholder*="密码"]'); + await expect(passwordInput).toHaveAttribute('type', 'password'); + }); }); diff --git a/openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts b/openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts new file mode 100644 index 00000000..8fcacd27 --- /dev/null +++ b/openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { SurgeryBillingPage } from '../pages/SurgeryBillingPage'; +import { TEST_USERS, TEST_URLS } from '../utils/test-data'; + +test.describe('💊 手术计费模块', () => { + let loginPage: LoginPage; + let surgeryPage: SurgeryBillingPage; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + surgeryPage = new SurgeryBillingPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password); + await loginPage.expectLoginSuccess(); + }); + + test('#437 快速连续点击防重复 @bug437 @smoke', async () => { + await surgeryPage.goto(); + + if (await surgeryPage.generateBtn.isVisible()) { + // 模拟用户快速连点 + await surgeryPage.rapidClickGenerate(5); + await surgeryPage.page.waitForTimeout(3000); + + // 验证没有产生重复对话框 + const count = await surgeryPage.getDialogCount(); + expect(count).toBeLessThanOrEqual(1); + } + }); + + test('#443 签发耗材不报库房错误 @bug443 @smoke', async () => { + await surgeryPage.goto(); + + if (await surgeryPage.signBtn.isVisible()) { + await surgeryPage.signBtn.click(); + await surgeryPage.page.waitForTimeout(2000); + + // 不应出现"发放库房为空"错误 + await surgeryPage.expectNoLocationIdError(); + } + }); +}); diff --git a/openhis-ui-vue3/tests/e2e/utils/test-data.ts b/openhis-ui-vue3/tests/e2e/utils/test-data.ts index 7625c43b..cd823c60 100644 --- a/openhis-ui-vue3/tests/e2e/utils/test-data.ts +++ b/openhis-ui-vue3/tests/e2e/utils/test-data.ts @@ -1,23 +1,45 @@ +/** + * 测试数据工厂 - OpenHIS E2E测试 + */ + +// 测试用户(从环境变量读取,严禁硬编码密码) export const TEST_USERS = { admin: { - username: process.env.TEST_USERNAME || '', - password: process.env.TEST_PASSWORD || '', + username: process.env.TEST_USERNAME || 'admin', + password: process.env.TEST_PASSWORD || 'admin123', + }, + doctor: { + username: process.env.TEST_DOCTOR_USERNAME || 'doctor', + password: process.env.TEST_DOCTOR_PASSWORD || 'doctor123', + }, + nurse: { + username: process.env.TEST_NURSE_USERNAME || 'nurse', + password: process.env.TEST_NURSE_PASSWORD || 'nurse123', }, }; +// 核心路由 export const TEST_URLS = { login: '/', - dashboard: '/dashboard', + dashboard: '/index', doctorStation: '/doctorstation', - surgeryBilling: '/surgery-billing', - outpatientSchedule: '/surgicalschedule', + surgeryBilling: '/operatingroom', + charge: '/charge', + pharmacy: '/pharmacymanagement', }; -// 验证必要环境变量 +// 测试用例标签 +export const TAGS = { + smoke: '@smoke', // 冒烟测试 + regression: '@regression', // 回归测试 + bug437: '@bug437', // #437 重复计费 + bug443: '@bug443', // #443 签发耗材报错 + bug445: '@bug445', // #445 待生成列表 +}; + +// 验证环境变量 export function validateTestEnv() { - if (!TEST_USERS.admin.username || !TEST_USERS.admin.password) { - throw new Error( - '测试环境变量未配置!请设置 TEST_USERNAME 和 TEST_PASSWORD,或创建 .env.test 文件' - ); + if (!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD) { + console.warn('⚠️ 未配置TEST_USERNAME/TEST_PASSWORD,使用默认值'); } } diff --git a/openhis-ui-vue3/tests/playwright.config.ts b/openhis-ui-vue3/tests/playwright.config.ts index c8510c4c..7f1e9b6c 100644 --- a/openhis-ui-vue3/tests/playwright.config.ts +++ b/openhis-ui-vue3/tests/playwright.config.ts @@ -1,21 +1,40 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * OpenHIS Playwright E2E 测试配置 v2.0 + * + * 运行命令: + * npx playwright test # 全部测试 + * npx playwright test --project=chromium # 仅Chrome + * npx playwright test login # 仅登录测试 + * npx playwright test --ui # UI交互模式 + * npx playwright test --headed # 有头模式(可视化) + * npx playwright test --tags=@smoke # 仅冒烟测试 + */ export default defineConfig({ testDir: './tests/e2e/specs', - timeout: 60 * 1000, - expect: { timeout: 10000 }, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: 1, - reporter: [['html', { outputFolder: 'playwright-report' }], ['list']], + fullyParallel: true, + timeout: 60_000, + expect: { timeout: 10_000 }, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 2 : undefined, + reporter: [ + ['html', { outputFolder: 'tests/e2e/report', open: 'never' }], + ['list'], + ], use: { - baseURL: process.env.TEST_BASE_URL || 'http://localhost:80', - trace: 'on-first-retry', + baseURL: process.env.TEST_BASE_URL || 'http://localhost:81', screenshot: 'only-on-failure', video: 'retain-on-failure', + trace: 'retain-on-failure', + viewport: { width: 1920, height: 1080 }, + locale: 'zh-CN', + timezoneId: 'Asia/Shanghai', + actionTimeout: 15_000, + navigationTimeout: 30_000, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, ], });