This commit is contained in:
Your Name
2026-04-21 09:49:44 +08:00
parent c9e0419895
commit c15c348b7a
454 changed files with 5391 additions and 25651 deletions
+64 -2
View File
@@ -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;