From 623bda2ecb82730a1758fd122244d1bf8b1f8739 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 15:20:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/views/fans/yeji.vue | 15 +- .../diagnosis/components/AssignLogPanel.vue | 58 +++++++ admin/src/views/tcm/diagnosis/index.vue | 9 +- .../adminapi/logic/stats/YejiStatsLogic.php | 147 ++++++++++-------- .../app/adminapi/logic/tcm/DiagnosisLogic.php | 140 ++++++++++++++--- 5 files changed, 271 insertions(+), 98 deletions(-) diff --git a/admin/src/views/fans/yeji.vue b/admin/src/views/fans/yeji.vue index a190d5dc..7f6b8bab 100644 --- a/admin/src/views/fans/yeji.vue +++ b/admin/src/views/fans/yeji.vue @@ -411,7 +411,7 @@
add_external_contact 事件数,按接待企微成员归属部门;定渠道时须客户带对应企微标签。
@@ -423,9 +423,9 @@ + + diff --git a/admin/src/views/tcm/diagnosis/index.vue b/admin/src/views/tcm/diagnosis/index.vue index f797cdd1..3c6bfb27 100644 --- a/admin/src/views/tcm/diagnosis/index.vue +++ b/admin/src/views/tcm/diagnosis/index.vue @@ -1390,13 +1390,12 @@ const handleConfirmAssign = async () => { feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选') return } - const promises = ids.map((id) => - tcmDiagnosisAssign({ + for (const id of ids) { + await tcmDiagnosisAssign({ id, - assistant_id: assignForm.value.assistant_id + assistant_id: assignForm.value.assistant_id!, }) - ) - await Promise.all(promises) + } selectedIds.value = [] batchAssignIds.value = [] } diff --git a/server/app/adminapi/logic/stats/YejiStatsLogic.php b/server/app/adminapi/logic/stats/YejiStatsLogic.php index f1a24942..14185758 100644 --- a/server/app/adminapi/logic/stats/YejiStatsLogic.php +++ b/server/app/adminapi/logic/stats/YejiStatsLogic.php @@ -15,9 +15,9 @@ use think\facade\Db; * 业绩看板(部门 × 时间区间 × 渠道来源) * * - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门 - * · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0** - * - 被指派数 = 区间内 tcm_diagnosis_assign_log 成功指派次数(to_assistant_id>0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助) - * · **二中心展示部门在选定渠道下计 0**(与进线一致) + * · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0** + * - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign) + * · **二中心及其组织下级在选定渠道下计 0**(与进线一致) * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致 * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 * - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。 @@ -25,15 +25,15 @@ use think\facade\Db; * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人的人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致。 * 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。 * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 - * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子** + * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致) * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致); * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 * - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 - * · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门)。 - * · ROI = 渠道业绩合计 ÷ 总投放成本(选定渠道时分子不含二中心渠道业绩)。 - * - ROI = 选定渠道时为 **{渠道}业绩合计** ÷ 投放成本(与表格「渠道业绩」列合计一致;未选渠道时分子为全表业绩合计;成本为 0 时返回 null) + * · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。 + * · ROI = (渠道业绩合计或全量业绩合计,**均不含二中心子树各部门的对应业绩**)÷ 总投放成本(成本为 0 时返回 null) + * - ROI = **不含二中心子树**后的 **{渠道}业绩合计**(或未选渠道时为全量业绩口径下同一套「部门行之和」)÷ 投放成本;部门行在四舍五入前若分摊成本为 0 则无 ROI * * 「业绩/接诊标签匹配」: * 优先 tcm_diagnosis.external_userid = 客户 external_userid; @@ -274,6 +274,7 @@ class YejiStatsLogic : null; $leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId); + $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $assignsByDept = self::countDiagnosisAssignsByDept( $startTs, $endTs, @@ -338,6 +339,14 @@ class YejiStatsLogic ); $performance = $perfBoth['all']; $channelPerformance = $perfBoth['scoped']; + /** 二中心子树展示行「计入 ROI 分子的业绩」合计({渠道}业绩或与合计业绩相同),供未选渠道时从全表分子里扣除 */ + $sumCompletedPerfErCenter = 0.0; + foreach ($tableRowDeptIds as $erDeptId) { + if (!isset($erCenterDeptSet[(int) $erDeptId])) { + continue; + } + $sumCompletedPerfErCenter += round((float) ($channelPerformance[$erDeptId] ?? 0), 1); + } // 成交单数(全量):与合计业绩列同列表部门口径 $dealOrderByDept = $listAlignedPack !== null ? $listAlignedPack['count'] @@ -352,17 +361,15 @@ class YejiStatsLogic ); $globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs); - /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减「二中心」展示部门上的渠道业绩 */ + /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减二中心子树部门的渠道业绩/成交单;进线、被指派同清零 */ $erCenterChannelStrip = 0.0; if ($channelCode !== '') { - $erCenterChannelStrip = self::excludeErCenterFromChannelMetrics( - $primaryDeptIds, - $deptNameMap, + $erCenterChannelStrip = self::excludeErCenterSubtreeFromChannelMetrics( + $tableRowDeptIds, + $erCenterDeptSet, $channelPerformance, $channelConsults ); - self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads); - self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept); } $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); @@ -372,8 +379,14 @@ class YejiStatsLogic $leads, $deptToDescendants ); + self::zeroIntKeyedDeptMetricsInErCenterSubtree($leadsForCost, $erCenterDeptSet); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); + if ($channelCode !== '') { + self::zeroIntKeyedDeptMetricsInErCenterSubtree($leads, $erCenterDeptSet); + self::zeroIntKeyedDeptMetricsInErCenterSubtree($assignsByDept, $erCenterDeptSet); + } + @set_time_limit(120); $revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs); $revisitTotals = $revisitPack['totals']; @@ -537,8 +550,16 @@ class YejiStatsLogic } $totalCost = round($globalCost, 1); - // ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计 - $totalRoiNumerator = round($totalChannelPerf, 1); + // ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和 + $totalRoiNumerator = round( + max( + 0.0, + $channelFilterActive + ? $totalChannelPerf + : ($totalChannelPerf - $sumCompletedPerfErCenter) + ), + 1 + ); if ($totalRevisitSlots !== []) { ksort($totalRevisitSlots, SORT_NUMERIC); @@ -550,7 +571,7 @@ class YejiStatsLogic . ($dataScopeRestricted ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' : ''); - $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊。'; + $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。'; return [ 'start_date' => $startDate, @@ -562,7 +583,7 @@ class YejiStatsLogic 'channel_filter_note' => $channelCode !== '' ? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。' . ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。' - . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' + . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。' . ($tagFallback ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' : '') @@ -707,9 +728,10 @@ class YejiStatsLogic ); if ($channelFilterActive) { + $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); self::excludeErCenterFromAdminLeaderboard( $adminToPrimary, - $deptNameMap, + $erCenterDeptSet, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin, @@ -745,7 +767,7 @@ class YejiStatsLogic $rangeNote = $channelFilterActive ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' - . '「二中心」医助在该渠道下诊金、进线与被指派计 0。' + . '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') : '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; @@ -815,7 +837,7 @@ class YejiStatsLogic 'channel_code' => $channelCode, 'channel_name' => $channelName, 'channel_filter_note' => $channelCode !== '' - ? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。' + ? '医助榜:**二中心及其组织下级**在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。' : '', 'range_note' => $rangeNote, 'leaderboards' => $leaderboards, @@ -1613,7 +1635,8 @@ class YejiStatsLogic } /** - * 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源。 + * 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。 + * 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。 * * @param array $adminToPrimary * @param int[]|null $tagDiagIds @@ -1636,13 +1659,11 @@ class YejiStatsLogic return []; } - $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') - ->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') - ->where('lg.create_time', 'between', [$startTs, $endTs]) + ->where('lg.related_po_create_time', '>', 0) + ->where('lg.related_po_create_time', 'between', [$startTs, $endTs]) ->where('lg.to_assistant_id', '>', 0); - if ($channelFilterActive) { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); @@ -1653,6 +1674,8 @@ class YejiStatsLogic if ($pack === null) { return []; } + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER'); $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { @@ -1718,11 +1741,10 @@ class YejiStatsLogic ?array $dataScopeAdminIds = null, ?array $channelInfo = null ): array { - $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_diagnosis_assign_log') ->alias('lg') - ->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') - ->where('lg.create_time', 'between', [$startTs, $endTs]) + ->where('lg.related_po_create_time', '>', 0) + ->where('lg.related_po_create_time', 'between', [$startTs, $endTs]) ->where('lg.to_assistant_id', '>', 0); if ($channelFilterActive) { @@ -1735,6 +1757,8 @@ class YejiStatsLogic if ($pack === null) { return []; } + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER'); $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { @@ -2361,14 +2385,14 @@ class YejiStatsLogic ); } - $deptNameMap = $c['deptNameMap']; $channelCode = (string) $c['channelCode']; - if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) { + $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); + if ($channelCode !== '' && isset($erCenterDeptSet[$deptId])) { return $empty( $c, $deptId, self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), - '与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。' + '与看板一致:二中心及其组织下级展示部门在选定渠道下进线计 0,无明细。' ); } @@ -3227,22 +3251,26 @@ class YejiStatsLogic } /** - * 选定渠道时:二中心展示部门下属医助的渠道诊金、进线清零(与部门行一致)。 + * 选定渠道时:二中心子树(DeptLogic::getErCenterSubtreeDeptIdSet)下属医助的渠道诊金、进线清零(与部门行一致)。 * - * @param array $feeByAdmin - * @param array $leadsByAdmin + * @param array $adminToPrimary + * @param array $erCenterDeptSet + * @param array $feeByAdmin + * @param array $leadsByAdmin + * @param array|null $dealOrderByAdmin * @param array|null $assignsByAdmin */ private static function excludeErCenterFromAdminLeaderboard( array $adminToPrimary, - array $deptNameMap, + array $erCenterDeptSet, array &$feeByAdmin, array &$leadsByAdmin, ?array &$dealOrderByAdmin = null, ?array &$assignsByAdmin = null ): void { foreach ($adminToPrimary as $aid => $pid) { - if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { + $pid = (int) $pid; + if ($pid <= 0 || !isset($erCenterDeptSet[$pid])) { continue; } $feeByAdmin[$aid] = 0.0; @@ -4270,51 +4298,46 @@ class YejiStatsLogic } /** - * 展示部门名称是否属「二中心」系:选定渠道时该部门不参与该渠道进线/业绩/成交单/ROI 分子。 - */ - private static function isErCenterDisplayPrimaryName(string $name): bool - { - return mb_strpos($name, '二中心') !== false; - } - - /** - * 选定渠道时:名称含「二中心」的展示部门渠道业绩与渠道成交单置 0,并从后续全量渠道 SUM 中扣减同等金额。 + * 选定渠道时:二中心子树展示行渠道业绩与渠道成交单置 0,并返回扣减金额(与 sumGlobalChannelScopedPerformance 对齐)。 * + * @param int[] $tableRowDeptIds + * @param array $erCenterDeptSet * @param array $channelPerformance - * @param array $channelConsults - * @return float 扣减金额(与 sumGlobalChannelScopedPerformance 对齐) + * @param array $channelConsults */ - private static function excludeErCenterFromChannelMetrics( - array $primaryDeptIds, - array $deptNameMap, + private static function excludeErCenterSubtreeFromChannelMetrics( + array $tableRowDeptIds, + array $erCenterDeptSet, array &$channelPerformance, array &$channelConsults ): float { $stripped = 0.0; - foreach ($primaryDeptIds as $pid) { - if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { + foreach ($tableRowDeptIds as $tid) { + $tid = (int) $tid; + if (!isset($erCenterDeptSet[$tid])) { continue; } - $stripped += (float) ($channelPerformance[$pid] ?? 0); - $channelPerformance[$pid] = 0.0; - $channelConsults[$pid] = 0; + $stripped += (float) ($channelPerformance[$tid] ?? 0); + $channelPerformance[$tid] = 0.0; + $channelConsults[$tid] = 0; } return round($stripped, 1); } /** - * 选定渠道时:名称含「二中心」的展示部门进线数置 0(与同渠道向业绩/成交单一致)。 + * 将 map 中落在二中心子树(DeptLogic)的部门 key 清零,用于进线分摊或渠道清零后的展示列。 * - * @param array $leads + * @param array $map + * @param array $erCenterDeptSet */ - private static function excludeErCenterFromLeads(array $primaryDeptIds, array $deptNameMap, array &$leads): void + private static function zeroIntKeyedDeptMetricsInErCenterSubtree(array &$map, array $erCenterDeptSet): void { - foreach ($primaryDeptIds as $pid) { - if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { - continue; + foreach (array_keys($map) as $k) { + $id = (int) $k; + if ($id > 0 && isset($erCenterDeptSet[$id])) { + $map[$k] = 0; } - $leads[$pid] = 0; } } diff --git a/server/app/adminapi/logic/tcm/DiagnosisLogic.php b/server/app/adminapi/logic/tcm/DiagnosisLogic.php index d8f69b26..fccdd594 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisLogic.php @@ -478,7 +478,66 @@ class DiagnosisLogic extends BaseLogic } /** - * @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条) + * 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。 + * + * @return array{creator_id: int, create_time: int}|null + */ + private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array + { + if ($diagnosisId <= 0) { + return null; + } + $row = Db::name('tcm_prescription_order') + ->where('diagnosis_id', $diagnosisId) + ->whereNull('delete_time') + ->order('create_time', 'desc') + ->order('id', 'desc') + ->field(['creator_id', 'create_time']) + ->find(); + + if ($row === null || $row === []) { + return null; + } + + return [ + 'creator_id' => (int) ($row['creator_id'] ?? 0), + 'create_time' => (int) ($row['create_time'] ?? 0), + ]; + } + + /** + * 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。 + * - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底) + * - 否则若 from_assistant_id > 0:视为释放/取消前的持有人 + */ + private static function inferPreviousAssistantIdFromLatestAssignLog(int $diagnosisId): int + { + if ($diagnosisId <= 0) { + return 0; + } + $prev = Db::name('tcm_diagnosis_assign_log') + ->where('diagnosis_id', $diagnosisId) + ->order('id', 'desc') + ->field(['from_assistant_id', 'to_assistant_id']) + ->find(); + if ($prev === null || $prev === []) { + return 0; + } + $pTo = (int) ($prev['to_assistant_id'] ?? 0); + $pFrom = (int) ($prev['from_assistant_id'] ?? 0); + if ($pTo > 0) { + return $pTo; + } + if ($pFrom > 0) { + return $pFrom; + } + + return 0; + } + + /** + * @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。 + * **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。 * @param array $params * @return bool */ @@ -493,33 +552,66 @@ class DiagnosisLogic extends BaseLogic return false; } - $diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find(); - if (!$diagnosis) { - self::setError('诊单不存在'); + $now = time(); + $poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id); + $relatedPoCreatorId = 0; + $relatedPoCreateTime = 0; + if ($poSnap !== null) { + $relatedPoCreatorId = $poSnap['creator_id']; + $relatedPoCreateTime = $poSnap['create_time']; + } + if ($relatedPoCreateTime <= 0) { + $relatedPoCreateTime = $now; + $relatedPoCreatorId = 0; + } + + Db::startTrans(); + try { + // 事务内 FOR UPDATE 直读库:确保「原医助」与 tcm_diagnosis.assistant_id 一致,避免模型缓存或并发覆盖 + $diagLock = Db::name('tcm_diagnosis') + ->where('id', $id) + ->whereNull('delete_time') + ->lock(true) + ->field(['id', 'assistant_id']) + ->find(); + if ($diagLock === null || $diagLock === []) { + Db::rollback(); + self::setError('诊单不存在'); + + return false; + } + $fromAssistantId = (int) ($diagLock['assistant_id'] ?? 0); + if ($fromAssistantId <= 0 && $toAssistantId > 0) { + $fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id); + } + + Db::name('tcm_diagnosis') + ->where('id', $id) + ->whereNull('delete_time') + ->update(['assistant_id' => $toAssistantId]); + + $req = request(); + $admin = $req->adminInfo ?? []; + // Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列 + Db::name('tcm_diagnosis_assign_log')->insert([ + 'diagnosis_id' => $id, + 'from_assistant_id' => $fromAssistantId, + 'to_assistant_id' => $toAssistantId, + 'operator_admin_id' => (int) ($admin['admin_id'] ?? 0), + 'operator_name' => (string) ($admin['name'] ?? ''), + 'operator_account' => (string) ($admin['account'] ?? ''), + 'ip' => (string) ($req->ip() ?? ''), + 'related_po_creator_id' => $relatedPoCreatorId, + 'related_po_create_time' => $relatedPoCreateTime, + 'create_time' => $now, + ]); + } catch (\Throwable $e) { + Db::rollback(); + self::setError($e->getMessage()); return false; } - $fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0); - - Db::startTrans(); - Diagnosis::where('id', $id)->whereNull('delete_time')->update([ - 'assistant_id' => $toAssistantId, - ]); - - $req = request(); - $admin = $req->adminInfo ?? []; - DiagnosisAssignLog::create([ - 'diagnosis_id' => $id, - 'from_assistant_id' => $fromAssistantId, - 'to_assistant_id' => $toAssistantId, - 'operator_admin_id' => (int) ($admin['admin_id'] ?? 0), - 'operator_name' => (string) ($admin['name'] ?? ''), - 'operator_account' => (string) ($admin['account'] ?? ''), - 'ip' => (string) ($req->ip() ?? ''), - 'create_time' => time(), - ]); - Db::commit(); return true;