更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user