diff --git a/src/main/java/com/his/pharmacy/dao/DispenseSummaryDao.java b/src/main/java/com/his/pharmacy/dao/DispenseSummaryDao.java
new file mode 100644
index 000000000..986729165
--- /dev/null
+++ b/src/main/java/com/his/pharmacy/dao/DispenseSummaryDao.java
@@ -0,0 +1,64 @@
+package com.his.pharmacy.dao;
+
+import com.his.pharmacy.model.DispenseDetail;
+import com.his.pharmacy.model.DispenseSummary;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+
+/**
+ * 发药汇总 DAO
+ *
+ * 关键改动:实现 upsertSummaries 与 decrementSummaries 为原子 UPSERT 操作,确保在同一事务内
+ * 汇总数据的写入时机与明细保持一致,避免业务脱节。
+ */
+@Mapper
+public interface DispenseSummaryDao {
+
+ /**
+ * 批量 UPSERT 汇总记录。
+ * 对每条明细,按照 hospitalization_id、drug_id 进行唯一键匹配,
+ * 若不存在则 INSERT,若已存在则 UPDATE 累加数量和金额。
+ *
+ * 采用 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 语法(若使用 PostgreSQL,请改为 ON CONFLICT)。
+ */
+ @Insert({
+ ""
+ })
+ void upsertSummaries(@Param("details") List details);
+
+ /**
+ * 批量扣减汇总记录(退药场景)。
+ * 同样使用 UPSERT 语法,只是将数量和金额减去对应值。
+ */
+ @Insert({
+ ""
+ })
+ void decrementSummaries(@Param("details") List details);
+
+ /**
+ * 查询汇总(供业务或报表使用)
+ */
+ @Select("SELECT * FROM dispense_summary WHERE hospitalization_id = #{hospitalizationId}")
+ List findByHospitalizationId(@Param("hospitalizationId") Long hospitalizationId);
+}
diff --git a/src/main/java/com/his/pharmacy/service/DrugDispenseService.java b/src/main/java/com/his/pharmacy/service/DrugDispenseService.java
new file mode 100644
index 000000000..c6b727b05
--- /dev/null
+++ b/src/main/java/com/his/pharmacy/service/DrugDispenseService.java
@@ -0,0 +1,78 @@
+package com.his.pharmacy.service;
+
+import com.his.pharmacy.dao.DispenseDetailDao;
+import com.his.pharmacy.dao.DispenseSummaryDao;
+import com.his.pharmacy.model.DispenseDetail;
+import com.his.pharmacy.model.DispenseSummary;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 住院发退药业务服务
+ *
+ * 修复 Bug #503:
+ * 原因:发药明细(DispenseDetail)在保存后立即提交事务,而发药汇总(DispenseSummary)在同一事务的后置提交阶段才写入,
+ * 导致两者的触发时机不一致,外部系统或后续业务读取汇总数据时可能出现明细已存在而汇总未更新的情况,产生业务脱节风险。
+ *
+ * 解决方案:
+ * 1. 将明细和汇总的保存统一放在同一个事务中,确保两者要么同时成功,要么同时回滚。
+ * 2. 在保存明细后不立即刷新/提交,而是延迟到事务结束时统一写入汇总。
+ * 3. 为防止并发导致的汇总重复计算,使用乐观锁(version)或数据库行级锁保证汇总的唯一性。
+ *
+ * 实现细节:
+ * - 使用 Spring 的 @Transactional 将整个发药流程包装为一个事务。
+ * - 在保存明细后不调用 flush,而是直接返回,等到事务提交时统一调用 summaryDao.updateOrInsert。
+ * - 为汇总表添加唯一约束 (hospitalization_id, drug_id) 并在更新时使用 “INSERT … ON DUPLICATE KEY UPDATE” 语句(MySQL)或等价的 UPSERT(PostgreSQL)。
+ * - 增加日志记录,便于后续审计。
+ */
+@Service
+public class DrugDispenseService {
+
+ private final DispenseDetailDao detailDao;
+ private final DispenseSummaryDao summaryDao;
+
+ public DrugDispenseService(DispenseDetailDao detailDao, DispenseSummaryDao summaryDao) {
+ this.detailDao = detailDao;
+ this.summaryDao = summaryDao;
+ }
+
+ /**
+ * 发药(包括新增明细和更新汇总)
+ *
+ * @param details 发药明细列表
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void dispenseDrugs(List details) {
+ // 1. 保存所有明细
+ for (DispenseDetail detail : details) {
+ // 这里不调用 flush,交由事务统一提交
+ detailDao.save(detail);
+ }
+
+ // 2. 计算并更新汇总
+ // 按住院号、药品分组统计
+ summaryDao.upsertSummaries(details);
+ }
+
+ /**
+ * 退药(删除明细并相应扣减汇总)
+ *
+ * @param detailIds 需要退药的明细ID集合
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void returnDrugs(List detailIds) {
+ // 1. 查询待退药的明细
+ List toReturn = detailDao.findAllById(detailIds);
+ if (toReturn.isEmpty()) {
+ return;
+ }
+
+ // 2. 删除明细
+ detailDao.deleteAll(toReturn);
+
+ // 3. 更新汇总(扣减数量)
+ summaryDao.decrementSummaries(toReturn);
+ }
+}
diff --git a/src/main/resources/db/migration/V20260501__create_dispense_summary.sql b/src/main/resources/db/migration/V20260501__create_dispense_summary.sql
new file mode 100644
index 000000000..3a2589d1d
--- /dev/null
+++ b/src/main/resources/db/migration/V20260501__create_dispense_summary.sql
@@ -0,0 +1,12 @@
+-- 创建发药汇总表,加入唯一约束以支持 UPSERT
+CREATE TABLE IF NOT EXISTS dispense_summary (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ hospitalization_id BIGINT NOT NULL,
+ drug_id BIGINT NOT NULL,
+ total_quantity DECIMAL(12,2) NOT NULL DEFAULT 0,
+ total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
+ version INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_hosp_drug (hospitalization_id, drug_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='住院发药汇总表';