ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP开源中医诊所后台管理源码:带数据库部署与二次开发

PHP开源中医诊所后台管理源码:带数据库部署与二次开发

简介:这套PHP开源中医诊所后台管理项目面向PHP Web开发学习者、医疗信息化从业者及需要二次开发诊所系统的开发者,提供带预配置数据库的完整管理平台源码,可用于理解预约、患者、药品库存等业务模块的后台实现。压缩包共2000个文件、约12.45MB,其中PHP文件承担后端逻辑与接口,HTML、CSS、Less及大量JavaScript构成前端界面与交互资源,SQL数据库文件、JSON配置和Markdown文档便于部署与查阅,整体目录保留了较完整的工程结构。已有933人学习,说明其在PHP后台与医疗管理场景中具有一定参考热度。项目涉及MySQL数据存储、MVC分层、登录认证与角色权限控制、RESTful API设计、数据安全、错误日志与性能优化等知识点,读者可据此梳理从请求处理到页面渲染的完整链路,并在此基础上调整科室、药品和预约流程,完成符合自身业务需求的定制开发。

1. 一套带库的中医诊所后台,能省掉多少手工活

接手小规模中医诊所的信息化需求时,最省事的做法不是从零写一套,而是先找一套 PHP 开源的中医诊所后台管理源码来改。这类项目通常把患者档案、挂号登记、开方、药品库存、收费统计都做在同一个后台里,并且附一份可以直接导入的数据库脚本——「带数据库」这几个字的分量就在这儿:表结构是现成的,导入之后整套数据流就摆在你面前。

它适合两类人:想给诊所快速搭一套能用的系统、改动要短平快的开发者;以及拿它练手 PHP 的人,因为代码多是原生 SQL 直出,逻辑全在明面上,没有框架那层黑匣子。至于要对接医保结算、多门店连锁、前后端分离的,这套骨架撑不住,改到最后不如重写。

2. 技术栈与目录结构:PHP 单体应用的四层骨架

2.1 技术栈盘点:为什么诊所系统还在用原生 PHP

这类 PHP 开源中医诊所后台,常见的技术组合是 PHP 7.x + MySQL 5.7 + 原生 PDO 或 mysqli + 一套 Bootstrap 系的后台模板 + jQuery。没有 Composer 依赖,没有路由分发器,一个.php文件对应一个页面,表单 POST 到xxx_save.php,处理完header('Location: ...')跳回来。看着土,但对这个场景反而是优势:诊所的电脑配置一般,运维就是老板自己或者一个兼职,出问题时能靠看一眼 URL 和文件目录定位,而不是翻日志找路由。

PHP 版本上要留个心眼。老源码包里常见mysql_connect()这种 PHP 5 时代的函数,PHP 7.0 之后就彻底移除了,直接跑会报Call to undefined function。稳妥的做法是先用 PHP 7.2 或 7.4 试,跑通再考虑往 8.x 迁移;真要迁移,mysql_*全家桶得换成 PDO,这是个体力活,不在第一轮改造范围内。

数据库这边,中医诊所的数据特点是「字典多、明细多」:药品字典、科室字典、治法字典、剂型字典,一张处方下面挂着十几味药的明细。MySQL 5.7 的utf8mb4是必须的,因为患者姓名、药材别名里出现生僻字很常见,utf8(实际是 utf8mb3)存不下四字节字符,会出现导入成功但查询出来是问号的情况。

前端部分通常是一个左侧菜单 + 顶部面包屑的后台模板,列表页用 jQuery 的 ajax 拉数据、弹层编辑。这类模板的耦合度不高,想换皮基本是改static/下的 css 和模板头尾两个 include 文件,不影响业务逻辑。

2.2 目录结构与一次请求的流向

解压之后,目录大致是这样一套结构。不同发行版本的文件夹名会有出入,但职责划分基本一致:

clinic-admin/ ├── index.php # 前台/入口,多数包会直接跳到 admin/ ├── config/ │ └── database.php # 数据库账号密码,部署时唯一必改的文件 ├── include/ │ ├── db.php # PDO 连接封装 │ ├── auth.php # 登录态校验,业务页第一行 require │ ├── func.php # 公共函数:分页、转义、金额格式化 │ └── header.php/footer.php # 后台模板头尾 ├── admin/ │ ├── login.php # 登录页与登录处理 │ ├── index.php # 后台首页,通常是几个统计卡片 │ ├── patient_*.php # 患者档案:列表/新增/编辑/详情 │ ├── registration_*.php # 挂号登记 │ ├── prescription_*.php # 开方:主表 + 明细 │ ├── medicine_*.php # 药品字典与库存 │ ├── charge_*.php # 收费与退费 │ └── report_*.php # 统计报表 ├── static/ # css / js / 字体 / 后台模板皮肤 ├── uploads/ # 患者头像、检查单照片、导入的 Excel ├── install/ # 部分包带安装向导,装完必须删 └── clinic.sql # 建表 + 初始数据的完整脚本

一次典型请求的流向是:浏览器请求admin/prescription_add.php→ 文件第一行require include/auth.php判断$_SESSION['uid']是否存在,不存在就header('Location: login.php')→require include/db.php拿到 PDO 单例 → 查患者、查医生、查药品字典填充下拉框 →include header.php输出模板 → 表单提交到prescription_save.php→ 写库 → 跳回列表页。

这条链路没有任何隐藏环节,好处是排查快,坏处是每个业务文件都要重复写鉴权和连接。二次开发时我一般会在include/auth.php里顺手加上统一的权限判断和操作日志,一处改动全局生效,比在每个文件里加if靠谱得多。

提示:install/目录装完一定要删。留在服务器上等于给数据库结构开了个公开的重装入口,这类老项目在这上面的翻车案例不少。

2.3 数据表设计:中药处方为什么不能一张表存完

导入clinic.sql之后,核心表大概有十来张。把关键的几张理清楚,后面的模块就都好读了:

表名作用关键字段注意点
patient患者档案namephonegenderallergyphone建索引做建档去重
doctor医生namedept_idtitle与系统登录账号分开,医生不一定能登录
registration挂号记录patient_iddoctor_idfeestatus状态区分待诊/已诊/退号
prescription处方主表patient_iddoctor_iddiagnosisstatusstatus决定是否计入营收
prescription_item处方明细prescription_idmedicine_idqtydose一味药一行,别塞进逗号串
medicine药品字典nameunitstockretail_price库存字段直接放主表,简单但要注意并发
stock_record出入库流水medicine_idtypeqtybeforeafter对账用的后悔药,必须记
sys_user后台账号usernamepassword_hashrole_id密码用password_hash存的才安全

中医场景里有两处和西医系统不一样,改的时候特别容易踩:一是处方明细要记「剂量 + 单位 + 煎法」,同样是黄芪,9g 和 30g 是两回事,先煎后下也得留痕;二是收费常按「帖」计,一帖包含多味药,金额合计在明细上算、在主表上冗余一份,报表才跑得快。

患者表建议最少这么建,字段不多但够用:

CREATE TABLE `patient` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL COMMENT '患者姓名', `gender` TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', `age` SMALLINT NOT NULL DEFAULT 0 COMMENT '建档时录年龄,生日非必填', `phone` VARCHAR(20) NOT NULL COMMENT '手机号,建档去重键', `id_card` VARCHAR(32) DEFAULT NULL, `addr` VARCHAR(128) DEFAULT NULL, `allergy` VARCHAR(255) DEFAULT NULL COMMENT '过敏史,开方页要红字提示', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除', `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `idx_phone` (`phone`), KEY `idx_created` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者档案';

allergy这个字段值得单独说一句:它在列表页不显眼,但在开方页应该被查出来顶在患者信息旁边。很多开源包只存不用,二次开发时把它接进开方页,是性价比很高的一次改造。deleted做软删除而不是物理删除,是因为处方和收费单会关联患者 ID,真删了历史单据就成孤儿数据了。

3. 核心模块拆解:从挂号建档到开方扣库存的完整链路

3.1 数据库连接与登录鉴权:全局只有一处密码

原生的老包常见写法是每个文件顶部写一遍mysqli_connect('localhost','root','123456','clinic')。能跑,但改密码要全项目搜替换。我一般先把连接收敛到配置文件 + 单例函数两层,业务文件只调db():

<?php // config/database.php —— 部署时唯一要改的文件 return [ 'host' => '127.0.0.1', 'port' => 3306, 'dbname' => 'clinic', 'user' => 'clinic_user', 'pass' => 'change_me', 'charset' => 'utf8mb4', ];
<?php // include/db.php —— 单例 PDO,一次请求复用同一个连接 function db(): PDO { static $pdo = null; if ($pdo instanceof PDO) { return $pdo; } $cfg = require __DIR__ . '/../config/database.php'; $dsn = "mysql:host={$cfg['host']};port={$cfg['port']};dbname={$cfg['dbname']};charset={$cfg['charset']}"; $pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常,别静默失败 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 统一取关联数组 PDO::ATTR_EMULATE_PREPARES => false, // 用真预处理,整数参数不会被当字符串 ]); return $pdo; }

ATTR_EMULATE_PREPARES => false这一行的作用是让 MySQL 服务端做预处理,好处是参数类型准确、注入面更小;代价是不能用同名占位符多次。charset=utf8mb4写在 DSN 里而不是靠SET NAMES,是因为它连建连阶段就生效,不会出现前几条查询乱码后几条正常这种玄学现象。

登录部分要注意两件事:密码比对用password_verify而不是明文或 md5 比较;登录成功后调session_regenerate_id(true),避免会话固定攻击。

<?php // admin/login.php —— 登录处理片段 session_start(); require __DIR__ . '/../include/db.php'; $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $stmt = db()->prepare('SELECT id, username, password_hash, role_id, status FROM sys_user WHERE username = ? LIMIT 1'); $stmt->execute([$username]); $user = $stmt->fetch(); // password_verify 对应建库时用 password_hash 写进去的密文 if (!$user || !password_verify($password, $user['password_hash'])) { exit('<script>alert("账号或密码错误");history.back();</script>'); } if ((int)$user['status'] !== 1) { exit('<script>alert("账号已被停用");history.back();</script>'); } session_regenerate_id(true); // 防会话固定 $_SESSION['uid'] = $user['id']; $_SESSION['role'] = $user['role_id']; header('Location: index.php');

参数说明:$user['status']是启用标记,1 为正常;role_id存到 session 里供include/auth.php做菜单和操作权限判断。如果导入的初始账号在库里是 md5 密文(老包常见),两个选择:写个一次性脚本把所有账号重算成password_hash,或者在登录时判断密文长度是 32 位就按 md5 比对、成功后顺手升级成新密文。后者更平滑,用户无感。

3.2 患者建档与挂号登记:去重比录得快更重要

诊所前台最常干的事就是建档案。手工录得快不重要,重要的是别同一病人建出三份档案,否则复诊时病史就断了。做法是拿手机号做幂等键,插入前先查:

<?php // admin/patient_save.php require __DIR__ . '/../include/auth.php'; // 未登录直接跳登录页 require __DIR__ . '/../include/db.php'; $data = [ 'name' => trim($_POST['name'] ?? ''), 'gender' => (int)($_POST['gender'] ?? 0), 'age' => (int)($_POST['age'] ?? 0), 'phone' => trim($_POST['phone'] ?? ''), 'id_card' => trim($_POST['id_card'] ?? ''), 'addr' => trim($_POST['addr'] ?? ''), 'allergy' => trim($_POST['allergy'] ?? ''), ]; if ($data['name'] === '' || $data['phone'] === '') { exit(json_encode(['code' => 1, 'msg' => '姓名和手机号必填'], JSON_UNESCAPED_UNICODE)); } // 手机号去重:已存在就返回老档案 ID,让前端跳去详情页 $stmt = db()->prepare('SELECT id FROM patient WHERE phone = ? AND deleted = 0 LIMIT 1'); $stmt->execute([$data['phone']]); if ($oldId = $stmt->fetchColumn()) { exit(json_encode(['code' => 2, 'msg' => '该手机号已建档', 'id' => (int)$oldId], JSON_UNESCAPED_UNICODE)); } $sql = 'INSERT INTO patient (name, gender, age, phone, id_card, addr, allergy, created_at) VALUES (:name, :gender, :age, :phone, :id_card, :addr, :allergy, NOW())'; db()->prepare($sql)->execute($data); echo json_encode(['code' => 0, 'msg' => '建档成功', 'id' => (int)db()->lastInsertId()], JSON_UNESCAPED_UNICODE);

逻辑上有两点值得留意。第一,execute($data)能直接跑,是因为命名占位符和数组键名一一对应,但要求数组里不能有多余的键,多一个就会报Invalid parameter number——这是新手最常撞的报错,看到了先检查数组。第二,重复建档时返回code=2和老的id,前端拿到后可以直接跳详情页并提示「已存在档案」,比弹个「手机号重复」然后让用户自己去找体验好很多。

挂号是在这个基础上再插一条registration,带上医生、科室、挂号费。挂号费建议落库时不覆盖原值,改费用走一条新的流水,否则月底对账时前台说收了多少、系统显示收了多少,两边扯不清。

3.3 开方与库存扣减:事务的边界画在哪

这是整套系统最容易出事的地方。开一张方子,要往主表插一条、往明细表插十几条、还要把每味药的库存减掉。任何一步失败都不该留下半张处方。稳妥的写法是一个事务包住全部,并且库存用带条件的 UPDATE:

<?php // admin/prescription_save.php —— 开方 + 扣库存,必须同一个事务 require __DIR__ . '/../include/auth.php'; require __DIR__ . '/../include/db.php'; $patientId = (int)($_POST['patient_id'] ?? 0); $doctorId = (int)($_POST['doctor_id'] ?? 0); $diagnosis = trim($_POST['diagnosis'] ?? ''); // items 形如 [['medicine_id'=>12,'qty'=>10,'dose'=>'9g','decoction'=>'先煎'], ...] $items = $_POST['items'] ?? []; if ($patientId <= 0 || !$items) { exit(json_encode(['code' => 1, 'msg' => '患者或处方明细为空'], JSON_UNESCAPED_UNICODE)); } $pdo = db(); try { $pdo->beginTransaction(); // 1. 主表 $pdo->prepare('INSERT INTO prescription (patient_id, doctor_id, diagnosis, status, created_at) VALUES (?, ?, ?, 0, NOW())') ->execute([$patientId, $doctorId, $diagnosis]); $pid = (int)$pdo->lastInsertId(); // 2. 明细与库存 $insItem = $pdo->prepare('INSERT INTO prescription_item (prescription_id, medicine_id, qty, dose, decoction, price) VALUES (?, ?, ?, ?, ?, ?)'); $updStock = $pdo->prepare('UPDATE medicine SET stock = stock - ? WHERE id = ? AND stock >= ?'); $getPrice = $pdo->prepare('SELECT retail_price FROM medicine WHERE id = ?'); foreach ($items as $it) { $mid = (int)$it['medicine_id']; $qty = (float)$it['qty']; if ($mid <= 0 || $qty <= 0) { continue; } // 条件更新:库存不足时影响行数为 0,直接抛异常回滚 $updStock->execute([$qty, $mid, $qty]); if ($updStock->rowCount() === 0) { throw new RuntimeException("药品 #{$mid} 库存不足"); } $getPrice->execute([$mid]); $price = (float)$getPrice->fetchColumn(); $insItem->execute([$pid, $mid, $qty, $it['dose'] ?? '', $it['decoction'] ?? '', $price]); } $pdo->commit(); echo json_encode(['code' => 0, 'msg' => '开方成功', 'pid' => $pid], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { $pdo->rollBack(); echo json_encode(['code' => 1, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE); }

关键在UPDATE medicine SET stock = stock - ? WHERE id = ? AND stock >= ?。把「够不够」和「减多少」放进同一条 SQL,由 InnoDB 的行锁保证原子性,两个人同时开同一味药也不会扣成负数。如果先SELECT stock判断再UPDATE,中间那一瞬间就是竞态窗口,门诊高峰期一天开几十张方,撞上一次就够对账到半夜。

参数上,qty用float强转是因为中药按克计算,8.5g 这种小数很常见,字段类型要对应DECIMAL(10,2)而不是INT。decoction(煎法)和dose(单味剂量)是中医特有信息,别图省事拼进一个remark字段,后面按「先煎类药材」统计用量就统计不出来了。

3.4 收费与报表:一条 SQL 撑起后台首页

后台首页那几个统计卡片,本质就是几条聚合查询。按医生看近 30 天开方量和金额:

-- 后台首页「医生工作量」卡片对应的查询 SELECT d.id, d.name AS doctor_name, COUNT(DISTINCT p.id) AS prescription_cnt, SUM(i.qty * i.price) AS amount FROM prescription p JOIN doctor d ON d.id = p.doctor_id JOIN prescription_item i ON i.prescription_id = p.id WHERE p.created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND p.status = 1 -- 1 = 已收费,未收费的不计入营收 GROUP BY d.id, d.name ORDER BY amount DESC;

status = 1这个条件是营收口径的分水岭。很多包把「已开方」当成「已收费」,结果报表金额永远比实际收款高。真正的口径应该是收费单落库之后回写处方状态,或者干脆用收费表做主表来统计。改造时我一般会先跑一遍全表核对:处方明细金额合计 vs 收费记录合计,两个数对不上就说明中间某一步逻辑漏了。

4. 把项目跑起来:环境、建库、配置、登录后台的完整步骤

4.1 运行环境与扩展检查

Windows 下用一体化 PHP 集成环境面板最省事,装好之后把站点根目录指到解压出来的文件夹;Linux 下 Nginx 或 Apache 都行,重点是 PHP 版本和扩展。需要确认的扩展有pdo_mysql、mbstring(中文截取和编码处理)、gd(验证码、图片处理)、fileinfo(上传文件类型判断)、openssl。写个临时文件跑一下就知道:

<?php // check.php —— 部署前放到站点根目录跑一次,跑完立刻删 $need = ['pdo_mysql', 'mbstring', 'gd', 'fileinfo', 'openssl']; foreach ($need as $ext) { printf("%-12s %s\n", $ext, extension_loaded($ext) ? 'OK' : '缺失'); } echo 'PHP 版本: ' . PHP_VERSION . "\n";

PHP 版本上,先按源码包里的说明来;没说明就从 7.2 试起,报Call to undefined function mysql_connect()说明是 PHP 5 时代的老代码,要么降到 5.6 跑(不推荐,很多新环境装不上),要么把那几个文件改成 PDO。

4.2 建库导数据与配置改写

导入之前先建库,并且一定要显式指定字符集,别指望默认值:

# 先建库再导数据,字符集必须写 utf8mb4 mysql -uroot -p -e "CREATE DATABASE clinic DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入源码包里的数据库脚本 mysql -uroot -p clinic < clinic.sql # 验证:表数量和中文字段注释能正常显示,就说明字符集没问题 mysql -uroot -p clinic -e "SHOW TABLES; SELECT name, phone FROM patient LIMIT 3;"

导完改config/database.php。这里有个坑:源码包里这个文件可能不叫这个名字,也可能直接写在include/conn.php里,搜一下mysqli_connect或new PDO就能找到真正的连接点。改的时候顺手给业务建一个独立数据库账号,别用 root,权限只给SELECT, INSERT, UPDATE, DELETE。

4.3 目录权限与 php.ini 关键项

Linux 部署最常见的失败原因不是代码,是权限。上传目录和缓存目录必须让 Web 进程可写:

# 假设 Web 进程用户是 www-data,按实际环境替换 chown -R www-data:www-data uploads runtime find uploads runtime -type d -exec chmod 755 {} \; find uploads runtime -type f -exec chmod 644 {} \;

php.ini里这几项建议直接改掉,处方照片、批量导入患者名单都指望它们:

; 上传限制和时区,诊所场景下这几项基本必调 upload_max_filesize = 20M post_max_size = 24M max_execution_time = 120 date.timezone = Asia/Shanghai

date.timezone不设的后果是 PHP 时间比北京时间差 8 小时,而 MySQL 的NOW()又是对的,于是同一张处方上「开方时间」和「创建时间」能差一天。这种问题在测试环境看不出来,上线后对账才暴露,属于典型的血泪经验。

4.4 登录后台后的第一轮初始化动作

能打开登录页只是第一步,登录进去之后有几件事必须马上做,顺序别乱:

  1. 用源码包附带的初始账号登录,改掉密码,并把用不到的演示账号停用或删除。
  2. 检查sys_user表里的密码字段:如果是 md5 密文,按第 3.1 节说的方法升级成password_hash;如果源码里是明文比对,那这个包的安全性得重新评估。
  3. 删除install/目录和临时放的check.php。
  4. 关掉错误显示:display_errors = Off,log_errors = On,错误写日志而不是打到页面上。
  5. 在后台录一遍基础字典:科室、医生、药品(含单位和零售价),否则开方页的下拉框全是空的,看着像系统坏了。

注意:药品字典里的stock初始值别图省事全填一个大数。库存是这套系统里唯一会随业务持续变动的数字,起点不准,后面所有的进销存报表都是错的。

5. 避坑与排查:五个让新手上不了手的典型问题

5.1 页面全白或直接 500,错误日志里是 undefined function

现象:访问首页一片空白,或者浏览器报 500,error_log里能看到Call to undefined function mysql_connect()/mysqli_connect()。

原因:源码是 PHP 5 时代写的,用的是mysql_*系列函数,PHP 7.0 起已被移除;或者 PHP 装好了但pdo_mysql、mysqli扩展没启用。

解决:先确认扩展,php -m | grep -i mysql看有没有pdo_mysql和mysqli。如果两者都有还报错,说明是老函数,把相关文件的连接部分批量替换成 PDO,至少把mysql_query→db()->query、mysql_fetch_array→fetch()这几类改掉。改之前先全局搜一遍函数名,心里有个工作量预期。

5.2 登录成功后刷新又回登录页

现象:账号密码明明对,点登录跳到首页,一刷新或者点任何菜单又弹回登录页。

原因:session写不进去。常见三种:session.save_path指向的目录不存在或不可写;include/下的某个文件在<?php之前有 BOM 头或一个空格,导致session_start()之前已经有输出、Set-Cookie头发不出去;Nginx 的fastcgi_param没配好,session 目录权限正常但进程用户不对。

解决:先看浏览器里有没有PHPSESSID这个 cookie,没有就是头没发出去,重点查 BOM。用grep -rl $'\xEF\xBB\xBF' include/ admin/能快速定位带 BOM 的文件,改成无 BOM 的 UTF-8。有 cookie 但刷新就丢,去看session.save_path目录权限,改成 755 并确认属主是 Web 用户。

5.3 中文名和药材名导入后变问号

现象:clinic.sql导入成功,页面上显示的都是???或者空格;或者前端录入正常,导出的 Excel 里全是乱码。

原因:三个层面的字符集不统一——建库时用了默认的latin1或utf8mb3;连接没有设charset=utf8mb4;表和字段各自又是另一套。生僻字(比如某些中药别名)是四字节字符,utf8mb3根本存不下,会被截断成问号。

解决:按「库 → 表 → 连接 → 页面」四层统一。建库指定utf8mb4,导入前用SHOW CREATE TABLE抽查几张表,连接 DSN 里带charset=utf8mb4,HTML 里加<meta charset="utf-8">。已经乱了的数据没法自动修,只能重新导入,所以导数据这一步最好在本地先练一遍。

5.4 库存被扣成负数,或者金额和收费对不上

现象:某味药的库存显示 -12g,或者同一天的开方金额合计和实际收款差几十块。

原因:处方保存没有事务,或者有事务但库存判断用了「先查再改」。明细里某一味药扣失败(库存不足、药名被删、价格为 NULL),前面的已经写进去了,没有回滚,就留下半张方子和一堆负数库存。金额对不上多半是status状态流转漏了,未收费的处方被统计进了营收。

解决:按 3.3 节的写法,用事务 + 条件更新,任何一步rowCount() === 0就抛异常回滚。已经在负数状态的库存,用stock_record流水倒推一次实际入库量,重新校准,别直接改成 0。金额口径上,把统计 SQL 里的status条件写死,并且定期跑一次「处方合计 vs 收费合计」的核对查询。

5.5 上传处方照片失败,但页面什么都不提示

现象:选好图片点上传,转一圈回到页面,什么也没发生,文件也没出现在uploads/里。

原因:两种情况最常见。一是uploads/没有写权限,move_uploaded_file返回 false 但代码没判断返回值,静默失败。二是图片超过upload_max_filesize或post_max_size,PHP 直接丢弃整个 POST 请求,$_POST和$_FILES都是空的,代码走不到任何分支。

解决:先在php.ini里把两个上限调大(见 4.3 节),再在保存逻辑里加返回值判断和$_FILES['file']['error']的映射提示。错误码 1 和 2 都是超限,3 是只上传了一部分,4 是没选文件。把这个映射写成一个函数,出错时直接把原因弹出来,比事后翻日志快得多。

6. 二次开发:在现有骨架上加复诊提醒与扩展字段

改造可以从两个成本最低、收益最明显的点切进去:复诊提醒和患者扩展字段。

先说复诊提醒。原表结构通常没有「下次复诊日期」,加字段比新建表快:

-- 两列就够:预计复诊时间和提醒状态,别一上来就建提醒表 ALTER TABLE `patient` ADD COLUMN `next_visit_date` DATE DEFAULT NULL COMMENT '下次预计复诊日期' AFTER `allergy`, ADD COLUMN `visit_reminded` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已提醒' AFTER `next_visit_date`, ADD KEY `idx_next_visit` (`next_visit_date`);

开方保存成功后顺手回写,代码加在事务 commit 之后:

<?php // 开方成功后写复诊日期,天数从前端传,默认 7 天 $days = max(1, (int)($_POST['next_days'] ?? 7)); db()->prepare('UPDATE patient SET next_visit_date = DATE_ADD(CURDATE(), INTERVAL ? DAY), visit_reminded = 0 WHERE id = ?') ->execute([$days, $patientId]);

首页加一个卡片,查未来三天内该复诊但还没来的患者:

SELECT p.id, p.name, p.phone, p.next_visit_date, d.name AS doctor_name FROM patient p LEFT JOIN prescription pr ON pr.patient_id = p.id AND pr.status = 1 LEFT JOIN doctor d ON d.id = pr.doctor_id WHERE p.deleted = 0 AND p.next_visit_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY) GROUP BY p.id ORDER BY p.next_visit_date ASC;

这段 SQL 里GROUP BY p.id是必要的,因为一个患者可能有多条已收费处方,LEFT JOIN会放大行数。想要更严谨就改成子查询取最近一条处方的医生,代价是慢一点,诊所数据量下无所谓。

再说扩展字段。体质辨识、舌象、脉象这类信息,字段会越加越多,直接往patient表加列会把主表撑成三十多列,列表页SELECT *拖得越来越慢。用附属表更干净:

CREATE TABLE `patient_ext` ( `patient_id` INT UNSIGNED NOT NULL, `body_type` VARCHAR(32) DEFAULT NULL COMMENT '体质类型', `tongue` VARCHAR(64) DEFAULT NULL COMMENT '舌象', `pulse` VARCHAR(64) DEFAULT NULL COMMENT '脉象', `updated_at` DATETIME NOT NULL, PRIMARY KEY (`patient_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者扩展信息';

写入用INSERT ... ON DUPLICATE KEY UPDATE,一次 SQL 解决新增和更新两种情况,前端不用区分是建档还是编辑。

改造完必须验一遍,别改完就上线。我的验证清单是:新建一个患者 → 挂号 → 开一张包含三味药的方子(其中一味故意给个超库存的数量)→ 确认报错且库存和处方数据都没落库 → 改小数量重开 → 收费 → 首页统计卡片金额增加 → 患者列表显示复诊日期。这套流程走通,基本说明改动没有破坏原有链路。

验证点预期结果失败时先看哪里
超库存开方报库存不足,主表和明细表都不新增prescription_save.php的事务与rowCount判断
同一手机号重复建档返回 code=2 和老档案 IDpatient_save.php的去重查询
复诊日期回写patient.next_visit_date有值回写代码是否在 commit 之后、是否用了同一个$patientId
首页提醒卡片三天内到期的患者出现next_visit_date是否为空、deleted是否为 0
收费后统计金额只增一次统计 SQL 的status条件

这类开源包最大的价值不是功能多全,而是结构够直白,你能在一个下午之内把一条业务链路读明白再动手改。我早年改这类系统时吃过一次亏:没备份数据库就直接ALTER TABLE,中途字段加错想回滚,结果演示数据全没了,只能对着 Excel 手工录回去。从那以后我每次动表结构之前都强制走一遍「导出 → 本地导入验证 → 再改线上」,多花五分钟,省一晚上的返工。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进