更新
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\qywx;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\MsgArchiveLists;
|
||||
use app\adminapi\lists\qywx\MsgSendTaskLists;
|
||||
use app\adminapi\lists\qywx\MsgSessionLists;
|
||||
use app\adminapi\logic\qywx\MessageLogic;
|
||||
use app\adminapi\validate\qywx\MessageValidate;
|
||||
use app\common\service\wechat\QywxMsgArchiveService;
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信 员工↔客户 消息收发
|
||||
*
|
||||
* 接口一览:
|
||||
* GET qywx.message/session_list 会话列表(左侧)
|
||||
* GET qywx.message/archive_list 会话消息历史(中间)
|
||||
* POST qywx.message/mark_read 清零会话未读
|
||||
* POST qywx.message/send 创建企业群发(员工代发)任务
|
||||
* GET qywx.message/send_task_list 群发任务列表
|
||||
* GET qywx.message/send_task_detail 任务送达详情(会回调企微查询)
|
||||
* GET qywx.message/staff_list 可代发员工(admin 表 work_wechat_userid 不空者)
|
||||
* GET qywx.message/customer_of_staff 某员工已添加的客户
|
||||
* POST qywx.message/upload_to_qywx 上传媒体文件到企微,返回 media_id(供附件使用)
|
||||
* POST qywx.message/pull_archive 手动触发一次存档拉取(便于调试;cron 正常也会跑)
|
||||
* GET qywx.message/archive_status 会话存档模块状态(SDK/私钥诊断)
|
||||
*/
|
||||
class MessageController extends BaseAdminController
|
||||
{
|
||||
public function session_list()
|
||||
{
|
||||
return $this->dataLists(new MsgSessionLists());
|
||||
}
|
||||
|
||||
public function archive_list()
|
||||
{
|
||||
return $this->dataLists(new MsgArchiveLists());
|
||||
}
|
||||
|
||||
public function mark_read()
|
||||
{
|
||||
$sessionId = (int) $this->request->post('session_id', 0);
|
||||
if ($sessionId <= 0) {
|
||||
return $this->fail('session_id 无效');
|
||||
}
|
||||
MessageLogic::markSessionRead($sessionId);
|
||||
|
||||
return $this->success('ok');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$params = (new MessageValidate())->post()->goCheck('send');
|
||||
$result = MessageLogic::createSendTask($params, $this->adminId);
|
||||
if ($result === false) {
|
||||
return $this->fail(MessageLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('已提交,员工手机端确认后将发出', $result);
|
||||
}
|
||||
|
||||
public function send_task_list()
|
||||
{
|
||||
return $this->dataLists(new MsgSendTaskLists());
|
||||
}
|
||||
|
||||
public function send_task_detail()
|
||||
{
|
||||
$taskId = (int) $this->request->get('task_id', 0);
|
||||
$cursor = (string) $this->request->get('cursor', '');
|
||||
if ($taskId <= 0) {
|
||||
return $this->fail('task_id 无效');
|
||||
}
|
||||
|
||||
return $this->data(MessageLogic::querySendTaskResult($taskId, $cursor));
|
||||
}
|
||||
|
||||
public function staff_list()
|
||||
{
|
||||
$keyword = trim((string) $this->request->get('keyword', ''));
|
||||
|
||||
return $this->data(MessageLogic::staffList($keyword));
|
||||
}
|
||||
|
||||
public function customer_of_staff()
|
||||
{
|
||||
$staff = trim((string) $this->request->get('staff_userid', ''));
|
||||
$keyword = trim((string) $this->request->get('keyword', ''));
|
||||
$limit = (int) $this->request->get('limit', 200);
|
||||
|
||||
return $this->data(MessageLogic::customerOfStaff($staff, $keyword, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传素材到企业微信,返回 media_id(3 天有效)
|
||||
*
|
||||
* 入参:multipart 文件字段 file,query / form 字段 type = image|voice|video|file
|
||||
*
|
||||
* 使用场景:前端先上传本地文件到自家服务器(/upload/image 等)得到本地 URL 用于预览;
|
||||
* 确认发送时再调本接口上传到企微拿 media_id,填入 add_msg_template 的 attachments。
|
||||
*/
|
||||
public function upload_to_qywx()
|
||||
{
|
||||
$type = (string) $this->request->param('type', 'image');
|
||||
if (!in_array($type, ['image', 'voice', 'video', 'file'], true)) {
|
||||
return $this->fail('不支持的媒体类型');
|
||||
}
|
||||
$file = $this->request->file('file');
|
||||
if (!$file) {
|
||||
return $this->fail('未上传文件');
|
||||
}
|
||||
|
||||
try {
|
||||
$tempDir = runtime_path() . 'qywx_upload_tmp' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($tempDir)) {
|
||||
@mkdir($tempDir, 0755, true);
|
||||
}
|
||||
$savePath = $tempDir . uniqid('qywx_', true) . '_' . $file->getOriginalName();
|
||||
// @phpstan-ignore-next-line move returns File
|
||||
move_uploaded_file($file->getPathname(), $savePath);
|
||||
|
||||
$service = new WechatWorkService('customer_contact');
|
||||
$resp = $service->uploadMedia($type, $savePath, $file->getOriginalName());
|
||||
@unlink($savePath);
|
||||
|
||||
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
|
||||
if ($errcode !== 0 || empty($resp['media_id'])) {
|
||||
Log::warning('企微上传素材失败: ' . json_encode($resp, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $this->fail('企微上传失败: ' . ($resp['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
return $this->success('ok', [
|
||||
'media_id' => (string) $resp['media_id'],
|
||||
'type' => $resp['type'] ?? $type,
|
||||
'created_at' => $resp['created_at'] ?? time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('upload_to_qywx 异常: ' . $e->getMessage());
|
||||
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function pull_archive()
|
||||
{
|
||||
$maxBatches = (int) $this->request->param('max_batches', 5);
|
||||
$download = (bool) $this->request->param('download', false);
|
||||
|
||||
$pull = QywxMsgArchiveService::pullLoop(max(1, $maxBatches));
|
||||
$media = null;
|
||||
if ($download && $pull['enabled']) {
|
||||
$media = QywxMsgArchiveService::downloadPendingMedia(200);
|
||||
}
|
||||
|
||||
return $this->data([
|
||||
'pull' => $pull,
|
||||
'media' => $media,
|
||||
]);
|
||||
}
|
||||
|
||||
public function archive_status()
|
||||
{
|
||||
return $this->data(MessageLogic::archiveStatus());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->get()->goCheck('paidPayOrders');
|
||||
$exceptPo = (int) $this->request->get('prescription_order_id', 0);
|
||||
|
||||
$lists = OrderLogic::listPaidOrdersForDiagnosis(
|
||||
(int) $params['diagnosis_id'],
|
||||
$this->adminId,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\qywx;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxMsgArchive;
|
||||
use app\common\model\QywxMsgArchiveMedia;
|
||||
|
||||
/**
|
||||
* 单会话消息历史
|
||||
*
|
||||
* 两种查询模式,二选一:
|
||||
* 1) session_id 传 QywxMsgSession.id(推荐)
|
||||
* 2) staff_userid + external_userid(单聊)/ roomid(群聊)
|
||||
*
|
||||
* 消息默认按 send_time 升序,便于前端从下往上追加。
|
||||
* 支持 before_time / after_time 游标翻页。
|
||||
*/
|
||||
class MsgArchiveLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['roomid', 'msgtype'],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveScope(): array
|
||||
{
|
||||
$sessionId = (int) ($this->params['session_id'] ?? 0);
|
||||
if ($sessionId > 0) {
|
||||
$session = \app\common\model\QywxMsgSession::find($sessionId);
|
||||
if ($session) {
|
||||
return [
|
||||
'staff_userid' => (string) $session['staff_userid'],
|
||||
'external_userid' => (string) $session['external_userid'],
|
||||
'roomid' => (string) $session['roomid'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'staff_userid' => trim((string) ($this->params['staff_userid'] ?? '')),
|
||||
'external_userid' => trim((string) ($this->params['external_userid'] ?? '')),
|
||||
'roomid' => trim((string) ($this->params['roomid'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$scope = $this->resolveScope();
|
||||
$query = QywxMsgArchive::where($this->searchWhere);
|
||||
|
||||
if ($scope['roomid'] !== '') {
|
||||
$query->where('roomid', $scope['roomid']);
|
||||
} else {
|
||||
// 单聊:两端顺序不定,from_user/tolist 都可能为员工或客户
|
||||
$staff = $scope['staff_userid'];
|
||||
$external = $scope['external_userid'];
|
||||
if ($staff === '' && $external === '') {
|
||||
$query->where('id', 0);
|
||||
} else {
|
||||
$query->where('roomid', '');
|
||||
if ($staff !== '' && $external !== '') {
|
||||
$query->where(function ($q) use ($staff, $external) {
|
||||
$q->where(function ($sub) use ($staff, $external) {
|
||||
$sub->where('from_user', $staff)->whereLike('to_list', '%"' . $external . '"%');
|
||||
})->whereOr(function ($sub) use ($staff, $external) {
|
||||
$sub->where('from_user', $external)->whereLike('to_list', '%"' . $staff . '"%');
|
||||
});
|
||||
});
|
||||
} elseif ($staff !== '') {
|
||||
$query->where(function ($q) use ($staff) {
|
||||
$q->where('from_user', $staff)->whereOr('to_list', 'like', '%"' . $staff . '"%');
|
||||
});
|
||||
} else {
|
||||
$query->where(function ($q) use ($external) {
|
||||
$q->where('from_user', $external)->whereOr('to_list', 'like', '%"' . $external . '"%');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->params['before_time'])) {
|
||||
$query->where('send_time', '<', (int) $this->params['before_time']);
|
||||
}
|
||||
if (!empty($this->params['after_time'])) {
|
||||
$query->where('send_time', '>', (int) $this->params['after_time']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->baseQuery()
|
||||
->order('send_time', 'desc')
|
||||
->order('id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = array_reverse($rows); // 前端按时间升序渲染
|
||||
|
||||
$users = [];
|
||||
foreach ($rows as $r) {
|
||||
$users[$r['from_user']] = true;
|
||||
$toList = $r['to_list'];
|
||||
if (is_array($toList)) {
|
||||
foreach ($toList as $to) {
|
||||
$users[$to] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$users = array_keys(array_filter($users, fn ($_, $k) => $k !== '', ARRAY_FILTER_USE_BOTH));
|
||||
|
||||
$adminMap = [];
|
||||
$extMap = [];
|
||||
if ($users) {
|
||||
$adminMap = Admin::whereIn('work_wechat_userid', $users)
|
||||
->column('id,name,avatar,work_wechat_userid', 'work_wechat_userid');
|
||||
$extMap = QywxExternalContact::whereIn('external_userid', $users)
|
||||
->column('external_userid,name,avatar,type,unionid', 'external_userid');
|
||||
}
|
||||
|
||||
$msgIds = array_column($rows, 'msgid');
|
||||
$mediaMap = [];
|
||||
if ($msgIds) {
|
||||
$mediaList = QywxMsgArchiveMedia::whereIn('msgid', $msgIds)->select()->toArray();
|
||||
foreach ($mediaList as $m) {
|
||||
$mediaMap[$m['msgid']][] = $m;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
$fromId = (string) $r['from_user'];
|
||||
$r['from_is_staff'] = isset($adminMap[$fromId]);
|
||||
$r['from_profile'] = $adminMap[$fromId] ?? ($extMap[$fromId] ?? null);
|
||||
$r['media'] = $mediaMap[$r['msgid']] ?? [];
|
||||
}
|
||||
unset($r);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->baseQuery()->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\qywx;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\QywxMsgSendTask;
|
||||
|
||||
class MsgSendTaskLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['sender_userid', 'status', 'admin_id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
return QywxMsgSendTask::where($this->searchWhere)
|
||||
->order('id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return QywxMsgSendTask::where($this->searchWhere)->count();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\qywx;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxMsgSession;
|
||||
|
||||
/**
|
||||
* 企微消息会话列表
|
||||
*
|
||||
* 查询参数:
|
||||
* staff_userid - 过滤某员工的会话(支持传 admin_id 反查)
|
||||
* external_userid- 过滤某客户
|
||||
* keyword - 按客户名 / 摘要 模糊搜索
|
||||
* only_unread - 仅未读
|
||||
*/
|
||||
class MsgSessionLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['staff_userid', 'external_userid', 'roomid', 'session_type'],
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$query = QywxMsgSession::where($this->searchWhere);
|
||||
|
||||
$adminId = (int) ($this->params['admin_id'] ?? 0);
|
||||
if ($adminId > 0) {
|
||||
$wxId = Admin::where('id', $adminId)->value('work_wechat_userid');
|
||||
if ($wxId) {
|
||||
$query->where('staff_userid', $wxId);
|
||||
} else {
|
||||
// 找不到对应企微 userid 时不返回任何会话
|
||||
$query->where('id', 0);
|
||||
}
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$kw = addcslashes($keyword, '%_\\');
|
||||
$extIds = QywxExternalContact::whereLike('name', '%' . $kw . '%')->column('external_userid');
|
||||
$query->where(function ($q) use ($kw, $extIds) {
|
||||
$q->whereLike('last_msg_summary', '%' . $kw . '%');
|
||||
if ($extIds) {
|
||||
$q->whereOr('external_userid', 'in', $extIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($this->params['only_unread'])) {
|
||||
$query->where('unread_staff', '>', 0);
|
||||
}
|
||||
|
||||
return $query->order('last_msg_time', 'desc');
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->baseQuery()
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$extIds = array_values(array_unique(array_filter(array_map(
|
||||
fn ($r) => (string) ($r['external_userid'] ?? ''),
|
||||
$rows
|
||||
))));
|
||||
$extMap = [];
|
||||
if ($extIds) {
|
||||
$extMap = QywxExternalContact::whereIn('external_userid', $extIds)
|
||||
->column('name,avatar,type,gender,corp_name,unionid', 'external_userid');
|
||||
}
|
||||
|
||||
$staffIds = array_values(array_unique(array_filter(array_map(
|
||||
fn ($r) => (string) ($r['staff_userid'] ?? ''),
|
||||
$rows
|
||||
))));
|
||||
$staffMap = [];
|
||||
if ($staffIds) {
|
||||
$staffMap = Admin::whereIn('work_wechat_userid', $staffIds)
|
||||
->column('id,name,avatar', 'work_wechat_userid');
|
||||
}
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
$r['customer'] = $extMap[$r['external_userid']] ?? null;
|
||||
$r['staff'] = $staffMap[$r['staff_userid']] ?? null;
|
||||
}
|
||||
unset($r);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->baseQuery()->count();
|
||||
}
|
||||
}
|
||||
@@ -924,9 +924,47 @@ class OrderLogic
|
||||
return count(array_intersect($myRoles, $supervisorRoles)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单可关联支付单列表:仅展示创建时间在此日 0 点及之后的支付单(编辑时当前单已关联的旧单仍保留在列表中)
|
||||
*/
|
||||
private static function payOrderLinkableMinCreateTs(): int
|
||||
{
|
||||
return strtotime('2026-04-20 00:00:00');
|
||||
}
|
||||
|
||||
/**
|
||||
* zyt_order.create_time 可能为 int 时间戳或 datetime 字符串,不可直接 (int) 强转(字符串会得到 0)
|
||||
*/
|
||||
private static function orderCreateTimeToUnix(mixed $value): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->getTimestamp();
|
||||
}
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_float($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
if (is_numeric($value) && strlen($value) <= 12) {
|
||||
return (int) $value;
|
||||
}
|
||||
$ts = strtotime($value);
|
||||
|
||||
return $ts !== false ? $ts : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某诊单下可关联的支付单(待支付/已支付),供关联业务订单多选(主管/诊单医助看该诊单全部;否则仅本人创建)
|
||||
* 已占用(关联到未撤回/未取消的业务订单)的支付单会排除;编辑某业务订单时传 $exceptPrescriptionOrderId 以保留当前单已选中的项
|
||||
* 列表仅含 2026-04-20 及之后创建的支付单(已关联到当前编辑业务订单的除外)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -974,6 +1012,7 @@ class OrderLogic
|
||||
);
|
||||
}
|
||||
|
||||
$minCreateTs = self::payOrderLinkableMinCreateTs();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$oid = (int) ($row['id'] ?? 0);
|
||||
@@ -985,6 +1024,10 @@ class OrderLogic
|
||||
if ($busy && ! $allowed) {
|
||||
continue;
|
||||
}
|
||||
$ct = self::orderCreateTimeToUnix($row['create_time'] ?? null);
|
||||
if ($ct < $minCreateTs && ! $allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $row;
|
||||
}
|
||||
@@ -994,11 +1037,17 @@ class OrderLogic
|
||||
|
||||
/**
|
||||
* 校验支付单均可关联到指定诊单(已支付、归属与权限)
|
||||
* 默认仅允许关联 2026-04-20 及之后创建的支付单;编辑业务订单时传入 $prescriptionOrderIdForDateGrace,已关联到该业务订单的旧单除外
|
||||
*
|
||||
* @param int[] $ids zyt_order.id
|
||||
*/
|
||||
public static function assertPayOrdersLinkable(array $ids, int $diagnosisId, int $adminId, array $adminInfo): ?string
|
||||
{
|
||||
public static function assertPayOrdersLinkable(
|
||||
array $ids,
|
||||
int $diagnosisId,
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
?int $prescriptionOrderIdForDateGrace = null
|
||||
): ?string {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $v): bool => $v > 0)));
|
||||
if ($diagnosisId <= 0) {
|
||||
return '诊单无效';
|
||||
@@ -1021,6 +1070,7 @@ class OrderLogic
|
||||
return '部分支付单不存在或已删除';
|
||||
}
|
||||
|
||||
$minTs = self::payOrderLinkableMinCreateTs();
|
||||
foreach ($orders as $o) {
|
||||
if ((int) $o->patient_id !== $diagnosisId) {
|
||||
return '支付单与诊单不匹配';
|
||||
@@ -1031,6 +1081,18 @@ class OrderLogic
|
||||
if (! $supervisor && ! $isDiagAssistant && (int) $o->creator_id !== $adminId) {
|
||||
return '无权限关联所选支付单';
|
||||
}
|
||||
$ct = self::orderCreateTimeToUnix($o->create_time ?? null);
|
||||
if ($ct < $minTs) {
|
||||
$grace = false;
|
||||
if ($prescriptionOrderIdForDateGrace !== null && $prescriptionOrderIdForDateGrace > 0) {
|
||||
$grace = PrescriptionOrderPayOrder::where('prescription_order_id', $prescriptionOrderIdForDateGrace)
|
||||
->where('pay_order_id', (int) $o->id)
|
||||
->count() > 0;
|
||||
}
|
||||
if (! $grace) {
|
||||
return '仅可关联 2026年4月20日及之后创建的支付单';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -595,9 +595,36 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
|
||||
$service = new WechatWorkService();
|
||||
$detail = $service->getExternalContactDetail($externalUserId);
|
||||
$detail = [];
|
||||
$lastError = null;
|
||||
// 接口偶发抖动:最多重试 1 次(共 2 次请求),间隔 1.2s。仅对「非预期错误 / 空响应」重试,
|
||||
// 业务预期错误(如 84061 客户未通过、60011/60111 无权限、41068 非法 ID)不重试,避免无谓压力。
|
||||
$maxAttempts = 2;
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
$err = null;
|
||||
$detail = $service->getExternalContactDetail($externalUserId, $err);
|
||||
if (!empty($detail['external_contact']) && is_array($detail['external_contact'])) {
|
||||
break;
|
||||
}
|
||||
$lastError = $err;
|
||||
$errcode = is_array($err) ? ($err['errcode'] ?? null) : null;
|
||||
$shouldRetry = $attempt < $maxAttempts
|
||||
&& !in_array($errcode, [84061, 41068, 60011, 60111], true);
|
||||
if (!$shouldRetry) {
|
||||
break;
|
||||
}
|
||||
usleep(1_200_000);
|
||||
}
|
||||
|
||||
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
|
||||
Log::warning('qywx external contact callback: 拉取详情为空', ['external_userid' => $externalUserId]);
|
||||
$errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null;
|
||||
$errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : '';
|
||||
Log::warning(sprintf(
|
||||
'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s',
|
||||
$errcode === null ? '?' : (string) $errcode,
|
||||
$errmsg !== '' ? $errmsg : '-',
|
||||
$externalUserId
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\qywx;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxMsgSendTask;
|
||||
use app\common\model\QywxMsgSession;
|
||||
use app\common\service\wechat\WeComFinanceSdkClient;
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信消息业务逻辑
|
||||
*
|
||||
* - 员工代发消息:企业群发 add_msg_template(客户端显示为员工本人,员工手机端需确认)
|
||||
* - 消息历史:来自会话内容存档落库(见 QywxMsgArchiveService)
|
||||
* - 本类只封装"创建任务 + 查询送达 + 辅助接口",不处理拉取
|
||||
*/
|
||||
class MessageLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 创建企业群发任务
|
||||
*
|
||||
* @param array{sender_userid:string, external_userids:string[], msg_payload:array, chat_type?:string} $params
|
||||
* @param int $adminId 后台发起人
|
||||
*
|
||||
* @return array{task_id:int, msg_template_id:string, fail_list: array}|false
|
||||
*/
|
||||
public static function createSendTask(array $params, int $adminId)
|
||||
{
|
||||
$chatType = (string) ($params['chat_type'] ?? 'single');
|
||||
$sender = (string) $params['sender_userid'];
|
||||
$externalIds = array_values(array_filter(array_map('strval', $params['external_userids'] ?? [])));
|
||||
$payload = (array) $params['msg_payload'];
|
||||
|
||||
if ($externalIds === []) {
|
||||
self::setError('请至少选择 1 位客户');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = self::buildAddMsgTemplateBody($chatType, $sender, $externalIds, $payload);
|
||||
if ($body === null) {
|
||||
// setError 已在 buildAddMsgTemplateBody 内调
|
||||
return false;
|
||||
}
|
||||
|
||||
$task = QywxMsgSendTask::create([
|
||||
'admin_id' => $adminId,
|
||||
'sender_userid' => $sender,
|
||||
'external_userids' => $externalIds,
|
||||
'chat_type' => $chatType === 'group' ? 2 : 1,
|
||||
'msg_payload' => $payload,
|
||||
'status' => QywxMsgSendTask::STATUS_PENDING,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$service = new WechatWorkService('customer_contact');
|
||||
$resp = $service->addMsgTemplate($body);
|
||||
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
|
||||
$errmsg = (string) ($resp['errmsg'] ?? '');
|
||||
$msgid = (string) ($resp['msgid'] ?? '');
|
||||
$failList = isset($resp['fail_list']) && is_array($resp['fail_list']) ? $resp['fail_list'] : [];
|
||||
|
||||
if ($errcode !== 0 || $msgid === '') {
|
||||
QywxMsgSendTask::where('id', $task['id'])->update([
|
||||
'status' => QywxMsgSendTask::STATUS_FAILED,
|
||||
'error' => sprintf('errcode=%d errmsg=%s', $errcode, $errmsg),
|
||||
'fail_list' => $failList,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
self::setError(sprintf('企微群发失败: [%d] %s', $errcode, $errmsg ?: '未知错误'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
QywxMsgSendTask::where('id', $task['id'])->update([
|
||||
'status' => QywxMsgSendTask::STATUS_SUBMITTED,
|
||||
'msg_template_id' => $msgid,
|
||||
'fail_list' => $failList,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'task_id' => (int) $task['id'],
|
||||
'msg_template_id' => $msgid,
|
||||
'fail_list' => $failList,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微群发创建异常: ' . $e->getMessage());
|
||||
QywxMsgSendTask::where('id', $task['id'])->update([
|
||||
'status' => QywxMsgSendTask::STATUS_FAILED,
|
||||
'error' => mb_substr('exception: ' . $e->getMessage(), 0, 500),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询群发任务送达结果(企业微信返回每客户的确认/送达状态)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function querySendTaskResult(int $taskId, string $cursor = ''): array
|
||||
{
|
||||
$task = QywxMsgSendTask::find($taskId);
|
||||
if (!$task || $task['msg_template_id'] === '') {
|
||||
return ['detail_list' => [], 'next_cursor' => '', 'task' => $task];
|
||||
}
|
||||
$service = new WechatWorkService('customer_contact');
|
||||
$resp = $service->getGroupMsgSendResult($task['msg_template_id'], $task['sender_userid'], $cursor, 500);
|
||||
$errcode = isset($resp['errcode']) ? (int) $resp['errcode'] : -1;
|
||||
if ($errcode !== 0) {
|
||||
return [
|
||||
'detail_list' => [],
|
||||
'next_cursor' => '',
|
||||
'task' => $task,
|
||||
'errcode' => $errcode,
|
||||
'errmsg' => (string) ($resp['errmsg'] ?? ''),
|
||||
];
|
||||
}
|
||||
$detailList = $resp['detail_list'] ?? [];
|
||||
$nextCursor = (string) ($resp['next_cursor'] ?? '');
|
||||
|
||||
if (is_array($detailList) && !empty($detailList)) {
|
||||
$sentCount = 0;
|
||||
foreach ($detailList as $d) {
|
||||
if (($d['status'] ?? 0) == 1) { // 1=已送达
|
||||
$sentCount++;
|
||||
}
|
||||
}
|
||||
if ($sentCount > 0 && $task['status'] == QywxMsgSendTask::STATUS_SUBMITTED) {
|
||||
QywxMsgSendTask::where('id', $task['id'])->update([
|
||||
'status' => QywxMsgSendTask::STATUS_SENT,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'detail_list' => $detailList,
|
||||
'next_cursor' => $nextCursor,
|
||||
'task' => $task->refresh(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可代发员工列表(只返回已绑定企微 userid 的 admin)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function staffList(string $keyword = ''): array
|
||||
{
|
||||
$query = Admin::where('work_wechat_userid', '<>', '')
|
||||
->whereNotNull('work_wechat_userid');
|
||||
if ($keyword !== '') {
|
||||
$kw = addcslashes($keyword, '%_\\');
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->whereLike('name', '%' . $kw . '%')
|
||||
->whereOr('work_wechat_userid', 'like', '%' . $kw . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->field('id,name,avatar,work_wechat_userid as userid,department')
|
||||
->limit(200)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 某员工的已添加客户列表(基于 follow_users JSON 查询)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function customerOfStaff(string $staffUserid, string $keyword = '', int $limit = 200): array
|
||||
{
|
||||
if ($staffUserid === '') {
|
||||
return [];
|
||||
}
|
||||
$query = QywxExternalContact::whereLike('follow_users', '%"userid":"' . $staffUserid . '"%');
|
||||
if ($keyword !== '') {
|
||||
$kw = addcslashes($keyword, '%_\\');
|
||||
$query->whereLike('name', '%' . $kw . '%');
|
||||
}
|
||||
|
||||
return $query->field('external_userid,name,avatar,type,gender,corp_name,unionid')
|
||||
->limit($limit)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public static function markSessionRead(int $sessionId): bool
|
||||
{
|
||||
$affected = QywxMsgSession::where('id', $sessionId)->update([
|
||||
'unread_staff' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $affected >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话存档模块状态
|
||||
*/
|
||||
public static function archiveStatus(): array
|
||||
{
|
||||
$client = new WeComFinanceSdkClient();
|
||||
|
||||
return [
|
||||
'enabled' => (bool) config('pay.wechat_work.msgaudit_enabled', false),
|
||||
'available' => $client->isAvailable(),
|
||||
'error' => $client->getLastError(),
|
||||
'lib_path' => (string) config('pay.wechat_work.msgaudit_sdk_lib_path', ''),
|
||||
'public_key_ver' => (int) config('pay.wechat_work.msgaudit_public_key_ver', 0),
|
||||
'has_private_key' => self::hasPrivateKeyConfigured(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function hasPrivateKeyConfigured(): bool
|
||||
{
|
||||
$path = (string) config('pay.wechat_work.msgaudit_private_key_path', '');
|
||||
if ($path !== '' && is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ((string) config('pay.wechat_work.msgaudit_private_key', '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 add_msg_template 请求体
|
||||
*
|
||||
* msg_payload 标准形:
|
||||
* {
|
||||
* "text": { "content": "你好" },
|
||||
* "attachments": [
|
||||
* { "msgtype":"image", "image":{"media_id":"xxx"} },
|
||||
* { "msgtype":"video", "video":{"media_id":"xxx"} },
|
||||
* { "msgtype":"file", "file":{"media_id":"xxx"} },
|
||||
* { "msgtype":"link", "link":{"title":"...","picurl":"...","desc":"...","url":"https://..."} },
|
||||
* { "msgtype":"miniprogram", "miniprogram":{"title":"...","pic_media_id":"...","appid":"wx...","page":"/pages/x/x"} }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function buildAddMsgTemplateBody(
|
||||
string $chatType,
|
||||
string $sender,
|
||||
array $externalIds,
|
||||
array $payload
|
||||
): ?array {
|
||||
$text = $payload['text'] ?? null;
|
||||
$attachments = $payload['attachments'] ?? null;
|
||||
if (($text === null || !isset($text['content']) || trim((string) $text['content']) === '')
|
||||
&& empty($attachments)) {
|
||||
self::setError('消息内容不能为空:至少填写文本或添加 1 个附件');
|
||||
|
||||
return null;
|
||||
}
|
||||
if (is_array($attachments) && count($attachments) > 9) {
|
||||
self::setError('最多只能添加 9 个附件');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = [
|
||||
'chat_type' => $chatType === 'group' ? 'group' : 'single',
|
||||
'external_userid' => array_values($externalIds),
|
||||
'sender' => $sender,
|
||||
];
|
||||
if (is_array($text) && trim((string) ($text['content'] ?? '')) !== '') {
|
||||
$body['text'] = ['content' => (string) $text['content']];
|
||||
}
|
||||
if (is_array($attachments) && $attachments !== []) {
|
||||
$body['attachments'] = array_values(array_filter($attachments, 'is_array'));
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
@@ -680,6 +680,17 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
// 已成功提交甘草 SCM(生成了甘草处方单号 或 submit_time>0),再改本地信息会与甘草侧不一致,禁止编辑
|
||||
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
|
||||
$gcSubmitTime = (int) $order->gancao_submit_time;
|
||||
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
|
||||
self::$error = sprintf(
|
||||
'订单已提交甘草(处方单号:%s),不可再编辑;如需修改请先走取消流程',
|
||||
$gcOrderNo !== '' ? $gcOrderNo : '—'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$medDays = $params['medication_days'] ?? null;
|
||||
$medDays = $medDays === '' || $medDays === null ? null : (int) $medDays;
|
||||
@@ -727,7 +738,7 @@ class PrescriptionOrderLogic
|
||||
$payOrderIds = array_values(array_intersect($payOrderIds, $existingIds));
|
||||
}
|
||||
|
||||
$linkErr = OrderLogic::assertPayOrdersLinkable($payOrderIds, $diagId, $adminId, $adminInfo);
|
||||
$linkErr = OrderLogic::assertPayOrdersLinkable($payOrderIds, $diagId, $adminId, $adminInfo, (int) $order->id);
|
||||
if ($linkErr !== null) {
|
||||
self::$error = $linkErr;
|
||||
|
||||
@@ -748,7 +759,7 @@ class PrescriptionOrderLogic
|
||||
$order->linked_pay_order_id = null;
|
||||
} else {
|
||||
$single = [(int) $lp];
|
||||
$linkErr = OrderLogic::assertPayOrdersLinkable($single, $diagId, $adminId, $adminInfo);
|
||||
$linkErr = OrderLogic::assertPayOrdersLinkable($single, $diagId, $adminId, $adminInfo, (int) $order->id);
|
||||
if ($linkErr !== null) {
|
||||
self::$error = $linkErr;
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\qywx;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class MessageValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'sender_userid' => 'require',
|
||||
'external_userids' => 'require|array|min:1',
|
||||
'msg_payload' => 'require|array',
|
||||
'chat_type' => 'in:single,group',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'sender_userid.require' => '请选择代发员工',
|
||||
'external_userids.require' => '请选择至少一位客户',
|
||||
'external_userids.array' => '客户列表格式错误',
|
||||
'external_userids.min' => '至少选择 1 位客户',
|
||||
'msg_payload.require' => '请填写消息内容',
|
||||
'msg_payload.array' => '消息内容格式错误',
|
||||
'chat_type.in' => '会话类型不合法',
|
||||
];
|
||||
|
||||
public function sceneSend()
|
||||
{
|
||||
return $this->only(['sender_userid', 'external_userids', 'msg_payload', 'chat_type']);
|
||||
}
|
||||
|
||||
public function sceneMarkRead()
|
||||
{
|
||||
return $this->only(['session_id'])->append('session_id', 'require|integer|>:0');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user