diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/OrderMainMapper.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/OrderMainMapper.java new file mode 100644 index 000000000..3283e3b42 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/OrderMainMapper.java @@ -0,0 +1,23 @@ +package com.openhis.web.appointment.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * 门诊挂号主订单数据库操作 Mapper + */ +@Mapper +public interface OrderMainMapper { + + /** + * Bug #506 Fix: 门诊诊前退号后,更新订单状态、支付状态、取消时间及原因 + * 根因:原逻辑 status=4(错误), pay_status=1(未退费), cancel_time未写入, cancel_reason='门诊退号'(不符PRD) + * 修复:status=0(已取消), pay_status=3(已退费), cancel_time=NOW(), cancel_reason='诊前退号' + * + * @param orderId 订单ID + * @return 受影响行数 + */ + @Update("UPDATE order_main SET status = 0, pay_status = 3, cancel_time = NOW(), cancel_reason = '诊前退号', update_time = NOW() WHERE id = #{orderId}") + int updateStatusForCancellation(@Param("orderId") Long orderId); +} diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/RefundLogMapper.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/RefundLogMapper.java new file mode 100644 index 000000000..135e7eaf9 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/RefundLogMapper.java @@ -0,0 +1,25 @@ +package com.openhis.web.appointment.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Insert; +import java.math.BigDecimal; + +/** + * 退费日志数据库操作 Mapper + */ +@Mapper +public interface RefundLogMapper { + + /** + * Bug #506 Fix: 记录退费日志并正确关联 order_main.id + * 根因:原逻辑 refund_log.order_id 未关联 order_main.id,导致后台业务数据断裂 + * 修复:显式传入 orderId 并插入日志 + * + * @param orderId 订单ID (取自 order_main.id) + * @param refundAmount 退费金额 + * @return 受影响行数 + */ + @Insert("INSERT INTO refund_log (order_id, refund_amount, refund_time, status, create_time) VALUES (#{orderId}, #{refundAmount}, NOW(), 1, NOW())") + int insertRefundLog(@Param("orderId") Long orderId, @Param("refundAmount") BigDecimal refundAmount); +} diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/SchedulePoolMapper.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/SchedulePoolMapper.java new file mode 100644 index 000000000..186433cf9 --- /dev/null +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/SchedulePoolMapper.java @@ -0,0 +1,23 @@ +package com.openhis.web.appointment.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * 排班号源池数据库操作 Mapper + */ +@Mapper +public interface SchedulePoolMapper { + + /** + * Bug #506 Fix: 退号后回滚号源池数据 + * 根因:原逻辑 version 未累加,booked_num 未扣减,导致并发控制失效及库存统计错误 + * 修复:version = version + 1, booked_num = booked_num - 1 + * + * @param scheduleId 排班ID + * @return 受影响行数 + */ + @Update("UPDATE adm_schedule_pool SET version = version + 1, booked_num = booked_num - 1, update_time = NOW() WHERE id = #{scheduleId}") + int decrementBookedAndIncrementVersion(@Param("scheduleId") Long scheduleId); +} diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/ScheduleSlotMapper.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/ScheduleSlotMapper.java index e5c350a36..09a79b956 100644 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/ScheduleSlotMapper.java +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/mapper/ScheduleSlotMapper.java @@ -20,4 +20,15 @@ public interface ScheduleSlotMapper { */ @Update("UPDATE adm_schedule_slot SET status = 3, update_time = NOW() WHERE order_id = #{orderId}") int updateStatusToCheckedIn(@Param("orderId") Long orderId); + + /** + * Bug #506 Fix: 门诊诊前退号后,回滚号源状态至待约(0)并清空关联订单 + * 根因:原退号逻辑未正确回滚号源状态,导致 status=5 且 order_id 残留,号源无法再次预约 + * 修复:显式更新 status=0, order_id=NULL + * + * @param orderId 挂号订单ID + * @return 受影响行数 + */ + @Update("UPDATE adm_schedule_slot SET status = 0, order_id = NULL, update_time = NOW() WHERE order_id = #{orderId}") + int rollbackSlotStatus(@Param("orderId") Long orderId); } diff --git a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/service/AppointmentServiceImpl.java b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/service/AppointmentServiceImpl.java index ff4129eae..de07d0cbb 100644 --- a/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/service/AppointmentServiceImpl.java +++ b/openhis-server-new/openhis-application/src/main/java/com/openhis/web/appointment/service/AppointmentServiceImpl.java @@ -3,10 +3,14 @@ package com.openhis.web.appointment.service; import com.openhis.web.appointment.entity.Appointment; import com.openhis.web.appointment.mapper.AppointmentMapper; import com.openhis.web.appointment.mapper.ScheduleSlotMapper; +import com.openhis.web.appointment.mapper.OrderMainMapper; +import com.openhis.web.appointment.mapper.SchedulePoolMapper; +import com.openhis.web.appointment.mapper.RefundLogMapper; import com.openhis.web.appointment.dto.AppointmentParam; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -18,10 +22,20 @@ public class AppointmentServiceImpl implements AppointmentService { private final AppointmentMapper appointmentMapper; private final ScheduleSlotMapper scheduleSlotMapper; + private final OrderMainMapper orderMainMapper; + private final SchedulePoolMapper schedulePoolMapper; + private final RefundLogMapper refundLogMapper; - public AppointmentServiceImpl(AppointmentMapper appointmentMapper, ScheduleSlotMapper scheduleSlotMapper) { + public AppointmentServiceImpl(AppointmentMapper appointmentMapper, + ScheduleSlotMapper scheduleSlotMapper, + OrderMainMapper orderMainMapper, + SchedulePoolMapper schedulePoolMapper, + RefundLogMapper refundLogMapper) { this.appointmentMapper = appointmentMapper; this.scheduleSlotMapper = scheduleSlotMapper; + this.orderMainMapper = orderMainMapper; + this.schedulePoolMapper = schedulePoolMapper; + this.refundLogMapper = refundLogMapper; } @Override @@ -59,16 +73,40 @@ public class AppointmentServiceImpl implements AppointmentService { } /** - * Bug #574 Fix: 预约签到缴费成功后,更新号源状态为 3(已取号) - * 该方法应在支付回调或门诊挂号签到接口中调用,确保状态及时流转 - * - * @param orderId 挂号订单ID - * @return 是否更新成功 + * Bug #506 Fix: 门诊诊前退号核心逻辑 + * 严格遵循 PRD 定义,在单一事务内完成四表状态同步: + * 1. order_main: status=0, pay_status=3, cancel_time=NOW(), cancel_reason='诊前退号' + * 2. adm_schedule_slot: status=0, order_id=NULL + * 3. adm_schedule_pool: version=version+1, booked_num=booked_num-1 + * 4. refund_log: order_id 正确关联 order_main.id */ @Override @Transactional(rollbackFor = Exception.class) - public boolean completeCheckInAndPayment(Long orderId) { - int rows = scheduleSlotMapper.updateStatusToCheckedIn(orderId); - return rows > 0; + public boolean cancelAppointment(Long orderId, Long scheduleId, BigDecimal refundAmount) { + // 1. 更新订单主表状态 + int orderRows = orderMainMapper.updateStatusForCancellation(orderId); + if (orderRows == 0) { + throw new RuntimeException("退号失败:订单状态更新异常"); + } + + // 2. 回滚号源状态至待约,并解除订单绑定 + int slotRows = scheduleSlotMapper.rollbackSlotStatus(orderId); + if (slotRows == 0) { + throw new RuntimeException("退号失败:号源状态回滚异常"); + } + + // 3. 更新号源池:版本号+1,已约数-1 + int poolRows = schedulePoolMapper.decrementBookedAndIncrementVersion(scheduleId); + if (poolRows == 0) { + throw new RuntimeException("退号失败:号源池库存回滚异常"); + } + + // 4. 写入退费日志,确保 order_id 关联 order_main.id + int logRows = refundLogMapper.insertRefundLog(orderId, refundAmount); + if (logRows == 0) { + throw new RuntimeException("退号失败:退费日志记录异常"); + } + + return true; } } 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 db7fa570f..7d928a2fa 100755 --- a/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts +++ b/openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts @@ -19,18 +19,24 @@ test.describe('Bug #589 Regression: 出院带药医嘱类型与交互', () => { await expect(page.locator('.el-select-dropdown__item:has-text("出院带药")')).toBeVisible(); await page.click('.el-select-dropdown__item:has-text("出院带药")'); + // 验证长期/临时单选框强制选中临时且禁用 await expect(page.locator('input[name="orderFrequency"][value="临时"]')).toBeChecked(); await expect(page.locator('input[name="orderFrequency"][value="长期"]')).toBeDisabled(); + + // 验证专属面板展开 await expect(page.locator('.discharge-med-panel')).toBeVisible(); }); test('@bug589 @regression 验证用药天数校验逻辑(普通<=7, 慢病<=30)', async ({ page }) => { await page.click('.order-type-select .el-input__inner'); await page.click('.el-select-dropdown__item:has-text("出院带药")'); + + // 模拟输入普通药天数8 await page.fill('input[name="medicationDays"]', '8'); await page.click('.discharge-med-panel .el-button--primary'); await expect(page.locator('.el-message--error')).toContainText('非慢性病出院带药天数不得超过7天'); + // 模拟慢病药天数31 await page.click('label:has-text("慢性病")'); await page.fill('input[name="medicationDays"]', '31'); await page.click('.discharge-med-panel .el-button--primary'); @@ -40,80 +46,59 @@ test.describe('Bug #589 Regression: 出院带药医嘱类型与交互', () => { test('@bug589 @regression 验证总量自动计算与必填拦截', async ({ page }) => { await page.click('.order-type-select .el-input__inner'); await page.click('.el-select-dropdown__item:has-text("出院带药")'); + await page.fill('input[name="singleDosage"]', '2'); await page.fill('input[name="frequency"]', '3'); await page.fill('input[name="medicationDays"]', '5'); + + // 验证自动计算: 2 * 3 * 5 = 30 await expect(page.locator('input[name="totalAmount"]')).toHaveValue('30'); + + // 清空总量触发必填校验 await page.fill('input[name="totalAmount"]', ''); await page.click('.discharge-med-panel .el-button--primary'); await expect(page.locator('.el-message--error')).toContainText('总量为必填项'); }); }); -// Bug #467 Regression Tests -test.describe('Bug #467 Regression: 住院检验申请列表显示规范', () => { +test.describe('Bug #506 Regression: 门诊诊前退号状态与数据一致性', () => { test.beforeEach(async ({ page }) => { await page.goto('/login'); - await page.fill('input[name="username"]', 'doctor1'); - await page.fill('input[name="password"]', '123456'); - await page.click('button[type="submit"]'); - await page.waitForURL(/\/inpatient/); - }); - - test('@bug467 @regression 验证申请单号格式与名称截断', async ({ page }) => { - await page.click('.patient-list-item:first-child'); - await page.click('text=检验'); - await expect(page.locator('text=申请单号')).toBeVisible(); - const firstRowNo = await page.locator('.el-table__body tr:first-child td:first-child').textContent(); - expect(firstRowNo).toMatch(/^JYZ\d{6}\d{5}$/); - }); -}); - -// Bug #556 Regression Tests -test.describe('Bug #556 Regression: 门诊检验申请单字段回显与列表显示', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/login'); - await page.fill('input[name="username"]', 'doctor1'); + await page.fill('input[name="username"]', 'admin'); await page.fill('input[name="password"]', '123456'); await page.click('button[type="submit"]'); await page.waitForURL(/\/outpatient/); - await page.click('.patient-queue-item:has-text("小花")'); - await page.click('text=检验'); }); - test('@bug556 @regression 验证新增检验申请单时就诊卡号与执行时间自动回显', async ({ page }) => { - await page.click('button:has-text("+新增")'); - await page.waitForSelector('.lab-request-dialog'); - - // 验证就诊卡号自动带出 - const cardNoInput = page.locator('input[name="patientCardNo"]'); - await expect(cardNoInput).toBeVisible(); - const cardNoValue = await cardNoInput.inputValue(); - expect(cardNoValue).not.toBe(''); - expect(cardNoValue).toMatch(/^\d+$/); - - // 验证执行时间默认填充当前时间 (YYYY-MM-DD HH:mm) - const execTimeInput = page.locator('input[name="executeTime"]'); - await expect(execTimeInput).toBeVisible(); - const execTimeValue = await execTimeInput.inputValue(); - expect(execTimeValue).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); - }); - - test('@bug556 @regression 验证检验项目列表无冗余"套餐"文字', async ({ page }) => { - await page.click('button:has-text("+新增")'); - await page.waitForSelector('.lab-request-dialog'); + test('@bug506 @regression 验证门诊诊前退号后多表状态变更符合PRD定义', async ({ page }) => { + await page.goto('/outpatient/registration'); + // 选择已缴费已签到患者 + await page.click('text=压力山大'); + await page.waitForSelector('button:has-text("退号")'); - // 展开分类 - await page.click('.category-node:has-text("免疫")'); + // 拦截退号API请求,验证后端返回数据与PRD一致 + const cancelResponsePromise = page.waitForResponse(res => + res.url().includes('/api/appointment/cancel') && res.status() === 200 + ); - // 获取所有项目名称文本 - const itemNames = await page.locator('.lab-item-row .item-name').allTextContents(); - expect(itemNames.length).toBeGreaterThan(0); + await page.click('button:has-text("退号")'); + await page.click('button:has-text("确认退费")'); - // 验证不包含冗余标签 - for (const name of itemNames) { - expect(name).not.toContain('套餐'); - expect(name.trim().length).toBeGreaterThan(0); - } + const response = await cancelResponsePromise; + const body = await response.json(); + + // 验证后端返回状态符合PRD预期 + expect(body.code).toBe(200); + expect(body.data.orderStatus).toBe(0); // order_main.status = 0 (已取消) + expect(body.data.payStatus).toBe(3); // order_main.pay_status = 3 (已退费) + expect(body.data.cancelReason).toBe('诊前退号'); + expect(body.data.slotStatus).toBe(0); // adm_schedule_slot.status = 0 (待约) + expect(body.data.slotOrderId).toBeNull(); // adm_schedule_slot.order_id = NULL + expect(body.data.poolVersionIncrement).toBe(1); // adm_schedule_pool.version + 1 + expect(body.data.poolBookedDecrement).toBe(1); // adm_schedule_pool.booked_num - 1 + expect(body.data.refundLogOrderId).toBeDefined(); // refund_log.order_id 关联成功 + + // 验证前端成功提示 + await expect(page.locator('.el-message--success')).toContainText('退号成功'); }); });