This commit is contained in:
Your Name
2026-05-05 18:25:14 +08:00
parent cd51f55701
commit de78f3b218
11 changed files with 559 additions and 97 deletions
+14
View File
@@ -54,6 +54,20 @@ export function yejiStatsUnassignedBreakdown(params: {
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
}
/** 部门行「进线数据」逐条明细(与看板同口径) */
export function yejiStatsLeadLines(params: {
start_date: string
end_date: string
dept_id: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/leadLines', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
@@ -1912,6 +1912,12 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 处方签签 -->
<el-tabs v-model="prescriptionTabType" class="mx-4 mt-2" style="margin-bottom: 0px; z-index: 1; position: relative;" v-if="prescriptionViewData">
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条(不进入打印/导出范围) -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -1962,7 +1968,7 @@
<!-- A4 处方纸(药房联) -->
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
<div class="rx-title">处方单</div>
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : '处方单' }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
@@ -2020,7 +2026,7 @@
</div>
<div class="rx-rp">
<div class="rx-watermark">药房联</div>
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
@@ -2036,8 +2042,10 @@
</div>
<div class="rx-herbs">
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }}克)</span>
<span class="rx-herb-name" v-else>{{ h.name }}</span>
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}克</span>
<span class="rx-herb-total" v-else>{{ h.dosage }}克</span>
</div>
</div>
</div>
@@ -4408,6 +4416,7 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
const prescriptionTabType = ref('internal')
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -4674,6 +4683,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
+165 -1
View File
@@ -603,7 +603,13 @@
@click="onYejiDeptRowClick(tb, r)"
>
<td class="col-dept">{{ r.dept_name }}</td>
<td>{{ formatInt(r.lead_count) }}</td>
<td @click="onLeadCountCellClick($event, tb, r)">
<span
v-if="r.dept_id > 0 && Number(r.lead_count) > 0"
class="yeji-lead-cell--link"
>{{ formatInt(r.lead_count) }}</span>
<template v-else>{{ formatInt(r.lead_count) }}</template>
</td>
<td>{{ formatMoney(r.performance_amount) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatMoney(r.completed_performance_amount) }}</td>
<td>{{ formatInt(r.consult_count) }}</td>
@@ -858,6 +864,58 @@
/>
</div>
</el-dialog>
<el-dialog
v-model="leadLinesDialogVisible"
width="min(920px, 96vw)"
destroy-on-close
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
</div>
</template>
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
<div v-loading="leadLinesLoading" class="yeji-unassigned-dialog__body">
<el-table
v-if="leadLinesRows.length > 0 || leadLinesLoading"
:data="leadLinesRows"
size="small"
stripe
border
max-height="440"
class="yeji-unassigned-dialog__table"
:empty-text="leadLinesLoading ? '加载中…' : '暂无数据'"
>
<el-table-column prop="event_time_text" label="进线时间" min-width="156" show-overflow-tooltip />
<el-table-column prop="reception_admin_name" label="接待" width="88" show-overflow-tooltip />
<el-table-column prop="external_contact_name" label="客户" min-width="100" show-overflow-tooltip />
<el-table-column prop="external_userid" label="外部联系人ID" min-width="140" show-overflow-tooltip />
<el-table-column prop="user_id" label="企微成员ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="state" label="渠道参数" width="96" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!leadLinesLoading && leadLinesRows.length === 0"
description="暂无进线明细"
/>
<div v-if="leadLinesCount > 0" class="yeji-leadlines-dialog__pager">
<el-pagination
:current-page="leadLinesPage"
:page-size="leadLinesPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:total="leadLinesCount"
:page-sizes="[10, 20, 50, 100]"
:disabled="leadLinesLoading"
:hide-on-single-page="false"
@current-change="onLeadLinesPageChange"
@size-change="onLeadLinesPageSizeChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -874,6 +932,7 @@ import {
yejiStatsMulti,
yejiStatsOverview,
yejiStatsUnassignedBreakdown,
yejiStatsLeadLines,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
@@ -962,6 +1021,17 @@ interface YejiUnassignedBreakdownRow {
amount: number
}
interface YejiLeadLineRow {
id: number
event_time: number
event_time_text: string
external_userid: string
user_id: string
state: string
reception_admin_name: string
external_contact_name: string
}
interface YejiRow {
dept_id: number
dept_name: string
@@ -1045,6 +1115,16 @@ const unassignedDialogSubtitle = ref('')
const unassignedDialogNote = ref('')
const unassignedDialogRows = ref<YejiUnassignedBreakdownRow[]>([])
const leadLinesDialogVisible = ref(false)
const leadLinesLoading = ref(false)
const leadLinesSubtitle = ref('')
const leadLinesApiNote = ref('')
const leadLinesRows = ref<YejiLeadLineRow[]>([])
const leadLinesCount = ref(0)
const leadLinesPage = ref(1)
const leadLinesPageSize = ref(20)
const leadLinesContext = ref<{ tb: YejiTable; row: YejiRow } | null>(null)
/** 图表 / 数据表切换 */
const yejiDisplayTab = ref<'charts' | 'table'>('table')
@@ -2039,6 +2119,72 @@ async function openUnassignedBreakdown(tb: YejiTable) {
}
}
function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
if (row.dept_id <= 0 || Number(row.lead_count) <= 0) {
return
}
ev.stopPropagation()
void openLeadLinesDialog(tb, row)
}
async function fetchLeadLinesPage() {
const ctx = leadLinesContext.value
if (!ctx) {
return
}
const { tb, row } = ctx
leadLinesLoading.value = true
try {
const p: Record<string, string | number> = {
start_date: tb.start_date,
end_date: tb.end_date,
dept_id: row.dept_id,
page: leadLinesPage.value,
page_size: leadLinesPageSize.value,
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
const res: any = await yejiStatsLeadLines(p as any)
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
leadLinesCount.value = Number(res?.count ?? 0)
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载进线明细失败')
leadLinesRows.value = []
leadLinesCount.value = 0
leadLinesApiNote.value = ''
} finally {
leadLinesLoading.value = false
}
}
async function openLeadLinesDialog(tb: YejiTable, row: YejiRow) {
leadLinesContext.value = { tb, row }
leadLinesSubtitle.value = `${row.dept_name} · ${buildUnassignedBreakdownSubtitle(tb)}`
leadLinesApiNote.value = ''
leadLinesRows.value = []
leadLinesCount.value = 0
leadLinesPage.value = 1
leadLinesPageSize.value = 20
leadLinesDialogVisible.value = true
await fetchLeadLinesPage()
}
function onLeadLinesPageChange(p: number) {
leadLinesPage.value = p
void fetchLeadLinesPage()
}
function onLeadLinesPageSizeChange(size: number) {
leadLinesPageSize.value = size
leadLinesPage.value = 1
void fetchLeadLinesPage()
}
/**
* 部门业绩行:有子部门 → 仅筛选该节点(后端展开子行);末级 → 侧栏业务订单;「未归属中心」→ 按创建人拆解弹窗
*/
@@ -3379,6 +3525,24 @@ onMounted(async () => {
min-height: 120px;
}
.yeji-lead-cell--link {
color: var(--yj-brand, #2563eb);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
tbody tr.yeji-row--action:hover .yeji-lead-cell--link {
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
}
.yeji-leadlines-dialog__pager {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
@media (prefers-reduced-motion: reduce) {
.date-pill,
.filter-btn,
File diff suppressed because one or more lines are too long
@@ -10,11 +10,11 @@ use app\adminapi\logic\stats\YejiStatsLogic;
/**
* 业绩看板(部门 × 时间区间)控制器
*
* - GET stats.yeji-stats/overview 单区间
* - GET stats.yeji-stats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
* - GET stats.yeji-stats/dept-options 部门下拉
* - GET stats.yeji-stats/leaderboard 医助排行榜(按展示部门分表)
* - GET stats.yeji-stats/unassignedBreakdown 「未归属中心」合计业绩按订单创建人拆解
* - GET stats.yejiStats/overview 单区间
* - GET stats.yejiStats/multi 多区间一次返回(默认 月/周/今日/昨日 四张表)
* - GET stats.yejiStats/deptOptions 部门下拉
* - GET stats.yejiStats/leaderboard 医助排行榜(按展示部门分表)
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
*/
class YejiStatsController extends BaseAdminController
{
@@ -112,4 +112,13 @@ class YejiStatsController extends BaseAdminController
return $this->data(YejiStatsLogic::unassignedCenterBreakdown($params, $this->adminId, $this->adminInfo));
}
/** 进线数据明细:与看板「进线数据」同口径 */
public function leadLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::leadLineList($params, $this->adminId, $this->adminInfo));
}
}
@@ -1445,6 +1445,203 @@ class YejiStatsLogic
return $byAdmin;
}
/**
* 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。
*
* @param \think\db\BaseQuery $query
*/
private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void
{
$query->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '');
if ($tagFilterId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
}
/**
* 归属到指定展示「中心」行部门的接待成员企微 userid 列表。
*
* @param array<int, int> $adminToPrimary
*
* @return list<string>
*/
private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array
{
$adminIds = [];
foreach ($adminToPrimary as $aid => $pid) {
if ((int) $pid === $deptRowId) {
$adminIds[] = (int) $aid;
}
}
if ($adminIds === []) {
return [];
}
$wids = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->column('work_wechat_userid');
$out = [];
foreach ($wids as $w) {
$w = trim((string) $w);
if ($w !== '') {
$out[] = $w;
}
}
return array_values(array_unique($out));
}
/**
* 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list<array<string, mixed>>,
* note: string
* }
*/
public static function leadLineList(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$empty = static function (array $c, int $deptId, string $deptName, string $note) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => (string) $c['channelCode'],
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptId <= 0) {
return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。');
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'该部门不在当前展示部门筛选或数据权限范围内。'
);
}
$deptNameMap = $c['deptNameMap'];
$channelCode = (string) $c['channelCode'];
if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。'
);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$tagFilterId = (string) $c['tagFilterId'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']);
$ecTable = self::tableWithPrefix('qywx_external_contact');
if ($wxUserIds === []) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。',
];
}
$countQuery = Db::name('qywx_external_contact_event')->alias('e');
self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId);
$countQuery->whereIn('e.user_id', $wxUserIds);
$count = (int) $countQuery->count();
$listQuery = Db::name('qywx_external_contact_event')->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL');
self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId);
$listQuery->whereIn('e.user_id', $wxUserIds);
$rawList = $listQuery
->field(
'e.id,e.event_time,e.external_userid,e.user_id,e.state,'
. 'a.name AS reception_admin_name,ec.name AS external_contact_name'
)
->order('e.event_time', 'desc')
->order('e.id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$lists = [];
foreach ($rawList as $rw) {
$ts = (int) ($rw['event_time'] ?? 0);
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'event_time' => $ts,
'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '',
'external_userid' => (string) ($rw['external_userid'] ?? ''),
'user_id' => (string) ($rw['user_id'] ?? ''),
'state' => (string) ($rw['state'] ?? ''),
'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''),
'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''),
];
}
$note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;'
. ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。');
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
*
+5 -4
View File
@@ -15,7 +15,7 @@ use think\console\Output;
* 使用方法:
* php think express:auto-update
*
* 配置crontab(每10分钟执行一次):
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
*/
class ExpressAutoUpdate extends Command
@@ -34,6 +34,7 @@ class ExpressAutoUpdate extends Command
try {
$result = ExpressTrackingService::autoUpdateBatch(200);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200);
$duration = round(microtime(true) - $startTime, 2);
@@ -41,9 +42,9 @@ class ExpressAutoUpdate extends Command
$output->writeln("总数: {$result['total']}");
$output->writeln("成功: {$result['success']}");
$output->writeln("失败: {$result['failed']}");
$output->writeln("医助已移除: " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助保留(有指派记录): " . (int) ($result['assistant_skipped_assign_log'] ?? 0));
$lines = $result['assistant_lines'] ?? [];
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助已移除(履约已发货/签收核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细: (无)');
} else {
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\command;
use app\common\service\ExpressTrackingService;
use app\common\service\gancao\GancaoLogisticsRouteService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\console\Command;
@@ -60,6 +61,10 @@ class GancaoSyncLogisticsRoute extends Command
try {
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200);
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
$stats['reconcile_lines'] = $recon['lines'] ?? [];
} catch (\Throwable $e) {
$output->error('同步异常:' . $e->getMessage());
return 1;
@@ -94,9 +99,9 @@ class GancaoSyncLogisticsRoute extends Command
$output->writeln('总数:' . $stats['total']);
$output->writeln('成功:' . $stats['success']);
$output->writeln('失败:' . $stats['failed']);
$output->writeln('医助已移除:' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助保留(有指派记录)' . (int) ($stats['assistant_skipped_assign_log'] ?? 0));
$lines = $stats['assistant_lines'] ?? [];
$output->writeln('医助已移除(甘草同步+指派日志)' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助已移除(履约已发货/签收核对)' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细:(无)');
} else {
@@ -12,6 +12,7 @@ use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use think\facade\Db;
use think\facade\Log;
/**
@@ -176,7 +177,7 @@ class ExpressTrackingService
self::logStateChange($tracking, $oldState);
}
// 定时拉轨迹:业务订单已发货时释放诊单医助(有正式指派记录则跳过,与结案逻辑一致)
// 定时拉轨迹:履约已发货/已签收时与甘草侧共用 applyAssistantReleaseForShippedPrescriptionOrder,释放诊单医助并写指派日志
$assistantSync = null;
if ($isAuto) {
$assistantSync = self::maybeClearDiagnosisAssistantWhenShippedPrescription($tracking);
@@ -242,20 +243,19 @@ class ExpressTrackingService
}
/**
* 业务订单已发货(5)/已签收(6)清空关联诊单医助;存在 tcm_diagnosis_assign_log 则跳过
* 供 express:auto-update、gancao:sync-logistics、甘草回调等共用
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log。
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则
* 例外:指派日志中若存在「从某医助改派到另一医助」(原、新医助 ID 均大于 0)的记录,视为人工指派链,自动释放跳过。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
* action: 'cleared'|'skipped_assign_log',
* action: 'cleared',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
*/
@@ -284,52 +284,73 @@ class ExpressTrackingService
if (!$diag) {
return null;
}
$oldAssistant = (string) ($diag->getAttr('assistant_id') ?? '');
if ($oldAssistant === '' || $oldAssistant === '0') {
$fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0);
if ($fromAssistantId <= 0) {
return null;
}
$assignLogCount = (int) DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->count();
if ($assignLogCount > 0) {
return [
'action' => 'skipped_assign_log',
'source' => $source,
'diagnosis_id' => $diagnosisId,
if (self::diagnosisHasFormalAssistantReassignInAssignLog($diagnosisId)) {
Log::info('Skipped auto assistant release (shipped/signed): formal assistant reassign in assign log', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'assistant_id' => $oldAssistant,
'assign_log_count' => $assignLogCount,
'fulfillment_status' => $fs,
];
'source' => $source,
'current_assistant_id' => $fromAssistantId,
]);
return null;
}
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => '']);
$operatorName = match ($source) {
'express_auto_update' => '系统·物流自动同步',
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
'gancao_callback' => '系统·甘草回调',
'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
default => '系统·订单已发货/签收',
};
Log::info('Cleared diagnosis assistant (shipped/signed prescription order)', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
Db::startTrans();
try {
Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => 0]);
DiagnosisAssignLog::create([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => 0,
'operator_admin_id' => 0,
'operator_name' => $operatorName,
'operator_account' => '',
'ip' => '',
'create_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
Log::info('Cleared diagnosis assistant (prescription shipped/signed), assign log written', [
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $oldAssistant,
'tracking_id' => $trackingId,
'fulfillment_status' => $fs,
'former_assistant_id' => $fromAssistantId,
]);
return [
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'action' => 'cleared',
'source' => $source,
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => $oldAssistant,
'fulfillment_status' => $fs,
'tracking_id' => $trackingId,
'tracking_number' => $tn,
'former_assistant_id' => (string) $fromAssistantId,
'fulfillment_status' => $fs,
];
} catch (\Throwable $e) {
Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [
'source' => $source,
'source' => $source,
'order_id' => $orderId,
]);
@@ -338,19 +359,20 @@ class ExpressTrackingService
}
/**
* @return array{
* action: 'cleared'|'skipped_assign_log',
* source?: string,
* diagnosis_id: int,
* prescription_order_id: int,
* tracking_id: int,
* tracking_number: string,
* former_assistant_id?: string,
* assistant_id?: string,
* assign_log_count?: int,
* fulfillment_status?: int
* }|null
* 是否存在「由原医助改派到新医助」的指派记录(两端 admin 均大于 0)。有此记录则已发货自动释放医助不执行。
*/
private static function diagnosisHasFormalAssistantReassignInAssignLog(int $diagnosisId): bool
{
if ($diagnosisId <= 0) {
return false;
}
return DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->where('from_assistant_id', '>', 0)
->where('to_assistant_id', '>', 0)
->count() > 0;
}
private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array
{
if ((string) $tracking->order_type !== 'prescription') {
@@ -368,14 +390,14 @@ class ExpressTrackingService
}
return self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_id' => (int) $tracking->id,
'tracking_id' => (int) $tracking->id,
'tracking_number' => (string) $tracking->tracking_number,
'source' => 'express_auto_update',
'source' => 'express_auto_update',
]);
} catch (\Throwable $e) {
Log::warning('maybeClearDiagnosisAssistantWhenShippedPrescription failed: ' . $e->getMessage(), [
'tracking_id' => (int) $tracking->id,
'order_id' => $orderId,
'order_id' => $orderId,
]);
return null;
@@ -528,6 +550,54 @@ class ExpressTrackingService
$log->save();
}
/**
* 按处方订单履约核对:已发货(5)/已签收(6) 时诊单仍挂在医助的,一律释放并写指派日志。
* 与快递 100 / 甘草来自哪个物流渠道无关,保障「已发货」后患者侧医助被清掉。
*
* @return array{scanned: int, cleared: int, lines: list<string>}
*/
public static function reconcileAssistantReleaseForShippedPrescriptionOrders(int $limit = 200): array
{
$dTable = (new Diagnosis())->getTable();
$orders = PrescriptionOrder::alias('po')
->join($dTable . ' d', 'd.id = po.diagnosis_id')
->whereNull('po.delete_time')
->whereNull('d.delete_time')
->whereIn('po.fulfillment_status', [5, 6])
->where('po.diagnosis_id', '>', 0)
->where('d.assistant_id', '>', 0)
->order('po.id', 'asc')
->limit($limit)
->field('po.*')
->select();
$cleared = 0;
$lines = [];
foreach ($orders as $order) {
$sync = self::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'source' => 'shipped_fulfillment_reconcile',
'tracking_number' => (string) ($order->tracking_number ?? ''),
]);
if (is_array($sync) && ($sync['action'] ?? '') === 'cleared') {
$cleared++;
$lines[] = sprintf(
'[履约核对·已发货/签收] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
return [
'scanned' => count($orders),
'cleared' => $cleared,
'lines' => $lines,
];
}
/**
* 批量自动更新(定时任务调用)
*
@@ -539,7 +609,7 @@ class ExpressTrackingService
* assistant_cleared: int,
* assistant_skipped_assign_log: int,
* assistant_lines: list<string>
* }
* } assistant_skipped_assign_log 保留兼容恒为 0(已发货/签收一律写指派日志并清空医助)
*/
public static function autoUpdateBatch(int $limit = 50): array
{
@@ -580,7 +650,6 @@ class ExpressTrackingService
$success = 0;
$failed = 0;
$assistantCleared = 0;
$assistantSkippedAssign = 0;
$assistantLines = [];
foreach ($list as $tracking) {
@@ -593,24 +662,13 @@ class ExpressTrackingService
if ($act === 'cleared') {
$assistantCleared++;
$assistantLines[] = sprintf(
'[移除医助] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
'[移除医助+指派日志] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$assistantSkippedAssign++;
$assistantLines[] = sprintf(
'[保留医助] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} catch (\Throwable $e) {
@@ -628,7 +686,7 @@ class ExpressTrackingService
'success' => $success,
'failed' => $failed,
'assistant_cleared' => $assistantCleared,
'assistant_skipped_assign_log' => $assistantSkippedAssign,
'assistant_skipped_assign_log' => 0,
'assistant_lines' => $assistantLines,
];
}
@@ -484,24 +484,13 @@ final class GancaoLogisticsRouteService
if ($act === 'cleared') {
$stats['assistant_cleared']++;
$stats['assistant_lines'][] = sprintf(
'[移除医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['former_assistant_id'] ?? ''),
(int) ($sync['fulfillment_status'] ?? 0)
);
} elseif ($act === 'skipped_assign_log') {
$stats['assistant_skipped_assign_log']++;
$stats['assistant_lines'][] = sprintf(
'[保留医助][甘草路由] 诊单=%d 业务订单=%d 运单=%s 医助ID=%s 指派记录=%d条 履约状态=%d',
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
(string) ($sync['assistant_id'] ?? ''),
(int) ($sync['assign_log_count'] ?? 0),
(int) ($sync['fulfillment_status'] ?? 0)
);
}
}
} else {
@@ -0,0 +1,15 @@
-- 业绩看板「进线数据」明细接口(fans/yeji · pid=293
-- 幂等:按 perms 判重
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
293, 'A', '业绩-进线明细', '', 10,
'stats.yejiStats/leadLines', '', '', '', '',
1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `zyt_system_menu` p WHERE p.id = 293)
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu` m
WHERE m.perms = 'stats.yejiStats/leadLines'
);