1. 项目背景与核心需求
连锁药店管理系统在医药零售行业一直是个痛点。传统的手工记录方式效率低下,容易出现错漏,而市面上的通用管理软件又难以满足药品管理的特殊需求(如批次追踪、效期预警等)。我去年接手过一家本地连锁药店的系统升级项目,深刻体会到一套量身定制的管理系统有多重要。
这个基于SSM+Android的解决方案,正是为了解决以下典型问题:
- 多门店库存无法实时同步,经常出现"有库存却找不到药"的情况
- 药品效期管理依赖人工检查,存在过期药品漏检风险
- 会员信息分散在各门店POS机,无法实现跨店积分和优惠
- 总部难以实时掌握各门店销售数据和库存情况
2. 技术架构设计解析
2.1 整体技术选型
选择SSM(Spring+SpringMVC+MyBatis)作为后端框架组合是经过慎重考虑的:
- Spring:提供完善的IoC容器和事务管理,特别适合需要严格保证数据一致性的药品出入库操作
- SpringMVC:RESTful接口设计方便Android端调用,我们实测在3G网络下也能保持稳定响应
- MyBatis:灵活的SQL编写能力对复杂药品查询场景至关重要,比如同时按药品名称、厂家、批次多条件筛选
Android端采用原生开发而非混合方案,主要考虑:
- 需要调用扫码枪等硬件设备
- 对列表页面的流畅度要求高(药品数据可能达上万条)
- 必须支持离线操作(乡镇门店网络不稳定)
2.2 数据库关键设计
药品主表的设计有几个特别注意点:
sql复制CREATE TABLE `medicine` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`code` varchar(20) NOT NULL COMMENT '药品编码',
`name` varchar(100) NOT NULL,
`spec` varchar(50) NOT NULL COMMENT '规格',
`manufacturer` varchar(100) NOT NULL,
`batch_number` varchar(50) NOT NULL COMMENT '批次号',
`production_date` date NOT NULL,
`expiry_date` date NOT NULL COMMENT '系统会自动计算剩余天数',
`stock` int(11) NOT NULL DEFAULT '0',
`price` decimal(10,2) NOT NULL,
`category_id` int(11) NOT NULL,
`store_id` int(11) NOT NULL COMMENT '所属门店',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-正常 0-停售',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_code_store` (`code`,`store_id`) COMMENT '同一门店药品编码不能重复'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
特别注意:药品表必须建立(code,store_id)联合唯一索引,避免同一药品在不同批次被误认为不同品种
3. 核心功能实现细节
3.1 药品效期智能预警
我们在后台配置了定时任务,每天凌晨2点扫描即将过期的药品:
java复制@Scheduled(cron = "0 0 2 * * ?")
public void checkExpiryMedicine() {
// 提前30天预警
Date warningDate = DateUtils.addDays(new Date(), 30);
List<Medicine> medicines = medicineMapper.selectExpiringSoon(warningDate);
medicines.forEach(med -> {
// 生成预警记录
WarningRecord record = new WarningRecord();
record.setMedicineId(med.getId());
record.setStoreId(med.getStoreId());
record.setDaysRemaining(DateUtils.getDaysBetween(new Date(), med.getExpiryDate()));
// 推送到相关责任人APP
pushNotification(record);
});
}
实际运营中发现,单纯按固定天数预警不够灵活。后来我们升级为动态预警策略:
- 普通药品:提前30天预警
- 冷链药品:提前15天预警(周转更快)
- 贵重药品:提前60天预警(需要更长时间促销)
3.2 多门店库存同步方案
采用"最终一致性"策略解决库存同步问题:
- 本地操作立即更新当前门店库存
- 通过消息队列异步同步到中心服务器
- 中心服务器定期(每5分钟)汇总各门店库存
java复制// 库存扣减示例
@Transactional
public Result deductStock(Long medicineId, int num) {
// 1. 检查本地库存
Medicine medicine = medicineMapper.selectByPrimaryKey(medicineId);
if(medicine.getStock() < num) {
// 2. 检查全局库存
int totalStock = stockService.getTotalStock(medicineId);
if(totalStock < num) {
return Result.error("库存不足");
}
// 3. 发起调拨申请
transferService.applyTransfer(medicineId, num);
return Result.success("已发起调拨");
}
// 4. 扣减本地库存
medicineMapper.updateStock(medicineId, -num);
// 5. 发送库存变更消息
stockChangeProducer.sendStockMessage(medicineId, -num);
return Result.success();
}
4. Android端关键实现
4.1 离线操作处理
采用Room数据库实现本地缓存,网络恢复后自动同步:
kotlin复制@Dao
interface LocalMedicineDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(medicine: LocalMedicine)
@Query("SELECT * FROM local_medicine")
fun getAll(): Flow<List<LocalMedicine>>
@Query("DELETE FROM local_medicine WHERE sync_status = 1")
suspend fun deleteSynced()
}
class MedicineRepository {
suspend fun syncData() {
if(!networkMonitor.isOnline()) return
val unsynced = localMedicineDao.getAll()
.first()
.filter { it.syncStatus == 0 }
unsynced.forEach { medicine ->
try {
apiService.uploadMedicine(medicine.toRemote())
localMedicineDao.updateSyncStatus(medicine.id, 1)
} catch (e: Exception) {
Log.e("SyncError", "Failed to sync ${medicine.id}", e)
}
}
localMedicineDao.deleteSynced()
}
}
4.2 扫码验药功能
利用ZXing库实现药品追溯码扫描:
java复制public class ScanActivity extends AppCompatActivity implements ZXingScannerView.ResultHandler {
private ZXingScannerView mScannerView;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
mScannerView = new ZXingScannerView(this);
setContentView(mScannerView);
}
@Override
public void handleResult(Result rawResult) {
String barcode = rawResult.getText();
Medicine medicine = medicineService.findByBarcode(barcode);
runOnUiThread(() -> {
if(medicine == null) {
Toast.makeText(this, "未找到该药品", Toast.LENGTH_SHORT).show();
} else {
Intent intent = new Intent(this, DetailActivity.class);
intent.putExtra("medicine", medicine);
startActivity(intent);
}
// 继续扫描
mScannerView.resumeCameraPreview(this);
});
}
}
5. 部署与性能优化
5.1 服务器配置建议
根据我们实际运营数据,给出以下配置参考:
- 5家门店以下:2核4G云服务器 + MySQL 5.7
- 5-20家门店:4核8G + MySQL读写分离
- 20家以上:8核16G + Redis缓存热点数据
特别提醒:药品图片建议使用OSS存储,不要存在数据库里。我们曾有个客户把药品照片直接存BLOB字段,导致数据库暴涨到50GB。
5.2 高频查询优化
药品查询接口的响应时间从最初的800ms优化到120ms的关键措施:
- 添加合适的索引:
sql复制ALTER TABLE medicine ADD INDEX idx_search (name, manufacturer, category_id); - 使用MyBatis二级缓存:
xml复制<cache eviction="LRU" flushInterval="60000" size="1024"/> - 热点数据预加载:
java复制@PostConstruct public void preloadHotMedicines() { List<Long> hotIds = statisticService.getHotMedicineIds(); hotIds.forEach(id -> medicineMapper.selectByPrimaryKey(id)); }
6. 踩坑实录与解决方案
6.1 批次管理混乱问题
初期设计时没有严格区分不同批次的同种药品,导致出现:
- 效期预警不准确
- 库存扣减错乱
- 召回操作困难
解决方案:
- 在所有的出入库操作中强制要求批次号
- 实现FIFO(先进先出)的库存扣减策略
- 在药品基础信息页面显眼位置标注批次信息
6.2 Android端列表卡顿
当药品数据超过5000条时,RecyclerView出现明显卡顿。通过以下措施优化:
- 使用DiffUtil高效更新数据
kotlin复制val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { // 实现比对逻辑 }) diffResult.dispatchUpdatesTo(adapter) - 实现分页加载
- 对复杂图片进行预压缩
6.3 扫码枪兼容性问题
不同型号的扫码枪在Android上的表现差异很大,我们最终采用的兼容方案:
- 监听物理按键事件
java复制@Override public boolean onKeyDown(int keyCode, KeyEvent event) { if(keyCode == KeyEvent.KEYCODE_F8) { // 扫码枪专用按键 startScan(); return true; } return super.onKeyDown(keyCode, event); } - 支持USB/蓝牙两种连接方式
- 提供扫码枪配置向导页面
7. 项目扩展方向
现有系统已经稳定运行后,可以考虑以下扩展:
- 对接医保系统:需要特别注意加密要求和数据规范
- 增加智能采购预测:基于历史销售数据的机器学习模型
- 开发药师咨询模块:集成在线问诊功能
- 实现GSP认证相关功能:温湿度监控、验收记录等
这个项目最让我有成就感的是看到店员们从最初抗拒使用,到后来主动提出改进建议。技术真正的价值不在于用了多炫酷的框架,而是切实解决了实际问题。
