;rgb:0000/0000/0000

This commit is contained in:
gr
2026-09-09 10:02:43 +08:00
640 changed files with 51641 additions and 20099 deletions
@@ -15,6 +15,7 @@
namespace app\adminapi\controller;
use app\common\cache\AdminAuthCache;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
{
$type = trim((string)$this->request->post('type', 'video'));
try {
$result = DirectUploadService::issueCredentials($type);
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::issueCredentials(
$type,
$this->adminId,
trim((string)$this->request->post('name', ''))
);
return $this->success('ok', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
public function ossConfirm()
{
try {
$type = trim((string)$this->request->post('type', 'video'));
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'type' => $type,
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'size' => (int)$this->request->post('size', 0),
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
}
}
/**
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
* @throws Exception
*/
private function assertDirectUploadPermission(string $type): void
{
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
return;
}
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
$permissions = array_map('strtolower', $permissions);
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
throw new Exception('权限不足,无法上传医生工作站安装包');
}
}
}
@@ -5,13 +5,48 @@ declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionOperatorAccess;
class WecomPromotionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public function tagOptions()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
}
public function createTag()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 创建标签');
}
$name = $this->request->post('name', '');
if (!is_string($name)) {
return $this->fail('标签名称格式不正确');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
}
public function uploadWelcomeMedia()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
$this->request->file('file'),
(string) $this->request->post('type', ''),
$this->adminId
)));
}
public function overview()
{
@@ -31,8 +66,25 @@ class WecomPromotionController extends BaseAdminController
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$params = $this->request->post();
if ((int) ($params['id'] ?? 0) <= 0 && !$this->hasBasePagePermission()) {
return $this->fail('共享操作人只能编辑已授权方案,不能新建分流方案');
}
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
$params,
$this->adminId,
$this->adminInfo
)));
}
public function batchUpdatePools()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools(
$this->request->post(),
$this->adminId,
$this->adminInfo
@@ -52,9 +104,22 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function batchSetOperators()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('方案操作人已批量更新', WecomPromotionLogic::batchSetOperators(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function deletePool()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -108,7 +173,7 @@ class WecomPromotionController extends BaseAdminController
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
@@ -117,7 +182,7 @@ class WecomPromotionController extends BaseAdminController
public function syncRemoteLinks()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$poolId = (int) $this->request->post('pool_id', 0);
@@ -145,7 +210,7 @@ class WecomPromotionController extends BaseAdminController
public function deleteRemoteLink()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -200,7 +265,7 @@ class WecomPromotionController extends BaseAdminController
public function deleteLink()
{
if (!$this->hasPagePermission()) {
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
@@ -223,10 +288,11 @@ class WecomPromotionController extends BaseAdminController
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return QywxPromotionOperatorAccess::hasPagePermission($this->adminId, $this->adminInfo);
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
private function hasBasePagePermission(): bool
{
return QywxPromotionOperatorAccess::hasBasePagePermission($this->adminId, $this->adminInfo);
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\controller\order;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\order\OrderLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\order\OrderActionLogLogic;
use app\adminapi\logic\order\OrderLogic;
use app\adminapi\validate\order\OrderValidate;
@@ -17,6 +18,8 @@ use app\adminapi\validate\order\OrderValidate;
*/
class OrderController extends BaseAdminController
{
private const EDIT_TIME_PERMISSION = 'order.order/editTime';
/**
* @notes 订单列表
* @return \think\response\Json
@@ -341,13 +344,21 @@ class OrderController extends BaseAdminController
}
/**
* @notes 编辑订单(关联患者、订单类型)
* @notes 编辑订单(关联患者、订单类型、支付时间、创建时间
* 权限:超管或指定角色组可修改任意订单;其他用户只能修改自己创建的订单
* @return \think\response\Json
*/
public function edit()
{
$params = (new OrderValidate())->post()->goCheck('edit');
$hasTimeParams = array_key_exists('payment_time', $params)
|| array_key_exists('create_time', $params);
if ($hasTimeParams) {
if (!$this->canEditOrderTime()) {
return $this->fail('无权限修改订单支付时间或创建时间');
}
$params = (new OrderValidate())->post()->goCheck('edit_time');
}
$orderId = (int)$params['id'];
$order = \app\common\model\Order::find($orderId);
if (!$order) {
@@ -357,11 +368,15 @@ class OrderController extends BaseAdminController
return $this->fail('无权限修改此订单');
}
$result = OrderLogic::edit($orderId, $params);
$result = OrderLogic::edit($orderId, $params, $hasTimeParams);
if (!$result) {
return $this->fail(OrderLogic::getError());
}
$this->logOrderAction($orderId, 'edit', '编辑患者/订单类型等');
$this->logOrderAction(
$orderId,
'edit',
$hasTimeParams ? '编辑患者/订单类型/支付时间/创建时间等' : '编辑患者/订单类型等'
);
return $this->success('编辑成功');
}
@@ -382,6 +397,18 @@ class OrderController extends BaseAdminController
return (int)$order->creator_id === $this->adminId;
}
/**
* @notes 是否拥有支付单时间修正权限
*/
private function canEditOrderTime(): bool
{
if (!empty($this->adminInfo['root']) && (int)$this->adminInfo['root'] === 1) {
return true;
}
return in_array(self::EDIT_TIME_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
/**
* @notes 支付订单
* @return \think\response\Json
@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace app\adminapi\controller\qywx;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
/**
* 企业微信客户管理控制器
*/
class CustomerController extends BaseAdminController
{
class CustomerController extends BaseAdminController
{
private const DELETE_PERMISSION = 'qywx.customer/delete';
/**
* @notes 客户列表
*/
@@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController
/**
* @notes 同步企业微信客户
*/
public function sync()
{
public function sync()
{
$result = CustomerLogic::triggerBackgroundSync();
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
$msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步';
return $this->success($msg, $result);
}
return $this->success($msg, $result);
}
/**
* @notes 删除一条本地企业微信客户同步记录
*/
public function delete()
{
// 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。
if (!$this->canDeleteCustomer()) {
return $this->fail('权限不足,无法删除企业微信客户');
}
$params = (new CustomerValidate())->post()->goCheck('delete');
if (!CustomerLogic::deleteCustomer((int) $params['id'])) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('删除成功');
}
/**
* @notes 获取统计信息
@@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController
/**
* @notes 保存同步设置
*/
public function saveSyncSettings()
public function saveSyncSettings()
{
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
$result = CustomerLogic::saveSyncSettings($params);
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('保存成功');
}
}
return $this->success('保存成功');
}
private function canDeleteCustomer(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -1010,13 +1010,20 @@ class DiagnosisController extends BaseAdminController
}
if ($result === null) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
'message' => DiagnosisAiLogic::getError(),
]);
} else {
$emit('done', $result);
}
} catch (\Throwable $e) {
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
'profile' => (string) ($prepared['profile'] ?? ''),
'task' => (string) ($prepared['task'] ?? ''),
'admin_id' => (int) ($prepared['admin_id'] ?? 0),
'exception_class' => get_class($e),
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('关联支付单成功', $result);
}
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
public function unlinkPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('收款关联已移除,金额已同步更新', $result);
}
/**
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
*/
@@ -16,8 +16,9 @@ declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\common\service\qywx\QywxPromotionOperatorAccess;
use app\common\{
cache\AdminAuthCache,
service\JsonService
@@ -69,10 +70,23 @@ class AuthMiddleware
$adminAuthCache = new AdminAuthCache($request->adminInfo['admin_id']);
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 获客助手的子接口多数不是独立菜单权限。整组动作统一绑定页面权限,
// 共享操作人的动态页面权限也必须先经过这一层,再由业务层校验具体方案。
if (str_starts_with($accessUri, 'firstvisit.wecompromotion/')) {
$adminUris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if ($this->isKnownWecomPromotionAction($accessUri)
&& in_array(strtolower(QywxPromotionOperatorAccess::PAGE_PERMISSION), $adminUris, true)) {
return $next($request);
}
return JsonService::fail('权限不足,无法访问或操作');
}
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
@@ -204,6 +218,32 @@ class AuthMiddleware
'tcm.prescription/audit',
], true);
}
private function isKnownWecomPromotionAction(string $accessUri): bool
{
return in_array($accessUri, [
'firstvisit.wecompromotion/tagoptions',
'firstvisit.wecompromotion/createtag',
'firstvisit.wecompromotion/uploadwelcomemedia',
'firstvisit.wecompromotion/overview',
'firstvisit.wecompromotion/savepool',
'firstvisit.wecompromotion/batchupdatepools',
'firstvisit.wecompromotion/savewidget',
'firstvisit.wecompromotion/batchsetoperators',
'firstvisit.wecompromotion/deletepool',
'firstvisit.wecompromotion/savelink',
'firstvisit.wecompromotion/savemember',
'firstvisit.wecompromotion/togglemember',
'firstvisit.wecompromotion/checkapipermission',
'firstvisit.wecompromotion/syncremotelinks',
'firstvisit.wecompromotion/remotelinkdetail',
'firstvisit.wecompromotion/deleteremotelink',
'firstvisit.wecompromotion/synccustomers',
'firstvisit.wecompromotion/customerstatistics',
'firstvisit.wecompromotion/togglelink',
'firstvisit.wecompromotion/deletelink',
], true);
}
/**
* 处方库 lists:与开方、处方库维护菜单权限互通(避免开方页「从处方库导入」403)
+210 -18
View File
@@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
}
return false;
}
private function baseQuery()
return false;
}
/**
* 按客户当前跟进关系中的添加方式筛选。
*
* follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值;
* 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。
*/
private function applyAddWayFilter($query): void
{
if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') {
return;
}
$addWay = self::normalizeAddWay($this->params['add_way']);
if ($addWay === null) {
$query->whereRaw('1=0');
return;
}
$numberPrefix = '%"add_way":' . $addWay;
$stringPrefix = '%"add_way":"' . $addWay;
$query->where(function ($q) use ($numberPrefix, $stringPrefix) {
$q->where('follow_users', 'like', $numberPrefix . ',%')
->whereOr('follow_users', 'like', $numberPrefix . '}%')
->whereOr('follow_users', 'like', $stringPrefix . '",%')
->whereOr('follow_users', 'like', $stringPrefix . '"}%');
});
}
private function baseQuery()
{
$query = QywxExternalContact::where($this->searchWhere);
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
$followAlreadyScoped = $this->applyAddTimeFilter($query);
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
$this->applyAddWayFilter($query);
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
$tagIds = $this->normalizeTagIds();
if ($tagIds !== []) {
@@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
return $query;
}
/**
* 批量读取当前页客户的加客渠道流水。
*
* 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。
* 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。
*
* @param string[] $externalUserids
* @return array<string, array<int, array<string, mixed>>>
*/
private function loadAddChannelsByExternalUserid(array $externalUserids): array
{
$ids = [];
foreach ($externalUserids as $externalUserid) {
$externalUserid = trim((string) $externalUserid);
if ($externalUserid !== '') {
$ids[$externalUserid] = true;
}
}
$ids = array_keys($ids);
if ($ids === []) {
return [];
}
$events = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('state', '<>', '')
->whereIn('external_userid', $ids)
->field(['id', 'external_userid', 'user_id', 'state', 'event_time'])
->order('event_time', 'desc')
->order('id', 'desc')
->select()
->toArray();
$poolIds = [];
foreach ($events as $event) {
$state = trim((string) ($event['state'] ?? ''));
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolIds[(int) $matches[1]] = true;
}
}
$poolNamesById = [];
if ($poolIds !== []) {
// 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。
$poolNamesById = Db::name('qywx_promotion_pool')
->whereIn('id', array_keys($poolIds))
->column('name', 'id');
}
return self::projectAddChannelEvents($events, $poolNamesById);
}
/**
* @param array<int, array<string, mixed>> $events 已按 event_time DESC, id DESC 排序
* @param array<int|string, string> $poolNamesById
* @return array<string, array<int, array<string, mixed>>>
*/
private static function projectAddChannelEvents(array $events, array $poolNamesById): array
{
$channelsByExternalUserid = [];
$seen = [];
foreach ($events as $event) {
$externalUserid = trim((string) ($event['external_userid'] ?? ''));
$state = trim((string) ($event['state'] ?? ''));
if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) {
continue;
}
$seen[$externalUserid][$state] = true;
$poolId = 0;
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolId = (int) $matches[1];
}
$poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : '';
$channelsByExternalUserid[$externalUserid][] = [
'state' => $state,
'label' => $poolName !== ''
? $poolName
: ($poolId > 0 ? '获客助手方案 #' . $poolId : $state),
'source_type' => $poolId > 0 ? 'promotion_pool' : 'state',
'pool_id' => $poolId,
'user_id' => trim((string) ($event['user_id'] ?? '')),
'event_time' => (int) ($event['event_time'] ?? 0),
];
}
return $channelsByExternalUserid;
}
/**
* 企业微信客户详情 follow_user.add_way 的可读文案。
*
* add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。
* 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。
*/
private static function addWayLabel(int $addWay): string
{
$labels = [
0 => '未知添加方式',
1 => '通过扫描二维码添加',
2 => '通过搜索手机号添加',
3 => '通过名片分享添加',
4 => '通过群聊添加',
5 => '通过手机通讯录添加',
6 => '通过微信联系人添加',
8 => '安装第三方应用时自动添加',
9 => '通过搜索邮箱添加',
10 => '通过视频号添加',
11 => '通过日程参与人添加',
12 => '通过会议参与人添加',
13 => '通过微信好友添加',
14 => '通过智慧硬件专属客服添加',
15 => '通过上门服务客服添加',
16 => '通过获客链接添加',
17 => '通过定制开发添加',
18 => '通过需求回复添加',
21 => '通过第三方售前客服添加',
22 => '通过可能的商务伙伴添加',
24 => '通过接受微信好友申请添加',
201 => '通过内部成员共享添加',
202 => '通过管理员或负责人分配添加',
];
return $labels[$addWay] ?? '其他添加方式(' . $addWay . '';
}
/**
* @param mixed $value
*/
private static function normalizeAddWay($value): ?int
{
if (is_int($value)) {
return $value >= 0 ? $value : null;
}
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) {
return null;
}
return (int) $value;
}
/**
* @notes 获取列表
*/
@@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
->toArray();
$wxUserids = [];
$externalUserids = [];
foreach ($lists as $item) {
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
if ($externalUserid !== '') {
$externalUserids[$externalUserid] = true;
}
$raw = json_decode($item['follow_users'] ?? '[]', true);
if (!is_array($raw)) {
continue;
@@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
if ($wxUserids !== []) {
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
}
$addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids));
foreach ($lists as &$item) {
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
$followUsers = is_array($followUsers) ? $followUsers : [];
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
}
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
$addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null);
if ($addWay !== null) {
$fu['add_way'] = $addWay;
$fu['add_way_label'] = self::addWayLabel($addWay);
}
}
unset($fu);
$item['follow_users'] = $followUsers;
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
@@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
$item['tags'] = is_array($tags) ? $tags : [];
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
$item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? [];
$item['add_channel_states'] = array_column($item['add_channels'], 'state');
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$allOids = array_values(array_unique($allOids));
$amountByOid = [];
if ($allOids !== []) {
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
->whereIn('status', [2, 5])->column('amount', 'id');
}
foreach ($poIds as $pid) {
$s = 0.0;
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
->join('order o', 'l.pay_order_id = o.id')
->whereIn('l.prescription_order_id', $poIds)
->whereNull('o.delete_time')
->whereIn('o.status', [2, 5])
->sum('o.amount');
return round($sum, 2);
+21 -6
View File
@@ -18,6 +18,7 @@ use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemMenu;
use app\common\model\auth\SystemRoleMenu;
use app\common\service\qywx\QywxPromotionOperatorAccess;
/**
@@ -74,11 +75,9 @@ class AuthLogic
->column('perms');
$hasAllAuth = array_diff($allAuth, $roleAuth);
if (empty($hasAllAuth)) {
return ['*'];
}
$permissions = empty($hasAllAuth) ? ['*'] : $roleAuth;
return $roleAuth;
return self::appendSharedPromotionPermission($permissions, (int) ($admin['id'] ?? 0));
}
@@ -94,12 +93,28 @@ class AuthLogic
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
$menuId = SystemRoleMenu::whereIn('role_id', $roleIds)->column('menu_id');
return SystemMenu::distinct(true)
$permissions = SystemMenu::distinct(true)
->where([
['is_disable', '=', 0],
['perms', '<>', ''],
['id', 'in', array_unique($menuId)],
])
->column('perms');
return self::appendSharedPromotionPermission($permissions, $adminId);
}
}
private static function appendSharedPromotionPermission(array $permissions, int $adminId): array
{
if (in_array('*', $permissions, true)
|| in_array(QywxPromotionOperatorAccess::PAGE_PERMISSION, $permissions, true)
|| !QywxPromotionOperatorAccess::hasSharedPagePermission($adminId)) {
return $permissions;
}
$permissions[] = QywxPromotionOperatorAccess::PAGE_PERMISSION;
return array_values(array_unique($permissions));
}
}
+47 -1
View File
@@ -20,6 +20,7 @@ use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\SystemMenu;
use app\common\model\auth\SystemRoleMenu;
use app\common\service\qywx\QywxPromotionOperatorAccess;
/**
@@ -51,6 +52,12 @@ class MenuLogic extends BaseLogic
if ($admin['root'] != 1) {
$roleMenu = SystemRoleMenu::whereIn('role_id', $admin['role_id'])->column('menu_id');
if (QywxPromotionOperatorAccess::hasSharedPagePermission((int) $adminId)) {
$roleMenu = array_values(array_unique(array_merge(
array_map('intval', $roleMenu),
self::sharedPromotionMenuIds()
)));
}
$where[] = ['id', 'in', $roleMenu];
}
@@ -62,6 +69,45 @@ class MenuLogic extends BaseLogic
}
/** @return list<int> */
private static function sharedPromotionMenuIds(): array
{
$menus = SystemMenu::where('is_disable', 0)
->field('id,pid,perms')
->select()
->toArray();
$byId = [];
$pageIds = [];
foreach ($menus as $menu) {
$id = (int) ($menu['id'] ?? 0);
if ($id <= 0) {
continue;
}
$byId[$id] = $menu;
if ((string) ($menu['perms'] ?? '') === QywxPromotionOperatorAccess::PAGE_PERMISSION) {
$pageIds[] = $id;
}
}
$result = [];
foreach ($pageIds as $pageId) {
$chain = [];
$currentId = $pageId;
while ($currentId > 0) {
if (!isset($byId[$currentId])) {
$chain = [];
break;
}
$chain[] = $currentId;
$currentId = (int) ($byId[$currentId]['pid'] ?? 0);
}
$result = array_merge($result, $chain);
}
return array_values(array_unique($result));
}
/**
* @notes 添加菜单
* @param array $params
@@ -181,4 +227,4 @@ class MenuLogic extends BaseLogic
return linear_to_tree($data, 'children');
}
}
}
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
}
unset($row);
}
return [
return self::withDeletedFansVisibility([
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
],
'rows' => $rows,
'target' => $target,
];
], $adminInfo);
}
/** @return array<string,mixed> */
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
$empty = [
$empty = self::withDeletedFansVisibility([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$context['start_date'], $context['end_date']],
'entity' => null,
];
], $adminInfo, true);
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
if (!in_array($entityType, ['dept', 'member'], true)) {
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
];
unset($result['deleted_count']);
return self::withDeletedFansVisibility($result, $adminInfo, true);
}
/**
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
*/
private static function canViewDeletedFans(array $adminInfo): bool
{
return ($adminInfo['account'] ?? null) === 'admin';
}
/**
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
*/
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
{
$canView = self::canViewDeletedFans($adminInfo);
if (!$canView) {
$fields = $detail
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
: ['deleted_fans_count', 'deleted_count'];
$result = self::removeDeletedFansFields($result, $fields);
}
if ($detail) {
$result['can_view_deleted_fans'] = $canView;
} else {
$result['meta']['can_view_deleted_fans'] = $canView;
}
return $result;
}
/** @param string[] $fields */
private static function removeDeletedFansFields(array $value, array $fields): array
{
foreach ($fields as $field) {
unset($value[$field]);
}
foreach ($value as &$item) {
if (is_array($item)) {
$item = self::removeDeletedFansFields($item, $fields);
}
}
unset($item);
return $value;
}
/**
* Resolve only the clicked entity and its authorized target range. This is
* deliberately structural: it avoids recomputing all overview metrics,
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxPromotionOperatorAccess;
use RuntimeException;
use think\facade\Db;
@@ -20,7 +21,12 @@ class WecomAcquisitionCustomerLogic
->whereNull('l.delete_time')
->where('l.remote_link_id', '<>', '')
->where('l.remote_status', 1);
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
self::applyScope(
$query,
'l',
QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo),
self::operatorPoolIds($adminId)
);
if ($localLinkId > 0) {
$query->where('l.id', $localLinkId);
}
@@ -55,8 +61,10 @@ class WecomAcquisitionCustomerLogic
{
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$base = self::customerQuery($params, $visibleIds);
$hasBasePagePermission = QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $adminInfo);
$visibleIds = QywxPromotionOperatorAccess::visibleAdminIds($adminId, $adminInfo);
$operatorPoolIds = self::operatorPoolIds($adminId);
$base = self::customerQuery($params, $visibleIds, $operatorPoolIds);
$total = (int) (clone $base)->count();
$rows = $base
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
@@ -71,7 +79,7 @@ class WecomAcquisitionCustomerLogic
}
unset($row);
$summaryQuery = self::customerQuery($params, $visibleIds);
$summaryQuery = self::customerQuery($params, $visibleIds, $operatorPoolIds);
$summaryRow = $summaryQuery->fieldRaw(
'COUNT(*) AS customer_count, '
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
@@ -81,7 +89,9 @@ class WecomAcquisitionCustomerLogic
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'scope_label' => $hasBasePagePermission
? DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo))
: '仅共享方案',
'generated_at' => date('Y-m-d H:i:s'),
],
'summary' => [
@@ -98,14 +108,14 @@ class WecomAcquisitionCustomerLogic
];
}
private static function customerQuery(array $params, ?array $visibleIds)
private static function customerQuery(array $params, ?array $visibleIds, array $operatorPoolIds)
{
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
->leftJoin('dept d', 'd.id = c.dept_id')
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
self::applyCustomerScope($query, $visibleIds);
self::applyCustomerScope($query, $visibleIds, $operatorPoolIds);
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
if ($localLinkId > 0) {
$query->where('c.promotion_link_id', $localLinkId);
@@ -131,35 +141,58 @@ class WecomAcquisitionCustomerLogic
* 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户;
* 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。
*/
private static function applyCustomerScope($query, ?array $visibleIds): void
private static function applyCustomerScope($query, ?array $visibleIds, array $operatorPoolIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
if ($visibleIds === [] && $operatorPoolIds === []) {
$query->whereRaw('1 = 0');
return;
}
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
$query->where(function ($scope) use ($ids): void {
$scope->whereIn('c.owner_admin_id', $ids)
->whereOr(function ($unmapped) use ($ids): void {
$unmapped->where('c.owner_admin_id', 0)
->whereIn('l.owner_admin_id', $ids);
});
$query->where(function ($scope) use ($ids, $operatorPoolIds): void {
if ($ids !== []) {
$scope->whereIn('c.owner_admin_id', $ids)
->whereOr(function ($unmapped) use ($ids): void {
$unmapped->where('c.owner_admin_id', 0)
->whereIn('l.owner_admin_id', $ids);
});
if ($operatorPoolIds !== []) {
$scope->whereOr('p.id', 'in', $operatorPoolIds);
}
return;
}
$scope->whereIn('p.id', $operatorPoolIds);
});
}
private static function applyScope($query, string $alias, ?array $visibleIds): void
private static function applyScope($query, string $alias, ?array $visibleIds, array $operatorPoolIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
if ($visibleIds === [] && $operatorPoolIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
$query->where(function ($scope) use ($alias, $ids, $operatorPoolIds): void {
if ($ids !== []) {
$scope->whereIn($alias . '.owner_admin_id', $ids);
if ($operatorPoolIds !== []) {
$scope->whereOr($alias . '.pool_id', 'in', $operatorPoolIds);
}
return;
}
$scope->whereIn($alias . '.pool_id', $operatorPoolIds);
});
}
/** @return list<int> */
private static function operatorPoolIds(int $adminId): array
{
return QywxPromotionOperatorAccess::activePoolIds($adminId);
}
private static function maskIdentifier(string $value): string
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -357,9 +357,10 @@ class OrderLogic
* @notes 编辑订单
* @param int $id
* @param array $params
* @param bool $canEditTime 是否已通过订单时间修改权限校验
* @return bool
*/
public static function edit(int $id, array $params): bool
public static function edit(int $id, array $params, bool $canEditTime = false): bool
{
try {
$order = Order::find($id);
@@ -368,6 +369,13 @@ class OrderLogic
return false;
}
$hasTimeParams = array_key_exists('payment_time', $params)
|| array_key_exists('create_time', $params);
if ($hasTimeParams && !$canEditTime) {
self::setError('无权限修改订单支付时间或创建时间');
return false;
}
if (isset($params['remark'])) {
$order->remark = $params['remark'];
}
@@ -379,6 +387,26 @@ class OrderLogic
if (isset($params['order_type'])) {
$order->order_type = (int)$params['order_type'];
}
// 支付时间参与营业额等统计,仅已支付/已退款订单允许修正且不可清空
if (array_key_exists('payment_time', $params)) {
$paymentTime = trim((string)$params['payment_time']);
if (!in_array((int)$order->status, [2, 4], true)) {
self::setError('仅已支付或已退款订单可修改支付时间');
return false;
}
if ($paymentTime === '') {
self::setError('已支付或已退款订单的支付时间不能为空');
return false;
}
$order->payment_time = $paymentTime;
}
if (isset($params['create_time'])) {
$createTime = (string)$params['create_time'];
$order->create_time = self::normalizeEditedCreateTime(
$order->getData('create_time'),
$createTime
);
}
$order->save();
return true;
@@ -388,6 +416,18 @@ class OrderLogic
}
}
/**
* 兼容历史库中 create_time 为 INT 时间戳的表结构
*/
private static function normalizeEditedCreateTime(mixed $storedValue, string $dateTime): int|string
{
if (is_int($storedValue) || (is_string($storedValue) && ctype_digit($storedValue))) {
return (int)strtotime($dateTime);
}
return $dateTime;
}
/**
* @notes 支付订单
* @param int $id
@@ -9,6 +9,7 @@ use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxSyncSettings;
use app\common\service\qywx\MediaChannelService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\wechat\WechatWorkService;
use think\facade\Cache;
use think\facade\Db;
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
public static function upsertSingleExternalContactFromApi(
string $externalUserId,
int $snapshotEventId = 0,
string $snapshotFollowUserId = ''
): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
$updateCount,
$skippedCount
);
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
$snapshotEventId,
$snapshotFollowUserId,
$followUsers
);
}
MediaChannelService::forgetCurrentTagCatalogCache();
}
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
*
* @param array<string, mixed> $data
*/
public static function recordExternalContactEvent(array $data): void
public static function recordExternalContactEvent(array $data): int
{
$changeType = (string) ($data['change_type'] ?? '');
if ($changeType === '') {
// 没有 ChangeType 的事件流水没有价值,直接丢弃
return;
return 0;
}
$eventTime = (int) ($data['event_time'] ?? 0);
@@ -716,9 +728,85 @@ class CustomerLogic extends BaseLogic
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ')';
Db::execute($sql, array_values($row));
return (int) Db::name('qywx_external_contact_event')
->where('change_type', $row['change_type'])
->where('user_id', $row['user_id'])
->where('external_userid', $row['external_userid'])
->where('event_time', $row['event_time'])
->value('id');
} catch (\Throwable $e) {
// 事件流水只用于统计,失败只记日志不阻塞主回调
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
return 0;
}
}
/**
* 后台手工删除一条本地同步记录。
*
* 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复
* external_userid。只有该客户已无其他有效行时才清理共享的标签关系。
*/
public static function deleteCustomer(int $id): bool
{
if ($id <= 0) {
self::$error = '客户参数错误';
return false;
}
try {
$externalUserId = Db::transaction(static function () use ($id): string {
$row = Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->find();
if (!$row) {
throw new \DomainException('客户不存在或已删除');
}
$now = time();
Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->update([
'delete_time' => $now,
'update_time' => $now,
]);
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
if ($externalUserId !== '') {
$activeRows = (int) Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->whereNull('delete_time')
->count();
if ($activeRows === 0) {
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
}
return $externalUserId;
});
if ($externalUserId !== '') {
MediaChannelService::forgetCurrentTagCatalogCache();
}
return true;
} catch (\DomainException $e) {
self::$error = $e->getMessage();
return false;
} catch (\Throwable $e) {
Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage());
self::$error = '删除失败,请稍后重试';
return false;
}
}
@@ -340,8 +340,8 @@ class ConversionLogic
return $result;
}
/**
* Return the distinct external contacts behind an add_fans_count row.
/**
* Return the add events behind an add_fans_count row.
*
* The caller must pass the already-authorized admin range and the exact
* department ids represented by the clicked tree node. This keeps the
@@ -505,7 +505,7 @@ class ConversionLogic
return $empty;
}
$pairs = self::loadFanDetailRows(
$pairs = self::loadFanDetailRows(
$startTimestamp,
$endTimestamp,
$mediaChannel,
@@ -515,15 +515,20 @@ class ConversionLogic
);
// Target membership has already been resolved before the event query,
// so sorting and pagination operate on the clicked row's small set.
$matched = $pairs;
$matched = $pairs;
usort($matched, static function (array $left, array $right): int {
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
if ($timeCompare !== 0) {
return $timeCompare;
}
return strcmp(
if ($timeCompare !== 0) {
return $timeCompare;
}
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
if ($eventCompare !== 0) {
return $eventCompare;
}
return strcmp(
(string) ($left['external_userid'] ?? ''),
(string) ($right['external_userid'] ?? '')
) ?: strcmp(
@@ -552,45 +557,45 @@ class ConversionLogic
$pageRows,
static fn (array $row): bool => !empty($row['is_deleted'])
));
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$deleteTimesByPair = [];
if ($deletedPageRows !== []) {
$deletedUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$deletedPageRows
))));
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
$deletedPageRows
))));
$deletedEvents = Db::name('qywx_external_contact_event')
->where('change_type', 'del_external_contact')
->where('event_time', '<=', $endTimestamp)
->whereIn('user_id', $deletedUserIds)
->whereIn('external_userid', $deletedExternalUserIds)
->field('user_id,external_userid,MAX(event_time) AS delete_time')
->group('user_id,external_userid')
->select()
->toArray();
foreach ($deletedEvents as $deletedEvent) {
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
. "\0"
. trim((string) ($deletedEvent['external_userid'] ?? ''));
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
}
}
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
$pageRows
)))));
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
$addTime = max(0, (int) ($row['add_time'] ?? 0));
$deleted = !empty($row['is_deleted']);
$deleteTime = $deleted
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
: 0;
if ($deleteTime < $addTime) {
$deleteTime = 0;
}
@@ -606,18 +611,18 @@ class ConversionLogic
];
}, $pageRows);
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
return [
'lists' => $lists,
'count' => $count,
'deleted_count' => $deletedCount,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
'date_range' => [$startDate, $endDate],
];
}
/**
* Department scope imposed by an actively maintained media-channel cost
* binding. null means the channel has no binding and therefore does not
* restrict business statistics.
*
@@ -1262,17 +1267,17 @@ class ConversionLogic
}
}
/**
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数)
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计)
/**
* 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径:
* - (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次
* - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
* - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除,仍计入加粉总数,
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
* 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
@@ -1285,25 +1290,21 @@ class ConversionLogic
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
$effectivePairs = array_values(array_filter(
$detailRows,
static fn (array $row): bool => empty($row['is_deleted'])
));
return self::buildFanCountRows($detailRows, $effectivePairs);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
return self::buildFanCountRows($detailRows);
}
/**
* Load the exact distinct (user_id, external_userid) pairs represented by
* add_fans_count. Both aggregate statistics and the detail endpoint consume
* this method, so channel, add-way, prior-add and deletion rules cannot
* drift apart.
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function loadFanDetailRows(
int $startTimestamp,
@@ -1335,7 +1336,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
$baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1379,23 +1380,56 @@ class ConversionLogic
}
}
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
->group('e.user_id, e.external_userid');
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$livePairPredicate =
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time <= ?'
. ' AND (surviving_del.event_time > surviving_e.event_time'
. ' OR (surviving_del.event_time = surviving_e.event_time'
. ' AND surviving_del.id > surviving_e.id))))';
$livePairBindings = [
'add_external_contact',
$startTimestamp,
$endTimestamp,
'del_external_contact',
$endTimestamp,
];
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
. ' WHERE prev_e.user_id = e.user_id'
. ' AND prev_e.external_userid = e.external_userid'
. ' AND prev_e.change_type = ?'
. ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp]
)
// 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` earlier_e'
. ' WHERE earlier_e.user_id = e.user_id'
. ' AND earlier_e.external_userid = e.external_userid'
. ' AND earlier_e.change_type = ?'
. ' AND earlier_e.event_time >= ?'
. ' AND earlier_e.event_time <= ?'
. ' AND (earlier_e.event_time < e.event_time'
. ' OR (earlier_e.event_time = e.event_time AND earlier_e.id < e.id)))',
['add_external_contact', $startTimestamp, $endTimestamp]
)
->field('e.id AS add_event_id,e.user_id,e.external_userid,e.event_time AS add_time');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
} elseif ($unboundOnly) {
@@ -1406,58 +1440,49 @@ class ConversionLogic
. ' AND bound_a.delete_time IS NULL)'
);
}
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
}
$effectivePairs = $effectiveQuery
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
}
$deletedPairs = $deletedQuery
->whereRaw(
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
. ' WHERE surviving_e.user_id = e.user_id'
. ' AND surviving_e.external_userid = e.external_userid'
. ' AND surviving_e.change_type = ?'
. ' AND surviving_e.event_time >= ?'
. ' AND surviving_e.event_time <= ?'
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
. ' WHERE surviving_del.user_id = surviving_e.user_id'
. ' AND surviving_del.external_userid = surviving_e.external_userid'
. ' AND surviving_del.change_type = ?'
. ' AND surviving_del.event_time >= surviving_e.event_time'
. ' AND surviving_del.event_time <= ?))',
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
)
->select()
->toArray();
// add_way 筛选对有效与已删粉丝使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
$effectiveQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$effectiveQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel
);
}
$effectivePairs = $effectiveQuery
->whereRaw($livePairPredicate, $livePairBindings)
->select()
->toArray();
foreach ($effectivePairs as &$effectivePair) {
$effectivePair['is_deleted'] = false;
}
unset($effectivePair);
$deletedQuery = clone $query;
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserEventChannelFilter(
$deletedQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel,
true
);
}
$deletedPairs = $deletedQuery
->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
->select()
->toArray();
foreach ($deletedPairs as &$deletedPair) {
$deletedPair['is_deleted'] = true;
}
unset($deletedPair);
// add_way 筛选对有效与已删组合使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs);
self::$requestRowsCache[$cacheKey] = $rows;
@@ -1465,97 +1490,69 @@ class ConversionLogic
}
/**
* @param array<int,array<string,mixed>> $candidatePairs
* @param array<int,array<string,mixed>> $effectivePairs
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(
array $candidatePairs,
array $effectivePairs
): array {
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId !== '' && $externalUserId !== '') {
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
}
$detailsByKey = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
if (isset($detailsByKey[$key])) {
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
$detailsByKey[$key]['add_time'] = $addTime;
}
continue;
}
$detailsByKey[$key] = [
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => $addTime,
'is_deleted' => !isset($effectiveKeys[$key]),
'delete_time' => 0,
];
}
return array_values($detailsByKey);
}
/**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
*
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @param array<int, array<string, mixed>> $effectivePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
{
$effectiveKeys = [];
foreach ($effectivePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
}
$countsByUser = [];
$seenCandidateKeys = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenCandidateKeys[$key])) {
continue;
}
$seenCandidateKeys[$key] = true;
$countsByUser[$userId] ??= [
* @param array<int,array<string,mixed>> $candidatePairs
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/
private static function buildFanDetailRows(array $candidatePairs): array
{
$detailsByPair = [];
foreach ($candidatePairs as $pair) {
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($detailsByPair[$key])) {
continue;
}
$detailsByPair[$key] = [
'add_event_id' => $eventId,
'user_id' => $userId,
'external_userid' => $externalUserId,
'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
'is_deleted' => !empty($pair['is_deleted']),
'delete_time' => 0,
];
}
return array_values($detailsByPair);
}
/**
* 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
*
* 每个不同 (user_id, external_userid) 都计入 add_fans_countis_deleted=true 的组合同时计入
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
*
* @param array<int, array<string, mixed>> $candidatePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/
private static function buildFanCountRows(array $candidatePairs): array
{
$countsByUser = [];
$seenPairs = [];
foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') {
continue;
}
$key = $userId . "\0" . $externalUserId;
if (isset($seenPairs[$key])) {
continue;
}
$seenPairs[$key] = true;
$countsByUser[$userId] ??= [
'user_id' => $userId,
'add_fans_count' => 0,
'deleted_fans_count' => 0,
];
++$countsByUser[$userId]['add_fans_count'];
if (!isset($effectiveKeys[$key])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
];
++$countsByUser[$userId]['add_fans_count'];
if (!empty($pair['is_deleted'])) {
++$countsByUser[$userId]['deleted_fans_count'];
}
}
return array_values($countsByUser);
@@ -1569,25 +1566,25 @@ class ConversionLogic
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeUncountedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $excludedKeys user_id\0external_userid */
@@ -1628,21 +1625,21 @@ class ConversionLogic
}
}
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($excludedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($excludedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
$kept[] = $pair;
}
return $kept;
@@ -18,6 +18,9 @@ use think\facade\Log;
*/
class DiagnosisAiLogic extends BaseLogic
{
/** @var string Safe machine-readable code for the current assistant request. */
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
@@ -354,6 +357,7 @@ class DiagnosisAiLogic extends BaseLogic
int $adminId,
array $adminInfo
): ?array {
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
$adminId,
@@ -433,6 +437,14 @@ class DiagnosisAiLogic extends BaseLogic
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
$deliveredDelta = false;
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
$accepted = $onDelta($delta);
if ($accepted !== false) {
$deliveredDelta = true;
}
return $accepted;
};
try {
$result = DifyChatService::streamChat(
@@ -440,7 +452,7 @@ class DiagnosisAiLogic extends BaseLogic
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$forwardDelta,
$shouldAbort,
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
@@ -452,6 +464,7 @@ class DiagnosisAiLogic extends BaseLogic
$e,
(string) ($prepared['task'] ?? '')
);
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
@@ -464,6 +477,47 @@ class DiagnosisAiLogic extends BaseLogic
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
// Some Dify-compatible gateways accept blocking chat but reject or
// incompletely terminate streaming responses. Before any delta has
// reached the doctor it is safe to make one blocking compatibility
// attempt; after a delta, retrying could duplicate clinical text.
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
if (
!$deliveredDelta
&& in_array(
$streamErrorCode,
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
true
)
) {
try {
$result = DifyChatService::chat(
$profile,
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
} catch (\Throwable $e) {
self::logAssistantFailure(
$diagnosisId,
$profile,
$adminId,
$e,
(string) ($prepared['task'] ?? '')
);
}
if (empty($result['ok'])) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
$adminId,
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
}
}
}
return self::formatAssistantResult($prepared, $result);
@@ -480,6 +534,7 @@ class DiagnosisAiLogic extends BaseLogic
// 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。
$message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试');
$errorCode = trim((string) ($result['error_code'] ?? ''));
self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode);
if ($errorCode !== '') {
$message .= '' . $errorCode . '';
}
@@ -488,6 +543,7 @@ class DiagnosisAiLogic extends BaseLogic
}
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
if ($content === '') {
self::$assistantErrorCode = 'EMPTY_RESPONSE';
self::setError('AI 助手未返回内容,请重试');
return null;
}
@@ -520,6 +576,20 @@ class DiagnosisAiLogic extends BaseLogic
return $payload;
}
/** Return a safe code for the current SSE terminal error event. */
public static function getAssistantErrorCode(): string
{
return self::normaliseAssistantErrorCode(self::$assistantErrorCode);
}
private static function normaliseAssistantErrorCode(string $code): string
{
$code = strtoupper(trim($code));
return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1
? $code
: 'AI_ASSISTANT_FAILED';
}
/** @return array<string,mixed>|null */
private static function parsePrescriptionDraft(string $content): ?array
{
@@ -621,14 +691,13 @@ class DiagnosisAiLogic extends BaseLogic
\Throwable $exception,
string $task = ''
): void {
Log::warning('diagnosis ai assistant upstream call failed', [
Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'task' => $task,
'admin_id' => $adminId,
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
]);
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
}
/**
@@ -647,15 +716,15 @@ class DiagnosisAiLogic extends BaseLogic
$errorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
$errorMessage = trim((string) ($result['error'] ?? ''));
$latencyMs = (int) ($result['latency_ms'] ?? 0);
Log::warning('diagnosis ai assistant upstream rejected', [
Log::warning('diagnosis ai assistant upstream rejected ' . json_encode([
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'task' => $task,
'admin_id' => $adminId,
'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN',
'upstream_error' => $errorMessage,
'has_upstream_error' => $errorMessage !== '',
'latency_ms' => $latencyMs,
]);
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
}
/**
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
return null;
}
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
private static function mutatePayOrderLinks(int $id, callable $mutation)
{
self::$error = '';
try {
return Db::transaction(static function () use ($id, $mutation) {
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
$result = $mutation();
if ($result === false) {
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
}
return $result;
});
} catch (\Throwable $e) {
if (self::$error === '') {
self::$error = $e->getMessage();
}
return false;
}
}
/**
* @param int[] $payOrderIds
*/
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
*/
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限移除收款关联';
return false;
}
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
$id = (int) $params['id'];
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作此订单';
return false;
}
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
self::$error = '已完成或已取消的订单不允许移除收款关联';
return false;
}
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
self::$error = '该收款记录未关联当前订单,请刷新后重试';
return false;
}
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
return false;
}
$oldPaidCents = (int) round((float) $order->paid * 100);
$removedCents = (int) round((float) $payOrder->amount * 100);
if ($removedCents < 0) {
self::$error = '收款金额异常,请先核对金额';
return false;
}
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
->where('pay_order_id', $payOrderId)->delete();
if ($deleted !== 1) {
throw new \RuntimeException('收款关联已变化,请刷新后重试');
}
$remainingIds = self::linkedPayOrderIdList($id);
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
$order->linked_pay_order_id = $remainingIds[0] ?? null;
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
$newPaidCents = $hasRefund
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
: $remainingPaidCents;
$order->paid = $newPaidCents / 100;
// 与详情的关联已付总额口径一致;订单 amount 不变。
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
$order->save();
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
), true);
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
*
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
$log->save();
} catch (\Throwable $e) {
if ($strict) {
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
}
// 非关键日志沿用历史容错行为
}
@@ -28,6 +28,8 @@ class OrderValidate extends BaseValidate
'assistant_id' => 'require|integer|gt:0',
'amounts' => 'require|array',
'order_types' => 'require|array',
'payment_time' => 'dateFormat:Y-m-d H:i:s',
'create_time' => 'require|dateFormat:Y-m-d H:i:s',
];
protected $message = [
@@ -38,11 +40,15 @@ class OrderValidate extends BaseValidate
'status.require' => '订单状态必填',
'status.in' => '订单状态不正确',
'payment_method.in' => '支付方式不正确',
'payment_time.dateFormat' => '支付时间格式不正确',
'create_time.require' => '创建时间必填',
'create_time.dateFormat' => '创建时间格式不正确',
];
protected $scene = [
'create' => ['patient_id', 'order_type', 'amount'],
'edit' => ['id'],
'edit' => ['id', 'patient_id', 'order_type', 'remark'],
'edit_time' => ['id', 'payment_time', 'create_time'],
'detail' => ['id'],
'pay' => ['payment_method'],
'cancel' => ['id'],
@@ -12,11 +12,15 @@ use app\common\validate\BaseValidate;
class CustomerValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer|gt:0',
'auto_sync' => 'require|boolean',
'interval' => 'require|integer|between:3600,86400',
];
protected $message = [
'id.require' => '请选择要删除的客户',
'id.integer' => '客户参数格式错误',
'id.gt' => '客户参数格式错误',
'auto_sync.require' => '请选择是否自动同步',
'auto_sync.boolean' => '自动同步参数格式错误',
'interval.require' => '请选择同步间隔',
@@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate
{
return $this->only(['auto_sync', 'interval']);
}
/**
* @notes 删除客户场景
*/
public function sceneDelete()
{
return $this->only(['id']);
}
}
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'linkPayOrder' => ['id', 'pay_order_id'],
'unlinkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'],
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
];
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
{
return $this->only(['id', 'pay_order_id'])
->append('id', 'require|integer|gt:0')
->append('pay_order_id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])