This commit is contained in:
Your Name
2026-04-30 09:27:42 +08:00
parent dc899e4af4
commit eb782305e6
460 changed files with 10785 additions and 928 deletions
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\finance;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\finance\DeptPerformanceTargetLogic;
use app\adminapi\validate\finance\DeptPerformanceTargetValidate;
/**
* 制定业绩:按部门、按自然月维护目标金额(元)
*
* - GET finance.dept_performance_target/monthMatrix
* - POST finance.dept_performance_target/batchSave
*/
class DeptPerformanceTargetController extends BaseAdminController
{
public function monthMatrix()
{
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
$ym = (string) $params['year_month'];
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym));
}
public function batchSave()
{
$params = (new DeptPerformanceTargetValidate())->post()->goCheck('batchSave');
$ym = (string) $params['year_month'];
$items = $params['items'] ?? [];
if (!is_array($items)) {
return $this->fail('目标数据格式错误');
}
$result = DeptPerformanceTargetLogic::batchSave(
$ym,
$items,
$this->adminId,
(string) ($this->adminInfo['name'] ?? '')
);
if ($result === false) {
return $this->fail(DeptPerformanceTargetLogic::getError());
}
return $this->success('保存成功', DeptPerformanceTargetLogic::monthMatrix($ym), 1, 1);
}
}
@@ -45,6 +45,14 @@ class CustomerController extends BaseAdminController
return $this->data($stats);
}
/**
* @notes 获取标签维度统计(按 group 分组、按客户数倒序,供前端筛选下拉 + 标签面板共用)
*/
public function tagStats()
{
return $this->data(CustomerLogic::getTagStats());
}
/**
* @notes 今日进入分布(用于页面"今日新增"卡片的迷你柱/最近进入时间/渠道 Top5)
*/
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
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 医助排行榜(按展示部门分表)
*/
class YejiStatsController extends BaseAdminController
{
public function overview()
{
// 业绩看板涉及多张大表 + 标签穿透计算,单 30s 上限不够;放宽到 120s 兜底
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::overview($params));
}
/**
* 一次返回 4 个时间区间的数据,对应前端 4 张表。
* 默认区间(不传 ranges 时):当月、当周(周一→今天)、今日、昨日。
* ranges 也可由前端自定义传入:[{label:..., start_date:..., end_date:...}]
*/
public function multi()
{
// 4 个区间叠加,30s 上限对部分线上数据量来说过紧,放宽到 120s
@set_time_limit(120);
$params = $this->request->get();
$rangesRaw = $params['ranges'] ?? null;
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
// 周一为起点(PHP date('N') 1=周一)
$weekStart = date('Y-m-d', strtotime('monday this week'));
$yesterday = date('Y-m-d', strtotime('-1 day'));
$ranges = [];
if (is_array($rangesRaw) && $rangesRaw !== []) {
foreach ($rangesRaw as $r) {
if (!is_array($r)) {
continue;
}
$ranges[] = [
'label' => (string) ($r['label'] ?? ''),
'start_date' => (string) ($r['start_date'] ?? $today),
'end_date' => (string) ($r['end_date'] ?? ($r['start_date'] ?? $today)),
];
}
}
if ($ranges === []) {
$ranges = [
['label' => '本月(' . date('n') . '月)', 'start_date' => $monthStart, 'end_date' => $today],
['label' => '本周(' . substr($weekStart, 5) . '~' . substr($today, 5) . '', 'start_date' => $weekStart, 'end_date' => $today],
['label' => '今日(' . substr($today, 5) . '', 'start_date' => $today, 'end_date' => $today],
['label' => '昨日(' . substr($yesterday, 5) . '', 'start_date' => $yesterday, 'end_date' => $yesterday],
];
}
$base = [];
if (isset($params['dept_ids'])) {
$base['dept_ids'] = $params['dept_ids'];
}
if (isset($params['tag_id'])) {
$base['tag_id'] = $params['tag_id'];
}
if (isset($params['channel_code'])) {
$base['channel_code'] = $params['channel_code'];
}
return $this->data([
'ranges' => $ranges,
'tables' => YejiStatsLogic::overviewBatch($ranges, $base),
]);
}
public function deptOptions()
{
return $this->data(YejiStatsLogic::deptOptions());
}
/** 渠道(标签)下拉,按 source_group_name 分组 */
public function channelOptions()
{
return $this->data(YejiStatsLogic::channelOptions());
}
/** 医助排行榜:与 overview 同筛选;日期由前端传入(多表模式下前端传「今日」单区间) */
public function leaderboard()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::assistantLeaderboards($params));
}
}
@@ -183,6 +183,7 @@ class PrescriptionOrderController extends BaseAdminController
(int) $params['id'],
(string) ($params['express_company'] ?? 'auto'),
(string) ($params['tracking_number'] ?? ''),
(string) ($params['ship_mode'] ?? 'gancao'),
$this->adminId,
$this->adminInfo
);
@@ -9,6 +9,7 @@ use app\adminapi\logic\qywx\CustomerLogic;
use app\common\lists\ListsSearchInterface;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use think\facade\Db;
/**
* 企业微信客户列表
@@ -26,6 +27,32 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
];
}
/**
* @return int[] 入参 tag_ids 规范化后的非空字符串数组(实际为 string[])
*/
private function normalizeTagIds(): array
{
$raw = $this->params['tag_ids'] ?? null;
if ($raw === null || $raw === '') {
return [];
}
if (is_string($raw)) {
$raw = explode(',', $raw);
}
if (!is_array($raw)) {
return [];
}
$ids = [];
foreach ($raw as $v) {
$s = trim((string) $v);
if ($s !== '') {
$ids[] = $s;
}
}
return array_values(array_unique($ids));
}
private function baseQuery()
{
$query = QywxExternalContact::where($this->searchWhere);
@@ -34,6 +61,22 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$query->whereLike('follow_users', '%' . $kw . '%');
}
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
$tagIds = $this->normalizeTagIds();
if ($tagIds !== []) {
$matchedExtIds = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->group('external_userid')
->column('external_userid');
if ($matchedExtIds === []) {
// 没人命中:直接给一个不可能成立的条件,避免下面命中所有客户
$query->whereRaw('1=0');
} else {
$query->whereIn('external_userid', $matchedExtIds);
}
}
return $query;
}
@@ -91,6 +134,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
$item['follow_admin_ids'] = is_array($followAdminIds) ? $followAdminIds : [];
// 解析标签 JSON 数组(值由 CustomerLogic::extractFollowUserTags 写入;按 tag_id 去重)
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
$item['tags'] = is_array($tags) ? $tags : [];
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
@@ -17,6 +17,100 @@ use app\common\model\tcm\PrescriptionOrder;
class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterface
{
use HasDataScopeFilter;
/**
* 识别「有业务订单且处方药材为空白/重复」的处方 ID(用于全局置顶排序)
*
* @param array<int,int|string> $candidateIds
* @return array<int,int>
*/
private function collectRiskPrescriptionIds(array $candidateIds): array
{
$candidateIds = array_values(array_unique(array_filter(array_map('intval', $candidateIds), static function (int $id): bool {
return $id > 0;
})));
if ($candidateIds === []) {
return [];
}
// 仅在「存在有效业务订单」的处方里做风险判定
$orderRxIds = PrescriptionOrder::whereIn('prescription_id', $candidateIds)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->column('prescription_id');
$orderRxIds = array_values(array_unique(array_filter(array_map('intval', $orderRxIds), static function (int $id): bool {
return $id > 0;
})));
if ($orderRxIds === []) {
return [];
}
$rows = Prescription::whereIn('id', $orderRxIds)
->whereNull('delete_time')
->field(['id', 'herbs'])
->select()
->toArray();
$riskIds = [];
foreach ($rows as $row) {
$rid = (int) ($row['id'] ?? 0);
if ($rid <= 0) {
continue;
}
if ($this->isRiskHerbs($row['herbs'] ?? null)) {
$riskIds[] = $rid;
}
}
return array_values(array_unique($riskIds));
}
/**
* 药材风险判定:空白 or 重复(按名称,忽略空格与大小写)
*/
private function isRiskHerbs($herbsRaw): bool
{
$herbs = [];
if (is_array($herbsRaw)) {
$herbs = $herbsRaw;
} elseif (is_string($herbsRaw) && $herbsRaw !== '') {
$decoded = json_decode($herbsRaw, true);
if (is_array($decoded)) {
$herbs = $decoded;
}
}
$names = [];
foreach ($herbs as $h) {
if (!is_array($h)) {
continue;
}
$name = trim((string) ($h['name'] ?? ''));
if ($name !== '') {
$names[] = $name;
}
}
// 空白药材
if ($names === []) {
return true;
}
// 重复药材
$seen = [];
foreach ($names as $name) {
$key = strtolower(preg_replace('/\s+/', '', $name) ?? '');
if ($key === '') {
continue;
}
if (isset($seen[$key])) {
return true;
}
$seen[$key] = true;
}
return false;
}
/**
* @notes 搜索条件
*/
@@ -142,8 +236,16 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
$this->applyCreatorIdsFilter($query);
$this->applyDataScopeByOwnerColumns($query, ['creator_id', 'assistant_id']);
$lists = $query
->whereNull('delete_time')
// 全局置顶:有业务订单且处方药材为空白/重复
$candidateIds = (clone $query)->whereNull('delete_time')->column('id');
$riskIds = $this->collectRiskPrescriptionIds(is_array($candidateIds) ? $candidateIds : []);
$listQuery = $query->whereNull('delete_time');
if ($riskIds !== []) {
$riskIdStr = implode(',', array_map('intval', $riskIds));
$listQuery->orderRaw("CASE WHEN id IN ({$riskIdStr}) THEN 0 ELSE 1 END ASC");
}
$lists = $listQuery
->order('id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
@@ -71,13 +71,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query);
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$diagIds = Diagnosis::where('assistant_id', $this->adminId)->whereNull('delete_time')->column('id');
$query->where(function ($q) use ($diagIds) {
$q->where('creator_id', $this->adminId);
if ($diagIds !== []) {
$q->whereOr('diagnosis_id', 'in', $diagIds);
}
});
$query->where('creator_id', $this->adminId);
}
$this->applyDataScopeForPrescriptionOrder($query);
}
@@ -145,13 +139,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return $query;
}
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
$diagIds = Diagnosis::where('assistant_id', $this->adminId)->whereNull('delete_time')->column('id');
$query->where(function ($q) use ($diagIds) {
$q->where('creator_id', $this->adminId);
if ($diagIds !== []) {
$q->whereOr('diagnosis_id', 'in', $diagIds);
}
});
$query->where('creator_id', $this->adminId);
}
$this->applyDataScopeForPrescriptionOrder($query);
@@ -319,9 +307,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'stats_order_amount' => $s['order_amount'],
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
/** 业绩:业务订单金额,剔除履约已取消(fulfillment_status=4),其余状态全部计入 */
'stats_order_amount_performance' => $s['order_amount_not_cancelled'],
'stats_linked_pay_amount' => $s['linked_pay_amount'],
'stats_linked_pay_amount_not_cancelled' => $s['linked_pay_amount_not_cancelled'],
'stats_linked_pay_amount_cancelled' => $s['linked_pay_amount_cancelled'],
/** 业绩-关联实付:剔除履约已取消订单的关联支付金额 */
'stats_linked_pay_amount_performance' => $s['linked_pay_amount_not_cancelled'],
'stats_time_start' => $s['label_start'],
'stats_time_end' => $s['label_end'],
/** 统计口径:all=支付审核白名单全量;assistant=医助仅诊单协助业绩;restricted=与列表同域 */
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\finance;
use app\adminapi\logic\dept\DeptLogic;
use app\common\logic\BaseLogic;
use app\common\model\dept\Dept;
use app\common\model\finance\DeptPerformanceTarget;
use think\facade\Db;
class DeptPerformanceTargetLogic extends BaseLogic
{
/**
* 指定月份:保留部门树层级 + 合并当月已有目标
*
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float}
*/
public static function monthMatrix(string $yearMonth): array
{
$tree = DeptLogic::getAllData();
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
->select()
->toArray();
$targets = [];
foreach ($rowsDb as $r) {
$targets[(int) ($r['dept_id'] ?? 0)] = $r;
}
$rows = self::attachTargetsToTree(is_array($tree) ? $tree : [], $targets);
$total = self::sumTargetsInTree($rows);
return [
'year_month' => $yearMonth,
'rows' => $rows,
'total_target' => round($total, 2),
];
}
/**
* @param array<int, array{dept_id?:int|float|string, target_amount?:int|float|string, remark?:string}> $items
*/
public static function batchSave(string $yearMonth, array $items, int $adminId, string $adminName): bool
{
try {
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName): void {
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$deptId = (int) ($item['dept_id'] ?? 0);
if ($deptId <= 0) {
continue;
}
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
if (!$dept) {
continue;
}
$deptName = trim((string) ($dept->name ?? ''));
$amt = round((float) ($item['target_amount'] ?? 0), 2);
$remark = mb_substr(trim((string) ($item['remark'] ?? '')), 0, 255);
if ($amt <= 0) {
DeptPerformanceTarget::where('dept_id', $deptId)
->where('year_month', $yearMonth)
->delete();
continue;
}
$row = DeptPerformanceTarget::where('dept_id', $deptId)
->where('year_month', $yearMonth)
->find();
if ($row) {
$row->dept_name = $deptName;
$row->target_amount = $amt;
$row->remark = $remark;
$row->updater_id = $adminId;
$row->updater_name = $adminName;
$row->save();
} else {
DeptPerformanceTarget::create([
'dept_id' => $deptId,
'dept_name' => $deptName,
'year_month' => $yearMonth,
'target_amount' => $amt,
'remark' => $remark,
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
]);
}
}
});
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @param array<int, array<string, mixed>> $nodes DeptLogic::getAllData 子树
* @param array<int, array<string, mixed>> $targets keyed by dept_id
*
* @return array<int, array<string, mixed>>
*/
private static function attachTargetsToTree(array $nodes, array $targets, string $prefix = ''): array
{
$out = [];
foreach ($nodes as $n) {
if (!is_array($n)) {
continue;
}
$id = (int) ($n['id'] ?? 0);
$name = trim((string) ($n['name'] ?? ''));
if ($id <= 0) {
continue;
}
$path = $prefix === '' ? $name : $prefix . ' / ' . $name;
$t = $targets[$id] ?? null;
$amt = $t ? round((float) $t['target_amount'], 2) : 0.0;
$node = [
'dept_id' => $id,
'dept_name' => $name,
'dept_path' => $path,
'target_id' => $t ? (int) $t['id'] : 0,
'target_amount' => $amt,
'remark' => $t ? (string) ($t['remark'] ?? '') : '',
];
$rawChildren = $n['children'] ?? [];
$childList = is_array($rawChildren) && $rawChildren !== []
? self::attachTargetsToTree($rawChildren, $targets, $path)
: [];
if ($childList !== []) {
$node['children'] = $childList;
}
$out[] = $node;
}
return $out;
}
/**
* @param array<int, array<string, mixed>> $nodes
*/
private static function sumTargetsInTree(array $nodes): float
{
$s = 0.0;
foreach ($nodes as $n) {
$s += (float) ($n['target_amount'] ?? 0);
$ch = $n['children'] ?? [];
if (is_array($ch) && $ch !== []) {
$s += self::sumTargetsInTree($ch);
}
}
return $s;
}
}
@@ -734,6 +734,11 @@ class CustomerLogic extends BaseLogic
'delete_time' => $now,
'update_time' => $now,
]);
// 客户已删 → 关系表硬删(无 delete_time 列;统计场景不需要保留)
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
/**
@@ -782,16 +787,23 @@ class CustomerLogic extends BaseLogic
->where('id', (int) $row['id'])
->update([
'follow_users' => json_encode([], JSON_UNESCAPED_UNICODE),
'tags' => '[]',
'delete_time' => $now,
'update_time' => $now,
]);
// 整客户已无人跟进 → 关系表清空
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
return;
}
$minCreate = self::minFollowCreatetime($kept);
$update = [
'follow_users' => json_encode($kept, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($kept),
'update_time' => $now,
];
if ($minCreate > 0) {
@@ -802,6 +814,9 @@ class CustomerLogic extends BaseLogic
Db::name('qywx_external_contact')
->where('id', (int) $row['id'])
->update($update);
// 关系表按剩余 kept follow_users 同步(自动清掉离开员工那行 + 保留其他员工的标签)
self::syncContactTagsRelation($externalUserId, $kept);
}
private static function upsertOneExternalContactBundle(
@@ -843,6 +858,7 @@ class CustomerLogic extends BaseLogic
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($followUsers),
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
'external_first_add_time' => self::minFollowCreatetime($followUsers),
'create_time' => $now,
@@ -851,11 +867,168 @@ class CustomerLogic extends BaseLogic
];
self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount);
// 同步「客户↔员工↔标签」关系表(用于检索/统计;列表筛选/聚合不必再解析 follow_users JSON
self::syncContactTagsRelation((string) $row['external_userid'], $followUsers);
if (($syncCount % 25) === 0) {
usleep(8000);
}
}
/**
* 同步「客户↔员工↔标签」关系表(zyt_qywx_external_contact_tag)。
*
* 行粒度:(external_userid, follow_user_id, tag_id) 三元组——同一标签由不同员工打则各占一行。
* 用于检索/统计/聚合:按标签筛客户、统计某标签客户数、多标签 AND/OR、按分组漏斗等。
*
* 写入策略(保留 create_time,仅在改名/分组变化时刷 tag_name/group_name/type):
* 1. 计算目标三元组集合
* 2. 删除"该 external_userid 下"不在新集合里的旧关系(含跟进人离开 / 标签移除)
* 3. 对每个新三元组 INSERT ... ON DUPLICATE KEY UPDATE 刷新冗余字段
*
* @param array<int, mixed> $followUsers /externalcontact/get 返回的 follow_user[]
* @internal 仅供 UPSERT / 回填命令复用
*/
public static function syncContactTagsRelation(string $externalUserId, array $followUsers): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
// 1. 拍平为 (follow_user_id, tag_id) → 行 的字典;保持 follow_user_id 维度区分
$rows = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$followUserId = trim((string) ($fu['userid'] ?? ''));
$tags = $fu['tags'] ?? [];
if (!is_array($tags)) {
continue;
}
foreach ($tags as $t) {
if (!is_array($t)) {
continue;
}
$tagId = trim((string) ($t['tag_id'] ?? ''));
if ($tagId === '') {
continue;
}
$key = $followUserId . '|' . $tagId;
$rows[$key] = [
'follow_user_id' => mb_substr($followUserId, 0, 64),
'tag_id' => mb_substr($tagId, 0, 64),
'tag_name' => mb_substr((string) ($t['tag_name'] ?? ''), 0, 128),
'group_name' => mb_substr((string) ($t['group_name'] ?? ''), 0, 128),
'type' => isset($t['type']) ? (int) $t['type'] : 1,
];
}
}
$now = time();
// 2. 删除该客户下不在目标集合里的旧关系
if ($rows === []) {
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
return;
}
// 用 (follow_user_id || tag_id) 拼字符串过滤;记录少时 PHP 拉出来比 SQL CONCAT 更高效
$existing = Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->field(['id', 'follow_user_id', 'tag_id'])
->select()
->toArray();
$deleteIds = [];
foreach ($existing as $ex) {
$key = (string) ($ex['follow_user_id'] ?? '') . '|' . (string) ($ex['tag_id'] ?? '');
if (!isset($rows[$key])) {
$deleteIds[] = (int) $ex['id'];
}
}
if ($deleteIds !== []) {
Db::name('qywx_external_contact_tag')
->whereIn('id', $deleteIds)
->delete();
}
// 3. UPSERT 新关系(命中唯一键时只刷冗余字段 + update_timecreate_time 保持原值)
foreach ($rows as $r) {
try {
Db::name('qywx_external_contact_tag')
->duplicate(['tag_name', 'group_name', 'type', 'update_time'])
->insert([
'external_userid' => $externalUserId,
'follow_user_id' => $r['follow_user_id'],
'tag_id' => $r['tag_id'],
'tag_name' => $r['tag_name'],
'group_name' => $r['group_name'],
'type' => $r['type'],
'create_time' => $now,
'update_time' => $now,
]);
} catch (\Throwable $e) {
Log::warning('qywx external contact tag upsert failed: ' . $e->getMessage(), [
'ext' => $externalUserId,
'follow_user_id' => $r['follow_user_id'],
'tag_id' => $r['tag_id'],
]);
}
}
}
/**
* 把所有 follow_user[].tags 合并去重(按 tag_id),返回 JSON 字符串。
*
* 一个客户可能同时被多个员工跟进,每个员工各自打的 tag 在各自的 follow_user.tags 里;
* 这里按 tag_id 唯一去重,保留 tag_name / group_name / type 完整结构,便于后台直接渲染/筛选,
* 不必再解析 follow_users 大 JSON。
*
* 来源结构示例:
* "tags":[{"group_name":"自媒体1","tag_name":"自媒体1","type":1,"tag_id":"etRr2..."}]
*
* @internal 仅供 UPSERT / 回填命令复用,不属于业务对外 API
* @param array<int, mixed> $followUsers
*/
public static function extractFollowUserTags(array $followUsers): string
{
$bag = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$tags = $fu['tags'] ?? [];
if (!is_array($tags)) {
continue;
}
foreach ($tags as $t) {
if (!is_array($t)) {
continue;
}
$tagId = trim((string) ($t['tag_id'] ?? ''));
if ($tagId === '') {
continue;
}
if (isset($bag[$tagId])) {
continue;
}
$bag[$tagId] = [
'group_name' => (string) ($t['group_name'] ?? ''),
'tag_name' => (string) ($t['tag_name'] ?? ''),
'type' => isset($t['type']) ? (int) $t['type'] : 1,
'tag_id' => $tagId,
];
}
}
return json_encode(array_values($bag), JSON_UNESCAPED_UNICODE);
}
/**
* 将企微 follow_user[].userid 与后台 admin.work_wechat_userid 对齐,得到 zyt_admin.id 列表 JSON。
*
@@ -922,6 +1095,7 @@ class CustomerLogic extends BaseLogic
'corp_full_name',
'external_profile',
'follow_users',
'tags',
'follow_admin_ids',
'external_first_add_time',
'update_time',
@@ -948,6 +1122,90 @@ class CustomerLogic extends BaseLogic
}
}
/**
* 标签维度统计:用于「按标签筛客户」下拉选项 + 「按标签看分布」面板
*
* 数据源:zyt_qywx_external_contact_tag(关系表)+ zyt_qywx_external_contact(剔除已软删客户)
* 输出按 group_name 分组、组内按客户数倒序,每条带 tag_id / tag_name / customer_count
*
* @return array{
* total_tags: int,
* total_relations: int,
* total_tagged_customers: int,
* groups: array<int, array{group_name: string, customer_count: int, tags: array<int, array{tag_id: string, tag_name: string, customer_count: int}>}>
* }
*/
public static function getTagStats(): array
{
// 关系表里 external_userid 可能指向已被软删的客户;这里 INNER JOIN 主表过滤未删除的
$rows = Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->field([
'ect.tag_id',
'ect.tag_name',
'ect.group_name',
'COUNT(DISTINCT ect.external_userid) AS customer_count',
])
->group('ect.tag_id, ect.tag_name, ect.group_name')
->order('customer_count', 'desc')
->select()
->toArray();
$groupMap = [];
$customersUnion = [];
foreach ($rows as $r) {
$g = (string) ($r['group_name'] ?? '');
if (!isset($groupMap[$g])) {
$groupMap[$g] = [
'group_name' => $g,
'customer_count' => 0,
'tags' => [],
];
}
$cnt = (int) ($r['customer_count'] ?? 0);
$groupMap[$g]['tags'][] = [
'tag_id' => (string) ($r['tag_id'] ?? ''),
'tag_name' => (string) ($r['tag_name'] ?? ''),
'customer_count' => $cnt,
];
}
// 组内已经按 customer_count desc 排好序了(外面 SQL 排序结果);
// 组本身按"组内最大 customer_count"倒序排(最热门标签所在组优先)
foreach ($groupMap as &$g) {
$g['customer_count'] = $g['tags'][0]['customer_count'] ?? 0;
}
unset($g);
$groups = array_values($groupMap);
usort($groups, static function ($a, $b) {
return $b['customer_count'] <=> $a['customer_count'];
});
// 全表 distinct 客户数(带 tag 的客户)
$taggedCustomers = (int) Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->group('ect.external_userid')
->count();
$totalRelations = (int) Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->count();
return [
'total_tags' => count($rows),
'total_relations' => $totalRelations,
'total_tagged_customers' => $taggedCustomers,
'groups' => $groups,
];
}
/**
* @notes 获取统计信息
*/
File diff suppressed because it is too large Load Diff
@@ -471,6 +471,27 @@ class PrescriptionLogic
->count() > 0;
$arr['has_prescription_order'] = $hasBizOrder ? 1 : 0;
/**
* 处方笺展示所需的"收件信息/订单号/合计"等字段,
* 来源是该处方关联的「业务处方订单」(zyt_tcm_prescription_order)。
* 取最近一条未删除且未取消的订单:
* - recipient_name / recipient_phone / shipping_address 用于「收件信息」一行
* - order_no 用于处方笺右上角「编号」
* - amount 作为「合计」金额回填(前端再做 0 兜底展示)
*/
$latestPo = PrescriptionOrder::where('prescription_id', $id)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->order('id', 'desc')
->find();
if ($latestPo) {
$arr['recipient_name'] = (string) ($latestPo->recipient_name ?? '');
$arr['recipient_phone'] = (string) ($latestPo->recipient_phone ?? '');
$arr['shipping_address'] = (string) ($latestPo->shipping_address ?? '');
$arr['order_no'] = (string) ($latestPo->order_no ?? '');
$arr['order_amount'] = round((float) ($latestPo->amount ?? 0), 2);
}
return $arr;
}
@@ -8,6 +8,7 @@ use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\order\OrderLogic;
use app\common\model\Order;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
@@ -117,12 +118,8 @@ class PrescriptionOrderLogic
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
return true;
}
if ((int) $row->creator_id === $adminId) {
return true;
}
$aid = (int) Diagnosis::where('id', (int) $row->diagnosis_id)->whereNull('delete_time')->value('assistant_id');
return $aid === $adminId;
return (int) $row->creator_id === $adminId;
}
/**
@@ -948,7 +945,7 @@ class PrescriptionOrderLogic
*
* @return array<string,mixed>|false
*/
public static function ship(int $id, string $expressCompany, string $trackingNumber, int $adminId, array $adminInfo)
public static function ship(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
{
self::$error = '';
@@ -976,6 +973,9 @@ class PrescriptionOrderLogic
return false;
}
$shipMode = self::normalizeShipMode($shipMode);
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
$order->express_company = self::normalizeExpressCompany($expressCompany);
$order->tracking_number = $trackingNumber;
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
@@ -994,9 +994,21 @@ class PrescriptionOrderLogic
}
if ($isFirstShip) {
self::writeLog((int) $order->id, $adminId, $adminInfo, 'ship', '确认发货,运单:' . $trackingNumber . '' . $expressCompany . '');
self::writeLog(
(int) $order->id,
$adminId,
$adminInfo,
'ship',
'确认发货(' . $shipModeText . '),运单:' . $trackingNumber . '' . $expressCompany . ''
);
} else {
self::writeLog((int) $order->id, $adminId, $adminInfo, 'fill_tracking', '更新发货信息,运单:' . $trackingNumber . '' . $expressCompany . '');
self::writeLog(
(int) $order->id,
$adminId,
$adminInfo,
'fill_tracking',
'更新发货信息(' . $shipModeText . '),运单:' . $trackingNumber . '' . $expressCompany . ''
);
}
$out = $order->toArray();
@@ -1006,6 +1018,16 @@ class PrescriptionOrderLogic
return $out;
}
private static function normalizeShipMode(string $shipMode): string
{
$s = strtolower(trim($shipMode));
if ($s === 'direct') {
return 'direct';
}
return 'gancao';
}
/**
* @return array<string,mixed>|false
*/
@@ -1771,7 +1793,7 @@ class PrescriptionOrderLogic
/**
* 完成订单时把关联诊单的 assistant_id 清空,让患者回到「待分配」状态
* 仅当该患者没有其它进行中的处方订单时才清空,避免误覆盖
* 仅当该患者没有其它进行中的处方订单且无指派日志时才清空,避免误覆盖
*
* @return string 追加到操作日志的描述(无变更则返回空串)
*/
@@ -1791,6 +1813,14 @@ class PrescriptionOrderLogic
return '';
}
// 存在医助指派日志时,保留当前医助分配,不自动清空
$assignLogCount = DiagnosisAssignLog::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->count();
if ($assignLogCount > 0) {
return ';检测到该患者已有医助指派记录,保留当前医助分配(assistant_id=' . $oldAssistant . '';
}
// 同一诊单下若仍有未完成/未取消的处方订单,则不清空
$pendingCount = PrescriptionOrder::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\finance;
use app\common\validate\BaseValidate;
class DeptPerformanceTargetValidate extends BaseValidate
{
protected $rule = [
'year_month' => 'require|regex:/^\d{4}-\d{2}$/',
'items' => 'require|array',
];
protected $message = [
'year_month.require' => '请选择月份',
'year_month.regex' => '月份格式须为 YYYY-MM',
'items.require' => '请提交目标数据',
'items.array' => '目标数据格式错误',
];
public function sceneMonthMatrix(): DeptPerformanceTargetValidate
{
return $this->only(['year_month']);
}
public function sceneBatchSave(): DeptPerformanceTargetValidate
{
return $this->only(['year_month', 'items']);
}
}
@@ -22,6 +22,7 @@ class PrescriptionOrderValidate extends BaseValidate
'service_package' => 'max:100',
'tracking_number' => 'max:80',
'express_company' => 'max:20',
'ship_mode' => 'in:gancao,direct',
'fee_type' => 'require|in:1,2,3,4,5,6,7',
'amount' => 'require|float',
'order_type' => 'require|in:1,2,3,4,5,6,7',
@@ -60,7 +61,7 @@ class PrescriptionOrderValidate extends BaseValidate
'auditPrescription' => ['id', 'action', 'remark'],
'auditPayment' => ['id', 'action', 'remark'],
'withdraw' => ['id'],
'ship' => ['id', 'tracking_number', 'express_company'],
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
'logs' => ['id'],
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],