This commit is contained in:
Your Name
2026-09-09 12:18:17 +08:00
parent 0cea43b027
commit 40319eea41
76 changed files with 4821 additions and 268 deletions
@@ -180,6 +180,24 @@ class WecomPromotionController extends BaseAdminController
return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission()));
}
/** 推送当前方案的可用成员,不导入其他官方链接。 */
public function syncMemberRange()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 同步成员范围');
}
$poolId = (int) $this->request->post('pool_id', 0);
return $this->run(fn () => $this->data(WecomPromotionLogic::syncMemberRange(
$poolId,
$this->adminId,
$this->adminInfo
)));
}
public function syncRemoteLinks()
{
if (!$this->hasBasePagePermission()) {
@@ -131,6 +131,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功', $result);
}
/** 独立授权的创建时间修正,逻辑层再次校验权限。 */
public function editTime()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('editTime');
$result = PrescriptionOrderLogic::editTime($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('创建时间已修改', $result);
}
/**
* 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。
*/
@@ -234,6 +234,7 @@ class AuthMiddleware
'firstvisit.wecompromotion/savelink',
'firstvisit.wecompromotion/savemember',
'firstvisit.wecompromotion/togglemember',
'firstvisit.wecompromotion/syncmemberrange',
'firstvisit.wecompromotion/checkapipermission',
'firstvisit.wecompromotion/syncremotelinks',
'firstvisit.wecompromotion/remotelinkdetail',
@@ -2,6 +2,8 @@
namespace app\adminapi\lists\doctor;
use app\common\enum\AppointmentTypeEnum;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
@@ -335,12 +337,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
];
$item['status_desc'] = $statusMap[$item['status']] ?? '未知';
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知';
$item['appointment_type'] = AppointmentTypeEnum::normalizeStored($item['appointment_type'] ?? null);
$item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']);
$periodRaw = (string) ($item['period'] ?? ($item['type'] ?? ''));
$periodMap = [
@@ -12,7 +12,9 @@
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\lists\tcm;
namespace app\adminapi\lists\tcm;
use app\common\enum\AppointmentTypeEnum;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
@@ -191,7 +193,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$subQuery
->field('id, patient_id, doctor_id, appointment_date, appointment_time, status, create_time')
->field('id, patient_id, doctor_id, appointment_date, appointment_time, appointment_type, status, create_time')
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc');
@@ -234,7 +236,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$apt = $aptList[0];
$item['has_appointment'] = 1;
$item['appointment_id'] = $apt['id'];
$item['appointment_status'] = (int) ($apt['status'] ?? 0);
$item['appointment_status'] = (int) ($apt['status'] ?? 0);
$item['appointment_type'] = AppointmentTypeEnum::normalizeStored($apt['appointment_type'] ?? null);
$item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']);
$item['appointment_doctor_id'] = $apt['doctor_id'];
$item['appointment_doctor_name'] = $doctorNames[$apt['doctor_id']] ?? '-';
$timePart = $apt['appointment_time'] ?? '';
@@ -253,14 +257,18 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
'id' => (int) ($a['id'] ?? 0),
'status' => (int) ($a['status'] ?? 0),
'doctor_id' => $doctorId,
'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'),
'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'),
'appointment_type' => AppointmentTypeEnum::normalizeStored($a['appointment_type'] ?? null),
'appointment_type_desc' => AppointmentTypeEnum::description($a['appointment_type'] ?? null),
'time_text' => trim((string) ($a['appointment_date'] ?? '') . ' ' . (string) $timePart),
];
}, $aptList);
} else {
$item['has_appointment'] = 0;
$item['appointment_id'] = null;
$item['appointment_status'] = 0;
$item['appointment_status'] = 0;
$item['appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['appointment_doctor_id'] = null;
$item['appointment_doctor_name'] = '';
$item['appointment_time_text'] = '';
@@ -272,7 +280,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$this->appendLatestAppointmentSummary($item, null);
$item['has_appointment'] = 0;
$item['appointment_id'] = null;
$item['appointment_status'] = 0;
$item['appointment_status'] = 0;
$item['appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['appointment_doctor_id'] = null;
$item['appointment_doctor_name'] = '';
$item['appointment_time_text'] = '';
@@ -806,7 +816,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$cols = $this->appointmentTableFields();
$fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'status'];
$fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status'];
foreach (['channel_source', 'channel_source_detail', 'channels'] as $col) {
if (in_array($col, $cols, true)) {
$fields[] = $col;
@@ -843,7 +853,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
*/
private function appendLatestAppointmentSummary(array &$item, ?array $appointment): void
{
$item['latest_appointment_id'] = null;
$item['latest_appointment_id'] = null;
$item['latest_appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['latest_appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['latest_appointment_time_text'] = '';
$item['latest_appointment_channel_source'] = '';
$item['latest_appointment_channel_source_desc'] = '';
@@ -862,7 +874,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$rawChannel = trim((string) $appointment['channels']);
}
$item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0);
$item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0);
$item['latest_appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
$item['latest_appointment_type_desc'] = AppointmentTypeEnum::description($item['latest_appointment_type']);
$item['latest_appointment_time_text'] = trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $timePart);
$item['latest_appointment_channel_source'] = $rawChannel;
$item['latest_appointment_channel_source_desc'] = (string) ($appointment['channel_source_desc'] ?? '');
@@ -36,7 +36,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
// 仅在「存在有效业务订单」的处方里做风险判定
$orderRxIds = PrescriptionOrder::whereIn('prescription_id', $candidateIds)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->column('prescription_id');
$orderRxIds = array_values(array_unique(array_filter(array_map('intval', $orderRxIds), static function (int $id): bool {
return $id > 0;
@@ -260,7 +260,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
$bizRejectRows = PrescriptionOrder::whereIn('prescription_id', $rxIds)
->where('prescription_audit_status', 2)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->field(['prescription_id', 'prescription_audit_remark', 'id'])
->order('id', 'desc')
->select()
@@ -280,7 +280,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
'intval',
PrescriptionOrder::whereIn('prescription_id', $rxIds)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->column('prescription_id')
));
$hasBizOrderRx = array_fill_keys($orderRxIds, true);
@@ -2,7 +2,8 @@
namespace app\adminapi\logic\doctor;
use app\common\logic\BaseLogic;
use app\common\logic\BaseLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Roster;
use app\common\service\doctor\RosterSegmentService;
@@ -46,7 +47,7 @@ class AppointmentLogic extends BaseLogic
/**
* @param array<int|string, mixed> $cols Db::name('doctor_appointment')->getTableFields()
*/
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannels = in_array('channels', $cols, true);
@@ -86,9 +87,12 @@ class AppointmentLogic extends BaseLogic
*
* @return array<string, mixed>
*/
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
$out = [];
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
if (array_key_exists('appointment_type', $row) && !in_array('appointment_type', $cols, true)) {
throw new \RuntimeException('挂号表缺少 appointment_type 字段,无法保存问诊类型');
}
$out = [];
foreach ($row as $k => $v) {
if (in_array((string) $k, $cols, true)) {
$out[$k] = $v;
@@ -273,9 +277,15 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array|bool
*/
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
$params = AppointmentTypeEnum::withDefault($params);
if (!AppointmentTypeEnum::isWritable($params['appointment_type'])) {
self::setError('问诊类型仅支持图文问诊或视频问诊');
return false;
}
try {
Db::startTrans();
// 同一诊单患者在「所选预约日」仅允许一条「已预约」或「已过号」记录(与 appointment_date 一致,不能误用服务器当天拦其它日期)
@@ -345,7 +355,7 @@ class AppointmentLogic extends BaseLogic
'doctor_id' => (int) $params['doctor_id'],
'appointment_date' => $params['appointment_date'],
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'appointment_type' => $params['appointment_type'],
'remark' => $params['remark'] ?? '',
'status' => 1,
'create_time' => time(),
@@ -491,12 +501,8 @@ class AppointmentLogic extends BaseLogic
];
$appointment['status_desc'] = $statusMap[$appointment['status']] ?? '未知';
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$appointment['appointment_type_desc'] = $typeMap[$appointment['appointment_type']] ?? '未知';
$appointment['appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
$appointment['appointment_type_desc'] = AppointmentTypeEnum::description($appointment['appointment_type']);
// 格式化时间戳为日期时间
if (isset($appointment['create_time']) && is_numeric($appointment['create_time'])) {
@@ -969,9 +975,14 @@ class AppointmentLogic extends BaseLogic
*
* @param array<string, mixed> $params
*/
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
try {
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
if (!AppointmentTypeEnum::isWritable($params['appointment_type'] ?? null)) {
self::setError('问诊类型仅支持图文问诊或视频问诊');
return false;
}
try {
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
self::setError('参数错误');
@@ -1023,12 +1034,7 @@ class AppointmentLogic extends BaseLogic
return false;
}
$appointmentType = trim((string) ($params['appointment_type'] ?? ''));
if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) {
self::setError('预约类型无效');
return false;
}
$appointmentType = $params['appointment_type'];
$remark = isset($params['remark']) ? trim((string) $params['remark']) : '';
if (mb_strlen($remark) > 500) {
@@ -330,7 +330,8 @@ class WecomPromotionLogic
$createdRemote = true;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)) {
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)
|| $remote['range_department_ids'] !== []) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
} catch (\Throwable $e) {
@@ -488,9 +489,7 @@ class WecomPromotionLogic
'range_userids' => $eligibleUserIds,
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
'sync_error' => $syncError,
'sync_queued' => $needsQueuedSync && !$syncImmediately,
];
] + self::memberSyncResult($id, $syncError);
}
/**
@@ -679,13 +678,13 @@ class WecomPromotionLogic
$memberMatched += $poolMemberResult['matched'];
$memberUpdated += $poolMemberResult['updated'];
}
$syncError = trim((string) ($saved['sync_error'] ?? ''));
$syncResult = self::memberSyncResult($poolId, (string) ($saved['sync_error'] ?? ''));
$syncError = $syncResult['sync_error'];
$updated++;
if ($syncError !== '') {
$syncErrorCount++;
}
$syncQueued = !empty($saved['sync_queued'])
|| !empty($poolMemberResult['dispatch']['queued']);
$syncQueued = $syncResult['sync_queued'];
if ($syncQueued) {
$syncQueuedCount++;
}
@@ -693,6 +692,7 @@ class WecomPromotionLogic
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => true,
'sync_status' => $syncResult['sync_status'],
'sync_error' => $syncError,
'sync_queued' => $syncQueued,
'member_matched' => $poolMemberResult['matched'],
@@ -754,7 +754,8 @@ class WecomPromotionLogic
]);
}
$dispatch = $updateIds !== []
// 状态相同也需要重算:上次保存可能只入队,远端仍保留已下线成员。
$dispatch = $matchedIds !== []
? QywxPromotionMemberSchedulerService::reconcilePool($poolId)
: null;
// 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。
@@ -1235,7 +1236,94 @@ class WecomPromotionLogic
}
}
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned]
+ self::memberSyncResult($poolId, $syncError);
}
/** 显式重新推送当前范围;一次请求仅处理一个方案,供批量前端逐方案调用。 */
public static function syncMemberRange(
int $poolId,
int $adminId,
array $adminInfo,
?QywxPromotionRangeSyncService $syncService = null
): array {
self::assertMemberDispatchSchema();
if (!QywxPromotionOperatorAccess::hasPagePermission($adminId, $adminInfo)) {
throw new RuntimeException('权限不足');
}
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$ready = Db::transaction(static function () use ($poolId): bool {
// 与成员保存、方案删除使用相同锁顺序,不重启删除中的同步任务。
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
throw new RuntimeException('分流方案不存在或已删除');
}
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if ((int) ($sync['status'] ?? 0) === 5
|| ((int) ($sync['status'] ?? 0) === 4
&& str_starts_with((string) ($sync['last_error'] ?? ''), '企业微信官方获客链接删除失败'))) {
return false;
}
$plan = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
if ($plan['blocked']) {
return false;
}
$linkId = (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('promotion_link_id') ?? 0);
// 即便快照看起来一致也重新 update/get,修复未被本地快照发现的企微端变化。
QywxPromotionMemberSchedulerService::requestPoolSync($poolId, $linkId);
return true;
});
$error = '';
if ($ready) {
try {
($syncService ?? new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return self::memberSyncResult($poolId, $error);
}
/** 返回已确认的远端范围及任务状态,不能把无异常的 noop 当成同步成功。 */
private static function memberSyncResult(int $poolId, string $error = ''): array
{
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find() ?: [];
$link = Db::name('qywx_promotion_link')->where('id', (int) ($sync['promotion_link_id'] ?? 0))
->whereNull('delete_time')->find() ?: [];
$members = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)
->whereNull('delete_time')->select()->toArray();
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
$remoteUsers = self::decodeStringList($link['range_user_json'] ?? null);
$remoteDepartments = self::decodeStringList($link['range_department_json'] ?? null);
$status = (int) ($sync['status'] ?? -1);
$syncError = trim($error ?: (string) ($sync['last_error'] ?? $link['sync_error'] ?? ''));
if ($link === [] || trim((string) ($link['remote_link_id'] ?? '')) === ''
|| (int) ($link['remote_status'] ?? 0) === 2 || $range['userids'] === [] || in_array($status, [4, 5], true)) {
$resultStatus = 'blocked';
$syncError = $syncError ?: '当前没有可同步的官方链接或可用成员,请检查方案和成员规则';
} elseif ($error !== '' || $status === 3) {
$resultStatus = 'failed';
$syncError = $syncError ?: '企业微信成员范围同步失败,请重试';
} elseif ($status === 0 && (int) ($sync['desired_version'] ?? 0) > 0
&& (int) ($sync['desired_version'] ?? 0) === (int) ($sync['applied_version'] ?? -1)
&& (int) ($link['last_sync_time'] ?? 0) > 0 && $remoteDepartments === []
&& QywxPromotionMemberRange::same($range['userids'], $remoteUsers)) {
$resultStatus = 'synced';
$syncError = '';
} else {
$resultStatus = 'pending';
}
return [
'pool_id' => $poolId,
'sync_status' => $resultStatus,
'sync_error' => $syncError,
'sync_queued' => in_array($resultStatus, ['pending', 'failed'], true),
'range_userids' => $remoteUsers,
'range_department_ids' => $remoteDepartments,
];
}
public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
@@ -748,16 +748,16 @@ class PrescriptionLogic
$bizPo = PrescriptionOrder::where('prescription_id', $id)
->where('prescription_audit_status', 2)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->order('id', 'desc')
->find();
$arr['business_prescription_audit_rejected'] = $bizPo ? 1 : 0;
$arr['business_prescription_audit_remark'] = $bizPo ? (string) ($bizPo->prescription_audit_remark ?? '') : '';
// 与 PrescriptionLists「业务订单」角标一致:未删除且非已取消(4) 即视为存在有效业务订单
// 与列表和创建校验一致:已取消 / 已退款的订单保留历史关联,但不再占用处方。
$hasBizOrder = PrescriptionOrder::where('prescription_id', $id)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->count() > 0;
$arr['has_prescription_order'] = $hasBizOrder ? 1 : 0;
@@ -1216,7 +1216,7 @@ class PrescriptionLogic
$rxId = (int) ($row->id ?? 0);
$bizCount = (int) PrescriptionOrder::where('prescription_id', $rxId)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->count();
if ($bizCount > 0) {
self::setError('该处方已存在业务订单,无法作废');
@@ -963,6 +963,31 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function create(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
// 先锁始终存在的处方行;空订单集合也能串行化两次创建。
// 首个一致性读发生在获得该锁后,等待者可见前一次创建提交的订单。
$rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0))
->whereNull('delete_time')->lock(true)->find();
if (!$rx) {
throw new \DomainException('处方不存在');
}
$result = self::createLocked($params, $adminId, $adminInfo);
if ($result === false) {
throw new \DomainException(self::$error ?: '创建业务订单失败');
}
return $result;
});
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
}
private static function createLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$rxId = (int) $params['prescription_id'];
@@ -989,8 +1014,9 @@ class PrescriptionOrderLogic
return false;
}
if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time')->where('fulfillment_status', '<>', 4)->count() > 0) {
self::$error = '该处方已存在有效业务订单(未撤回前不可重复创建)';
if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time')
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)->count() > 0) {
self::$error = '该处方已存在有效业务订单(撤回或全额退款后可重新创建)';
return false;
}
@@ -1087,7 +1113,7 @@ class PrescriptionOrderLogic
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单');
self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单', true);
if ($payOrderIds !== []) {
self::replacePayOrderLinks((int) $order->id, $payOrderIds);
@@ -1103,6 +1129,52 @@ class PrescriptionOrderLogic
return $out;
}
/** 只修正业务订单创建时间,不改变关联支付、处方、药房快照或履约状态。 */
public static function editTime(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 菜单尚未部署时,中间件可能放行未知权限,因此必须显式校验。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/editTime', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限修改业务订单创建时间';
return false;
}
$validator = new \app\adminapi\validate\tcm\PrescriptionOrderValidate();
if (!$validator->scene('editTime')->check($params)) {
self::$error = (string) $validator->getError();
return false;
}
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
$order = PrescriptionOrder::where('id', (int) $params['id'])
->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
throw new \DomainException('无权限操作此订单');
}
$oldValue = $order->getData('create_time');
$dateTime = (string) $params['create_time'];
$isTimestamp = is_int($oldValue) || (is_string($oldValue) && ctype_digit($oldValue));
$newValue = $isTimestamp ? (int) strtotime($dateTime) : $dateTime;
if ((string) $oldValue !== (string) $newValue) {
$order->create_time = $newValue;
$order->save();
$oldLabel = $isTimestamp ? date('Y-m-d H:i:s', (int) $oldValue) : (string) $oldValue;
self::writeLog((int) $order->id, $adminId, $adminInfo, 'edit_time',
'修改业务订单创建时间:' . $oldLabel . ' → ' . $dateTime, true);
}
return ['id' => (int) $order->id, 'create_time' => $newValue];
});
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): ?array
{
self::$error = '';
@@ -2,7 +2,8 @@
namespace app\adminapi\validate\doctor;
use app\common\validate\BaseValidate;
use app\common\validate\BaseValidate;
use app\common\enum\AppointmentTypeEnum;
/**
* 医生预约验证器
@@ -23,7 +24,7 @@ class AppointmentValidate extends BaseValidate
'appointment_date' => 'require|date',
'appointment_time' => 'require',
'period' => 'in:morning,afternoon,all',
'appointment_type' => 'require|in:video,text,phone',
'appointment_type' => 'require|checkAppointmentType',
'status' => 'require|integer|between:1,4',
'remark' => 'max:500',
'channel_source' => 'require',
@@ -38,7 +39,7 @@ class AppointmentValidate extends BaseValidate
* 参数描述
* @var string[]
*/
protected $field = [
protected $field = [
'id' => '预约ID',
'patient_id' => '患者ID',
'doctor_id' => '医生ID',
@@ -53,7 +54,21 @@ class AppointmentValidate extends BaseValidate
'channel_source' => '渠道来源',
'channel_source_detail' => '渠道补充说明',
'ids' => '预约ID列表',
];
];
protected function checkAppointmentType($value)
{
return AppointmentTypeEnum::isWritable($value) ? true : '问诊类型仅支持图文问诊或视频问诊';
}
public function check(array $data, array $rules = []): bool
{
if ($this->currentScene === 'create') {
$data = AppointmentTypeEnum::withDefault($data);
}
return parent::check($data, $rules);
}
/**
* @notes 创建预约场景
@@ -10,6 +10,7 @@ class PrescriptionOrderValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer',
'create_time' => 'require|string|dateFormat:Y-m-d H:i:s',
'diagnosis_id' => 'require|integer|gt:0',
'prescription_id' => 'require|integer',
'pay_order_ids' => 'array',
@@ -62,6 +63,8 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number.require' => '请输入快递单号',
'phone_tail.regex' => '手机后四位仅支持数字',
'reason.require' => '请填写退款原因',
'create_time.require' => '创建时间必填',
'create_time.dateFormat' => '创建时间格式不正确',
];
protected $scene = [
@@ -71,6 +74,7 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number', 'express_company', 'ship_mode', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
],
'detail' => ['id'],
'editTime' => ['id', 'create_time'],
'edit' => [
'id', 'recipient_name', 'recipient_phone', 'shipping_address',
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
@@ -109,6 +113,11 @@ class PrescriptionOrderValidate extends BaseValidate
->append('pay_order_id', 'require|integer|gt:0');
}
public function sceneEditTime(): PrescriptionOrderValidate
{
return $this->only(['id', 'create_time'])->append('id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\enum;
/** 挂号的问诊方式,与诊单的初诊/复诊类型无关。 */
class AppointmentTypeEnum
{
public const VIDEO = 'video';
public const TEXT = 'text';
public static function isWritable($value): bool
{
return is_string($value) && in_array($value, [self::VIDEO, self::TEXT], true);
}
/** 只给未传字段的新客户端请求补默认值;显式传入空值仍需校验。 */
public static function withDefault(array $params): array
{
if (!array_key_exists('appointment_type', $params)) {
$params['appointment_type'] = self::VIDEO;
}
return $params;
}
/** 旧记录未填写类型时按视频展示,已记录的历史类型保持原值。 */
public static function normalizeStored($value): string
{
return $value === null || (is_string($value) && trim($value) === '')
? self::VIDEO
: (string) $value;
}
public static function description($value): string
{
return [
self::VIDEO => '视频问诊',
self::TEXT => '图文问诊',
'phone' => '电话问诊', // 仅兼容已存在的历史记录,不允许新写入。
][self::normalizeStored($value)] ?? '未知';
}
}
@@ -3,6 +3,7 @@
namespace app\common\model\doctor;
use app\common\model\BaseModel;
use app\common\enum\AppointmentTypeEnum;
/**
* 医生预约模型
@@ -58,12 +59,12 @@ class Appointment extends BaseModel
*/
public function getAppointmentTypeDescAttr($value, $data)
{
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
return $typeMap[$data['appointment_type']] ?? '未知';
return AppointmentTypeEnum::description($data['appointment_type'] ?? null);
}
public function getAppointmentTypeAttr($value)
{
return AppointmentTypeEnum::normalizeStored($value);
}
/**
@@ -14,6 +14,9 @@ class PrescriptionOrder extends BaseModel
{
use SoftDelete;
/** 已取消 / 已退款的历史订单不再占用处方;部分退款仍沿用原履约状态。 */
public const RELEASED_PRESCRIPTION_STATUSES = [4, 10];
protected $name = 'tcm_prescription_order';
protected $deleteTime = 'delete_time';
@@ -261,8 +261,13 @@ class QywxPromotionMemberSchedulerService
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
}
$appliedUserIds = self::linkRangeUserIds($linkId);
$changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds);
$alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 3], true);
$departmentJson = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_department_json') ?? '[]');
$departmentIds = json_decode($departmentJson, true);
$changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds)
|| !empty($departmentIds);
// 活跃或超时的租约、尚未应用的版本都不能仅凭旧快照被重算成“已同步”。
$alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 2, 3], true)
|| (int) ($sync['desired_version'] ?? 0) > (int) ($sync['applied_version'] ?? 0);
$needsSync = $changed || $alreadyPending;
self::upsertSync($poolId, $linkId, $needsSync, $sync, $now);
@@ -99,33 +99,42 @@ class QywxPromotionRangeSyncService
$response = $this->api->getLink($remoteLinkId);
$remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId);
$actualUserIds = $remote['range_userids'];
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) {
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)
|| $remote['range_department_ids'] !== []) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
$url = $remote['url'];
$snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
'wecom_url' => $url,
'remote_status' => 1,
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remote_snapshot' => $snapshot === false ? null : $snapshot,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
$freshVersion = (int) (Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)->value('desired_version') ?? 0);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
'status' => $freshVersion === $desiredVersion ? 0 : 1,
$confirmed = Db::transaction(function () use (
$poolId, $token, $desiredVersion, $link, $url, $actualUserIds, $remote, $snapshot
): bool {
$fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if (!$fresh || (int) ($fresh['status'] ?? 0) === 5) {
return false;
}
if ((string) ($fresh['lock_token'] ?? '') !== $token
|| (int) ($fresh['lock_until'] ?? 0) <= time()) {
// 过期工作不能覆盖新工作的确认结果。它可能较晚触达企微,需再推一次最新范围。
QywxPromotionMemberSchedulerService::requestPoolSync($poolId, (int) $fresh['promotion_link_id']);
return false;
}
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
'wecom_url' => $url,
'remote_status' => 1,
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remote_snapshot' => $snapshot === false ? null : $snapshot,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
$isLatest = (int) ($fresh['desired_version'] ?? 0) === $desiredVersion;
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => $isLatest ? 0 : 1,
'desired_member_id' => 0,
'applied_member_id' => 0,
'applied_version' => $desiredVersion,
'next_retry' => $freshVersion === $desiredVersion ? 0 : time(),
'next_retry' => $isLatest ? 0 : time(),
'attempts' => 0,
'lock_token' => '',
'lock_until' => 0,
@@ -133,13 +142,19 @@ class QywxPromotionRangeSyncService
'update_time' => time(),
]);
return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0];
return $isLatest;
});
return ['status' => $confirmed ? 'synced' : 'pending', 'pool_id' => $poolId, 'member_id' => 0];
} catch (\Throwable $e) {
$attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
Db::transaction(function () use ($poolId, $token, $attempts, $claim, $e): void {
$fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if (!$fresh || (string) ($fresh['lock_token'] ?? '') !== $token
|| (int) ($fresh['status'] ?? 0) === 5) {
return;
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 3,
'next_retry' => time() + min(300, 15 * $attempts),
'lock_token' => '',
@@ -147,9 +162,10 @@ class QywxPromotionRangeSyncService
'last_error' => mb_substr($e->getMessage(), 0, 500),
'update_time' => time(),
]);
Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
});
throw $e;
}
@@ -0,0 +1,22 @@
-- 处方业务订单:单独授权修改创建时间。默认不授予任何角色。
-- 请在角色管理中按需勾选“修改业务订单创建时间”。表前缀如非 zyt_ 请调整。
START TRANSACTION;
SET @po_menu_id = (
SELECT id FROM zyt_system_menu
WHERE perms = 'tcm.prescriptionOrder/lists' ORDER BY id ASC LIMIT 1
);
INSERT INTO zyt_system_menu (
pid, type, name, icon, sort, perms, paths, component,
selected, params, is_cache, is_show, is_disable, create_time, update_time
)
SELECT
@po_menu_id, 'A', '修改业务订单创建时间', '', 89,
'tcm.prescriptionOrder/editTime', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @po_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/editTime');
COMMIT;
@@ -0,0 +1,9 @@
-- 问诊类型复用 doctor_appointment.appointment_type;请先备份,在部署窗口执行。
-- 只回填未记录方式的历史挂号,不改变已有 text、video 或历史 phone 记录。
UPDATE `zyt_doctor_appointment`
SET `appointment_type` = 'video'
WHERE `appointment_type` IS NULL OR TRIM(`appointment_type`) = '';
ALTER TABLE `zyt_doctor_appointment`
MODIFY COLUMN `appointment_type` varchar(20) NOT NULL DEFAULT 'video'
COMMENT '问诊类型:video=视频问诊,text=图文问诊;phone仅保留历史记录';
+1 -1
View File
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `zyt_doctor_appointment` (
`appointment_date` date NOT NULL COMMENT '预约日期',
`period` enum('morning','afternoon') NOT NULL COMMENT '时段:morning=上午,afternoon=下午',
`appointment_time` time NOT NULL COMMENT '预约时间',
`appointment_type` varchar(20) DEFAULT 'video' COMMENT '预约类型:video=视频问诊,text=图文问诊phone=电话问诊',
`appointment_type` varchar(20) NOT NULL DEFAULT 'video' COMMENT '问诊类型:video=视频问诊,text=图文问诊phone仅保留历史记录',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1=已预约,2=已取消,3=已完成',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`channel_source` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源(字典channels)',
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\common\enum\AppointmentTypeEnum;
use app\common\model\doctor\Appointment;
$testApp = new think\App();
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
function appointmentTypeExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$payload = [
'id' => 1,
'patient_id' => 101,
'doctor_id' => 202,
'appointment_date' => '2026-09-10',
'appointment_time' => '09:30',
'period' => 'morning',
'channel_source' => 'test',
'status' => 1,
];
foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $label) {
foreach (['create', 'adminEdit'] as $scene) {
$validator = (new AppointmentValidate())->scene($scene);
appointmentTypeExpect($validator->check($payload + ['appointment_type' => $type]), "$scene accepts $type");
}
appointmentTypeExpect(AppointmentTypeEnum::description($type) === $label, "$type label round trip");
appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice');
}
$invalidValues = ['', ' ', 'phone', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
foreach ($invalidValues as $value) {
foreach (['create', 'adminEdit'] as $scene) {
appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value));
}
// Invalid requests must be rejected before touching a database, even if called outside the controller.
appointmentTypeExpect(AppointmentLogic::create(['appointment_type' => $value]) === false, 'create rejects invalid type before DB');
appointmentTypeExpect(AppointmentLogic::adminEdit(['appointment_type' => $value], 0, []) === false, 'edit rejects invalid type before DB');
}
appointmentTypeExpect((new AppointmentValidate())->scene('create')->check($payload), 'legacy create request may omit type');
appointmentTypeExpect(AppointmentTypeEnum::withDefault([])['appointment_type'] === 'video', 'omitted create type persists as video');
appointmentTypeExpect(!(new AppointmentValidate())->scene('adminEdit')->check($payload), 'edit cannot silently reset an existing text selection');
appointmentTypeExpect(AppointmentLogic::adminEdit([], 0, []) === false, 'internal edit also requires explicit type');
$model = (new ReflectionClass(Appointment::class))->newInstanceWithoutConstructor();
foreach ([null, '', ' '] as $legacyEmpty) {
appointmentTypeExpect($model->getAppointmentTypeAttr($legacyEmpty) === 'video', 'legacy empty model value uses video');
appointmentTypeExpect($model->getAppointmentTypeDescAttr(null, ['appointment_type' => $legacyEmpty]) === '视频问诊', 'legacy empty model label uses video');
}
appointmentTypeExpect(AppointmentTypeEnum::description('phone') === '电话问诊', 'historical phone records retain accurate labels');
$filter = (new ReflectionClass(AppointmentLogic::class))->getMethod('filterAppointmentRowByExistingColumns');
appointmentTypeExpect($filter->invoke(null, ['appointment_type' => 'text'], ['appointment_type']) === ['appointment_type' => 'text'], 'text is retained in database write payload');
try {
$filter->invoke(null, ['appointment_type' => 'text'], ['id']);
throw new RuntimeException('missing appointment_type column must not silently lose the selected type');
} catch (RuntimeException $exception) {
appointmentTypeExpect(str_contains($exception->getMessage(), '挂号表缺少 appointment_type'), 'missing schema yields an actionable error');
}
$summary = (new ReflectionClass(DiagnosisLists::class))->getMethod('appendLatestAppointmentSummary');
$lists = (new ReflectionClass(DiagnosisLists::class))->newInstanceWithoutConstructor();
$row = [];
$summary->invokeArgs($lists, [&$row, ['id' => 8, 'appointment_type' => 'text']]);
appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appointment_type'] === 'text' && $row['latest_appointment_type_desc'] === '图文问诊', 'latest appointment summary keeps its own type');
$summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]);
appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type');
echo "Appointment type validation, defaults, legacy labels and summary: OK\n";
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
/**
* Real ORM/transaction tests. Requires an explicitly selected disposable local MySQL.
* Run: ZYT_RX_ORDER_TEST_MYSQL_PORT=13379 php tests/PrescriptionOrderReleaseAndTimeTest.php
* Never initializes the application or loads its business database configuration.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\lists\tcm\PrescriptionLists;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_RX_ORDER_TEST_MYSQL_PORT');
if ($port <= 0) {
fwrite(STDERR, "Set ZYT_RX_ORDER_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
exit(1);
}
$isWorker = ($argv[1] ?? '') === '--worker';
$database = $isWorker ? (string) getenv('ZYT_RX_ORDER_TEST_DATABASE') : 'rx_order_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^rx_order_test_[a-f0-9]{12}$/', $database)) {
throw new RuntimeException('Only this test\'s disposable databases are allowed');
}
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if (!$isWorker) $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
$testApp = new think\App();
$manager = new DbManager();
$manager->setConfig([
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => '',
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
]],
]);
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', new think\Config());
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
$checks++;
};
$createParams = static fn (int $rxId): array => [
'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者',
'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100,
];
if ($isWorker) {
echo "ready\n";
flush();
$out = PrescriptionOrderLogic::create($createParams((int) $argv[2]), 1, $admin);
echo json_encode(['success' => is_array($out), 'error' => PrescriptionOrderLogic::getError()]) . "\n";
exit(0);
}
try {
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_07_create_tcm_prescription_order.sql'));
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order
ADD agency_collect_amount DECIMAL(10,2) NULL, ADD paid DECIMAL(10,2) DEFAULT 0,
ADD refund_amount DECIMAL(10,2) DEFAULT 0, ADD express_company VARCHAR(20) DEFAULT "auto",
ADD ship_mode VARCHAR(20) DEFAULT "gancao", ADD remark_assistant VARCHAR(500) DEFAULT "",
ADD gancao_reciperl_order_no VARCHAR(100) DEFAULT ""');
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
id INT PRIMARY KEY AUTO_INCREMENT, diagnosis_id INT DEFAULT 1, gender INT DEFAULT 0,
creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0,
herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "",
audit_time INT NULL, audit_by INT NULL, audit_by_name VARCHAR(100) DEFAULT "", audit_remark VARCHAR(500) DEFAULT "",
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_order (
id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), patient_id INT DEFAULT 1,
creator_id INT DEFAULT 1, order_type INT DEFAULT 3, amount DECIMAL(10,2), status INT,
remark VARCHAR(200) DEFAULT "", is_exempt INT DEFAULT 0, payment_method VARCHAR(50) DEFAULT "",
create_type VARCHAR(50) DEFAULT "", create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql'));
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis (id) VALUES (1)');
$pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, source_revision INT, status VARCHAR(50)
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(100), delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu (
id INT PRIMARY KEY AUTO_INCREMENT, pid INT, type VARCHAR(5), name VARCHAR(100), icon VARCHAR(50),
sort INT, perms VARCHAR(100), paths VARCHAR(100), component VARCHAR(100), selected VARCHAR(100),
params VARCHAR(100), is_cache INT, is_show INT, is_disable INT DEFAULT 0, create_time INT, update_time INT
)');
$newRx = static fn (array $fields = []): int => (int) Db::name('tcm_prescription')->insertGetId(array_merge(['herbs' => '[]'], $fields));
$fixture = static fn (int $rx, array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
'prescription_id' => $rx, 'order_no' => 'OLD-' . bin2hex(random_bytes(4)), 'diagnosis_id' => 1,
'creator_id' => 1, 'amount' => 100, 'paid' => 100, 'payment_slip_audit_status' => 1,
'create_time' => 1724300000, 'fulfillment_status' => 5,
], $fields));
$row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
$logRows = static fn (int $id): array => Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray();
$detail = static fn (int $rx): array => PrescriptionLogic::detail($rx, 1, $admin);
$listsClass = new ReflectionClass(PrescriptionLists::class);
$lists = $listsClass->newInstanceWithoutConstructor();
foreach (['adminInfo' => $admin, 'adminId' => 1, 'params' => [], 'searchWhere' => [], 'limitOffset' => 0, 'limitLength' => 1000] as $name => $value) {
$listsClass->getProperty($name)->setValue($lists, $value);
}
$listRow = static function (int $rx) use ($lists): array {
return array_values(array_filter($lists->lists(), static fn (array $r): bool => (int) $r['id'] === $rx))[0];
};
// Existing refunded/cancelled/deleted history never blocks a new order or contaminates active audit flags.
foreach ([['fulfillment_status' => 10], ['fulfillment_status' => 4], ['delete_time' => time()]] as $released) {
$rx = $newRx();
$oldId = $fixture($rx, array_merge($released, ['prescription_audit_status' => 2, 'prescription_audit_remark' => '旧驳回']));
$before = $row($oldId);
$expect($detail($rx)['has_prescription_order'] === 0 && $listRow($rx)['has_prescription_order'] === 0, 'Released history must be available in list and detail');
$expect($detail($rx)['business_prescription_audit_rejected'] === 0 && $listRow($rx)['business_prescription_audit_rejected'] === 0, 'Released history must not carry rejection badges');
$expect($listsClass->getMethod('collectRiskPrescriptionIds')->invoke($lists, [$rx]) === [], 'Released orders must not pin herb-risk rows');
$out = PrescriptionOrderLogic::create($createParams($rx), 1, $admin);
$expect(is_array($out), 'Approved prescription must support creating after released history');
$expect($row($oldId) === $before, 'Creation must preserve all historical order fields');
$expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'New order must occupy the prescription in list and detail');
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'A second live order must be rejected');
}
foreach ([1, 2, 3, 5, 6, 7, 8, 9, 11, 12] as $status) {
$rx = $newRx();
$fixture($rx, ['fulfillment_status' => 10]);
$fixture($rx, ['fulfillment_status' => $status]);
$expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'Every nonreleased live status must occupy prescription, even with refunded history');
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Live status must prevent duplicate creation');
}
// Exercise the actual refund endpoint logic and preserve payment/remote history and approved prescription.
$rx = $newRx();
$oldId = $fixture($rx, ['gancao_reciperl_order_no' => 'REMOTE-HISTORY']);
$payId = Db::name('order')->insertGetId(['order_no' => 'PAID-HISTORY', 'status' => 2, 'amount' => 100]);
Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $oldId, 'pay_order_id' => $payId, 'create_time' => time()]);
Db::name('tcm_prescription_order')->where('id', $oldId)->update(['linked_pay_order_id' => $payId]);
$out = PrescriptionOrderLogic::refund($oldId, '测试全退', 1, $admin);
$expect(is_array($out) && (int) $out['fulfillment_status'] === 10, 'Full refund must transition to released status');
$expect((int) Db::name('order')->where('id', $payId)->value('status') === 4, 'Original payment must stay linked and be marked refunded');
$expect(Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $oldId)->count() === 1, 'Refund must preserve payment association history');
$expect($detail($rx)['has_prescription_order'] === 0 && (int) $detail($rx)['audit_status'] === 1, 'Refund must release an already approved prescription without resetting approval');
$expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Actual refund must permit a new business order');
$expect($row($oldId)['gancao_reciperl_order_no'] === 'REMOTE-HISTORY' && (int) $row($oldId)['prescription_id'] === $rx, 'New order must preserve old remote and prescription associations');
$rx = $newRx();
$oldId = $fixture($rx);
$out = PrescriptionOrderLogic::refund($oldId, '测试部分退款', 1, $admin, 20);
$expect(is_array($out) && (int) $out['fulfillment_status'] === 5 && (float) $out['paid'] === 80.0, 'Partial refund must remain active while there is a balance');
$expect($detail($rx)['has_prescription_order'] === 1 && PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Partial refund must not release prescription');
$out = PrescriptionOrderLogic::refund($oldId, '剩余全退', 1, $admin);
$expect(is_array($out) && $detail($rx)['has_prescription_order'] === 0, 'Refunding remaining balance must release prescription');
$rx = $newRx();
$oldId = $fixture($rx, ['fulfillment_status' => 1]);
$expect(is_array(PrescriptionOrderLogic::withdraw($oldId, 1, $admin)), 'Existing cancellation must still work');
$expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Withdrawn order must still release prescription');
$rx = $newRx(['void_status' => 1]);
$fixture($rx, ['fulfillment_status' => 10]);
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Refund must never revive a voided prescription');
$rx = $newRx(['delete_time' => time()]);
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Deleted prescription must not be orderable');
// Audit-log failures roll back order creation and creation-time edits.
$rx = $newRx();
$oldId = $fixture($rx, ['fulfillment_status' => 10]);
$before = $row($oldId);
$pdo->exec("CREATE TRIGGER reject_order_log BEFORE INSERT ON zyt_tcm_prescription_order_log
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit failure'");
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Create log failure must reject creation');
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->count() === 1, 'Create log failure must not leave a partial active order');
$timeParams = ['id' => $oldId, 'create_time' => '2026-08-22 11:40:03'];
$expect(PrescriptionOrderLogic::editTime($timeParams, 1, $admin) === false && $row($oldId) === $before, 'Time edit must roll back if audit logging fails');
$pdo->exec('DROP TRIGGER reject_order_log');
// Dedicated permission is enforced before menu deployment, including owners and manager roles.
foreach ([[], [3]] as $roles) {
$out = PrescriptionOrderLogic::editTime($timeParams, 1, ['root' => 0, 'admin_id' => 1, 'role_id' => $roles]);
$expect($out === false && $row($oldId) === $before, 'Ownership/general manager role must not imply time-edit permission');
}
foreach ([[], ['id' => 0, 'create_time' => '2026-08-22 11:40:03'], ['id' => 1, 'create_time' => ''],
['id' => 1, 'create_time' => '2026-02-30 11:40:03'], ['id' => 1, 'create_time' => '2026-08-22'],
['id' => 1, 'create_time' => ['2026-08-22 11:40:03']], ['id' => [1], 'create_time' => '2026-08-22 11:40:03']] as $invalid) {
$expect(!(new PrescriptionOrderValidate())->scene('editTime')->check($invalid), 'Invalid time requests must be rejected');
}
$expect((new PrescriptionOrderValidate())->scene('editTime')->check($timeParams), 'Canonical creation datetime must validate');
$out = PrescriptionOrderLogic::editTime($timeParams + ['paid' => 999, 'fulfillment_status' => 1], 1, $admin);
$after = $row($oldId);
$expect(is_array($out) && (int) $after['create_time'] === strtotime($timeParams['create_time']), 'Timestamp schema must retain Unix creation time');
foreach ($before as $key => $value) {
if (!in_array($key, ['create_time', 'update_time'], true)) $expect($after[$key] === $value, 'Time edit must preserve ' . $key);
}
$log = $logRows($oldId)[0];
$expect($log['action'] === 'edit_time' && (int) $log['admin_id'] === 1
&& str_contains($log['summary'], date('Y-m-d H:i:s', (int) $before['create_time']))
&& str_contains($log['summary'], $timeParams['create_time']), 'Audit log must record operator, previous and new time');
$expect(is_array(PrescriptionOrderLogic::editTime($timeParams, 1, $admin)) && count($logRows($oldId)) === 1, 'Repeated identical edit must not duplicate audit log');
$deletedId = $fixture($newRx(), ['delete_time' => time()]);
$expect(PrescriptionOrderLogic::editTime(['id' => $deletedId, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Deleted order time must not be editable');
$expect(PrescriptionOrderLogic::editTime(['id' => 999999, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Missing order time must not be editable');
$pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)");
$sql = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql');
$pdo->exec($sql);
$pdo->exec($sql);
$expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->count() === 1, 'Time permission migration must be idempotent');
$expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant privileges automatically');
$menuId = Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->value('id');
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]);
Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]);
$out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-23 11:40:03'], 2,
['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']);
$expect(is_array($out) && array_keys($out) === ['id', 'create_time'], 'Explicit time permission must work and return only safe fields');
// Two independent PHP connections race behind the prescription lock, including already-refunded history.
foreach ([false, true] as $withHistory) {
$rx = $newRx();
if ($withHistory) $fixture($rx, ['fulfillment_status' => 10]);
putenv('ZYT_RX_ORDER_TEST_DATABASE=' . $database);
Db::startTrans();
Db::name('tcm_prescription')->where('id', $rx)->lock(true)->find();
$workers = [];
try {
foreach ([1, 2] as $_) {
$process = proc_open([PHP_BINARY, __FILE__, '--worker', (string) $rx],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) throw new RuntimeException('Cannot start concurrency worker');
fclose($pipes[0]);
$workers[] = [$process, $pipes];
if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Worker failed initialization');
}
} finally {
Db::commit();
}
$results = [];
foreach ($workers as [$process, $pipes]) {
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) throw new RuntimeException('Worker failed: ' . $errors . $output);
$results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR);
}
$expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent create must succeed exactly once');
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->whereNotIn('fulfillment_status', [4,10])->count() === 1, 'Concurrency must persist only one live order');
}
// Compatibility with installations that store create_time as DATETIME.
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order ADD legacy_datetime DATETIME NULL');
$pdo->exec('UPDATE zyt_tcm_prescription_order SET legacy_datetime=FROM_UNIXTIME(create_time)');
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order DROP create_time, CHANGE legacy_datetime create_time DATETIME NULL');
$out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-24 11:40:03'], 1, $admin);
$expect(is_array($out) && $row($oldId)['create_time'] === '2026-08-24 11:40:03', 'Datetime schema must preserve canonical datetime strings');
echo "PrescriptionOrderReleaseAndTimeTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
/**
* Real MySQL/Think ORM regression tests; all enterprise WeChat requests are fake.
* ZYT_WECOM_MEMBER_TEST_MYSQL_PORT must point at a disposable loopback MySQL server.
* No application initialization, environment file, or business DB config is loaded.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use think\Container;
use think\DbManager;
use think\facade\Db;
final class MemberSyncFakeApi extends QywxCustomerAcquisitionApiService
{
public array $updates = [];
public array $gets = [];
public ?array $remoteUsers = null;
public array $remoteDepartments = [];
public string $failure = '';
public ?Closure $onGet = null;
// Deliberately do not create the real HTTP client or token resolver.
public function __construct() {}
public function updateLink(array $payload): array
{
$this->updates[] = $payload;
if ($this->failure !== '') {
throw new RuntimeException($this->failure);
}
return ['errcode' => 0, 'errmsg' => 'ok'];
}
public function getLink(string $linkId): array
{
$this->gets[] = $linkId;
$payload = $this->updates[count($this->updates) - 1] ?? [];
$response = [
'errcode' => 0,
'link' => ['link_id' => $linkId, 'url' => 'https://work.weixin.qq.com/ca/isolated-test'],
// Official GET shape: range is at the root, not under link.
'range' => [
'user_list' => $this->remoteUsers ?? ($payload['range']['user_list'] ?? []),
'department_list' => $this->remoteDepartments,
],
];
if ($this->onGet !== null) {
($this->onGet)();
}
return $response;
}
}
$port = (int) getenv('ZYT_WECOM_MEMBER_TEST_MYSQL_PORT');
if ($port < 1024 || $port === 3306 || $port > 65535) {
fwrite(STDERR, "Set ZYT_WECOM_MEMBER_TEST_MYSQL_PORT to an isolated local MySQL port (not 3306).\n");
exit(1);
}
$database = 'wecom_member_test_' . bin2hex(random_bytes(6));
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
$testApp = new think\App();
$manager = new DbManager();
$manager->setConfig([
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => '',
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
]],
]);
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', new think\Config());
$checks = 0;
$passed = [];
$failed = [];
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) {
throw new RuntimeException($message);
}
$checks++;
};
$run = static function (string $name, Closure $test) use (&$passed, &$failed): void {
try {
$test();
$passed[] = $name;
echo "PASS {$name}\n";
} catch (Throwable $error) {
$failed[$name] = $error->getMessage();
echo "FAIL {$name}: {$error->getMessage()}\n";
}
};
$admin = ['root' => 1, 'admin_id' => 1, 'name' => 'Isolated test administrator'];
try {
// Use the deployed table definitions, without executing unrelated menu/cron mutations.
$schemas = [
'1.9.20260805/add_first_visit_wecom_promotion.sql' => ['qywx_promotion_pool', 'qywx_promotion_link'],
'1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql' => [
'qywx_promotion_pool_member', 'qywx_promotion_dispatch_event', 'qywx_promotion_range_sync',
],
'1.9.20260828/add_wecom_promotion_pool_operators.sql' => ['qywx_promotion_pool_operator'],
];
foreach ($schemas as $file => $tables) {
$sql = file_get_contents(dirname(__DIR__) . '/sql/' . $file);
foreach ($tables as $table) {
if (!preg_match('/CREATE TABLE IF NOT EXISTS `zyt_' . preg_quote($table, '/') . '` \([\s\S]*?;/', $sql, $match)) {
throw new RuntimeException('Missing fixture schema: ' . $table);
}
$pdo->exec($match[0]);
}
}
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)');
$fixture = static function (array $syncFields = [], array $cachedUsers = ['XuKe', 'OldAssistant'], array $cachedDepartments = []): array {
$poolId = (int) Db::name('qywx_promotion_pool')->insertGetId([
'name' => '隔离范围同步测试', 'public_key' => bin2hex(random_bytes(16)), 'owner_admin_id' => 1,
]);
$linkId = (int) Db::name('qywx_promotion_link')->insertGetId([
'pool_id' => $poolId, 'remote_link_id' => 'test-remote-' . $poolId,
'wecom_url' => 'https://work.weixin.qq.com/ca/isolated-test', 'remote_status' => 1,
'range_user_json' => json_encode($cachedUsers), 'range_department_json' => json_encode($cachedDepartments),
]);
foreach ([['XuKe', 1], ['OldAssistant', 0], ['AnotherOldAssistant', 0]] as $index => [$userId, $enabled]) {
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId, 'admin_id' => $index + 1, 'userid' => $userId,
'enabled' => $enabled, 'today_date' => date('Y-m-d'),
]);
}
Db::name('qywx_promotion_range_sync')->insert(array_replace([
'pool_id' => $poolId, 'promotion_link_id' => $linkId, 'status' => 1,
'desired_version' => 2, 'applied_version' => 1,
], $syncFields));
return [$poolId, $linkId, new MemberSyncFakeApi()];
};
$syncRow = static fn (int $poolId): array => Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find();
$linkRow = static fn (int $linkId): array => Db::name('qywx_promotion_link')->where('id', $linkId)->find();
$retry = static fn (int $poolId, MemberSyncFakeApi $api): array => WecomPromotionLogic::syncMemberRange(
$poolId, 1, $admin, new QywxPromotionRangeSyncService($api)
);
$run('only XuKe is sent and both remote range dimensions are verified', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture([], ['XuKe', 'OldAssistant'], ['42']);
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'synced', 'Exact confirmed range should sync');
$expect(count($api->updates) === 1 && $api->gets === ['test-remote-' . $poolId], 'Must update then GET the same official link');
$expect($api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Disabled members and departments must be removed from update');
$link = $linkRow($linkId);
$expect(json_decode($link['range_user_json'], true) === ['XuKe'] && json_decode($link['range_department_json'], true) === [], 'Save the GET-verified users and empty departments');
$row = $syncRow($poolId);
$expect((int) $row['status'] === 0 && (int) $row['applied_version'] === (int) $row['desired_version'] && $row['lock_token'] === '', 'Confirmed sync must finish its version and release the lease');
});
foreach (['old user' => [['XuKe', 'OldAssistant'], []], 'department expansion' => [['XuKe'], ['42']]] as $name => [$users, $departments]) {
$run('GET mismatch rejects ' . $name . ' and remains retryable', static function () use ($fixture, $expect, $syncRow, $linkRow, $users, $departments): void {
[$poolId, $linkId, $api] = $fixture();
$api->remoteUsers = $users;
$api->remoteDepartments = $departments;
$error = null;
try {
(new QywxPromotionRangeSyncService($api))->syncPool($poolId);
} catch (Throwable $caught) {
$error = $caught;
}
$expect($error !== null, 'Mismatched confirmed range must reject sync');
$row = $syncRow($poolId);
$expect((int) $row['status'] === 3 && (int) $row['next_retry'] > time(), 'Mismatch must leave a scheduled retry');
$expect((int) $row['applied_version'] === 1 && $row['last_error'] !== '' && $linkRow($linkId)['sync_error'] !== '', 'Failure must not advance confirmed version and must remain visible');
$expect((int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('enabled') === 0, 'Failure must preserve the saved offline switch');
});
}
$run('transport failure preserves last confirmed snapshot and retry', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture();
$before = $linkRow($linkId)['range_user_json'];
$api->failure = 'fake upstream timeout';
try {
(new QywxPromotionRangeSyncService($api))->syncPool($poolId);
throw new LogicException('Expected fake transport error');
} catch (RuntimeException $error) {
$expect($error->getMessage() === $api->failure, 'Surface the transport error');
}
$row = $syncRow($poolId);
$expect((int) $row['status'] === 3 && (int) $row['attempts'] === 1 && (int) $row['next_retry'] > time(), 'Transport failure must retain retry backoff');
$expect($linkRow($linkId)['range_user_json'] === $before && $api->gets === [], 'Failed update cannot replace confirmed remote snapshot');
});
$run('active sync lease cannot be reported as synced or stolen', static function () use ($fixture, $expect, $syncRow): void {
$token = str_repeat('a', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] !== 'synced' && $api->updates === [] && $syncRow($poolId)['lock_token'] === $token, 'Existing worker keeps its active lease and caller stays pending');
});
$run('version change during GET reports pending and resyncs', static function () use ($fixture, $expect, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->onGet = static function () use ($poolId): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->inc('desired_version')->update();
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$row = $syncRow($poolId);
$expect($result['status'] === 'pending' && (int) $row['status'] === 1, 'Stale confirmed version must report pending, never synced');
$expect((int) $row['applied_version'] < (int) $row['desired_version'], 'Concurrent version must remain unconfirmed');
$api->onGet = null;
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced', 'Next attempt should confirm the newer version');
});
$run('lease expiry during GET cannot commit success', static function () use ($fixture, $expect, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->onGet = static function () use ($poolId): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]);
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'pending' && (int) $syncRow($poolId)['status'] !== 0, 'Expired worker cannot acknowledge a completed sync');
});
$run('superseded lease cannot overwrite newer worker snapshot', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture();
$newToken = str_repeat('b', 32);
$api->onGet = static function () use ($poolId, $linkId, $newToken): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'lock_token' => $newToken, 'lock_until' => time() + 90, 'desired_version' => 3,
]);
Db::name('qywx_promotion_link')->where('id', $linkId)->update(['range_user_json' => '["NewWorkerSnapshot"]']);
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'pending' && $syncRow($poolId)['lock_token'] === $newToken, 'Superseded worker must not release or acknowledge the new lease');
$expect($linkRow($linkId)['range_user_json'] === '["NewWorkerSnapshot"]', 'Superseded worker must not overwrite a newer remote snapshot');
});
foreach ([
'expired running lease' => ['status' => 2, 'lock_token' => str_repeat('c', 32), 'lock_until' => time() - 1],
'unconfirmed version' => ['status' => 0, 'desired_version' => 7, 'applied_version' => 6],
] as $name => $fields) {
$run('reconcile keeps ' . $name . ' pending despite matching cache', static function () use ($fixture, $expect, $syncRow, $fields): void {
[$poolId] = $fixture($fields, ['XuKe']);
$result = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'An unverified version or expired worker must be retried even when user cache matches');
});
}
$run('reconcile removes cached departments even when users match', static function () use ($fixture, $expect, $syncRow): void {
[$poolId] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2], ['XuKe'], ['42']);
$result = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'Residual department routes require a fresh update');
});
foreach (['pending' => ['status' => 1], 'failed backoff' => ['status' => 3, 'next_retry' => time() + 300, 'last_error' => 'previous failure']] as $name => $fields) {
$run('explicit retry repairs old ' . $name . ' without changing member switches', static function () use ($fixture, $expect, $syncRow, $retry, $fields): void {
[$poolId, , $api] = $fixture($fields);
$before = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid');
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'synced' && $result['sync_error'] === '' && !$result['sync_queued'], 'Explicit retry must complete and return confirmed structured state');
$expect($result['range_userids'] === ['XuKe'] && $result['range_department_ids'] === [] && count($api->updates) === 1, 'Retry must return the GET-confirmed member range');
$expect($before === Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid') && (int) $syncRow($poolId)['status'] === 0, 'Retry must leave all switches unchanged');
});
}
$run('explicit retry active lease returns pending without false success', static function () use ($fixture, $expect, $retry, $syncRow): void {
$token = str_repeat('d', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $api->updates === [], 'In-flight retry must say pending');
$expect($syncRow($poolId)['lock_token'] === $token && (int) $syncRow($poolId)['status'] === 2, 'Retry must preserve active worker ownership');
});
$run('explicit retry API failure remains failed with saved local state', static function () use ($fixture, $expect, $retry, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->failure = 'fake permission denied';
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'failed' && $result['sync_error'] !== '' && $result['sync_queued'], 'Failure should return a visible error and scheduled retry');
$expect((int) $syncRow($poolId)['status'] === 3 && $result['range_userids'] === ['XuKe', 'OldAssistant'], 'Failure must expose the last confirmed range, including pending removal');
});
$run('no eligible member is blocked and never sends empty official range', static function () use ($fixture, $expect, $retry, $syncRow): void {
[$poolId, , $api] = $fixture();
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 0]);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'blocked' && $result['sync_error'] !== '' && !$result['sync_queued'], 'Empty eligible range must clearly report blocked');
$expect($api->updates === [] && (int) $syncRow($poolId)['status'] === 4, 'Blocked pool must not send an empty enterprise WeChat range');
});
$run('explicit retry rejects pools outside operator scope', static function () use ($fixture, $expect): void {
[$poolId, , $api] = $fixture();
$error = null;
try {
WecomPromotionLogic::syncMemberRange($poolId, 99, ['root' => 0, 'admin_id' => 99], new QywxPromotionRangeSyncService($api));
} catch (RuntimeException $caught) {
$error = $caught;
}
$expect($error !== null && $api->updates === [], 'Unrelated account must not mutate remote member ranges');
});
$run('batch repeated offline selection requeues the unsynced remote range', static function () use ($fixture, $expect, $syncRow, $admin): void {
[$poolId, , $api] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2]);
$result = WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId],
'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]],
], 1, $admin);
$expect($result['failed'] === 0 && $result['member_matched'] === 2 && $result['member_updated'] === 0, 'An already-offline selection remains a valid repeat action');
$expect($result['sync_queued_count'] === 1 && $result['results'][0]['sync_queued'] && (int) $syncRow($poolId)['status'] === 1, 'No-op local switches must still queue the stale official range');
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'The queued batch repair must leave only XuKe in the official link');
});
$run('batch disables both old assistants and queue confirms XuKe only', static function () use ($fixture, $expect, $admin): void {
[$poolId, , $api] = $fixture([], ['XuKe', 'OldAssistant', 'AnotherOldAssistant']);
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 1]);
$result = WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId],
'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]],
], 1, $admin);
$expect($result['member_updated'] === 2 && $result['failed'] === 0 && $result['sync_queued_count'] === 1, 'Batch offline must save both switches and report queued remote work');
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Actual batch queue must update/get the final exact range');
});
$run('single offline with active worker reports pending then retries successfully', static function () use ($fixture, $expect, $admin, $retry, $syncRow): void {
$token = str_repeat('e', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->update(['enabled' => 1]);
$memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('id');
// Active lease prevents any real API request from the endpoint's default service.
$result = WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin);
$expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $syncRow($poolId)['lock_token'] === $token, 'Single toggle must not turn a service noop into success: ' . json_encode($result, JSON_UNESCAPED_UNICODE));
$expect((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, 'Single toggle must persist offline locally while waiting for its worker');
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]);
$expect($retry($poolId, $api)['sync_status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'Explicit retry must clear the single-toggle pending removal');
});
$run('single and batch preserve at least one eligible assistant', static function () use ($fixture, $expect, $admin): void {
[$poolId] = $fixture();
$memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'XuKe')->value('id');
foreach (['single', 'batch'] as $method) {
$error = null;
try {
if ($method === 'single') {
WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin);
} else {
WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [1], 'status' => 0]],
], 1, $admin);
}
} catch (RuntimeException $caught) {
$error = $caught;
}
$expect($error !== null && (int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 1, 'The final available member must remain online after rejected ' . $method . ' action');
}
});
$run('shared operator can retry its own assigned pool', static function () use ($fixture, $expect): void {
[$poolId, , $api] = $fixture();
Db::name('system_menu')->insert(['id' => 1, 'perms' => 'firstvisit.wecomPromotion/overview']);
Db::name('qywx_promotion_pool_operator')->insert(['pool_id' => $poolId, 'admin_id' => 88]);
$result = WecomPromotionLogic::syncMemberRange($poolId, 88, ['root' => 0, 'admin_id' => 88], new QywxPromotionRangeSyncService($api));
$expect($result['sync_status'] === 'synced' && count($api->updates) === 1, 'Assigned operator should be allowed the targeted range retry');
});
foreach ([
'deleting' => ['status' => 5, 'lock_token' => str_repeat('f', 32), 'lock_until' => time() + 90],
'delete failed' => ['status' => 4, 'last_error' => '企业微信官方获客链接删除失败: fake timeout'],
] as $name => $fields) {
$run('retry never revives ' . $name . ' pool', static function () use ($fixture, $expect, $retry, $syncRow, $fields): void {
[$poolId, , $api] = $fixture($fields);
$before = $syncRow($poolId);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'blocked' && $api->updates === [] && $syncRow($poolId) === $before, 'Retry must preserve deletion ownership and leave remote API untouched');
});
}
echo json_encode(['passed' => count($passed), 'failed' => count($failed), 'checks' => $checks, 'failures' => $failed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
} finally {
$pdo->exec("DROP DATABASE IF EXISTS `{$database}`");
echo "Disposable test database dropped.\n";
}
exit($failed === [] ? 0 : 1);