Files

444 lines
13 KiB
PHP
Raw Permalink Normal View History

2026-06-04 14:15:12 +08:00
<?php
declare(strict_types=1);
namespace app\admin\model\split;
use app\common\service\SplitSyncConfigService;
use app\common\service\SplitTicketRuleService;
2026-07-13 23:06:53 +08:00
use app\common\service\SplitTicketSyncFailRetryService;
use app\common\service\SplitTicketSyncLockService;
2026-06-04 14:15:12 +08:00
use think\Db;
use think\Model;
/**
* 分流工单模型
*/
class Ticket extends Model
{
protected $name = 'split_ticket';
protected static function init(): void
{
2026-07-05 23:03:50 +08:00
// 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
self::beforeDelete(function (self $ticket): void {
(new SplitTicketSyncLockService())->release((int) $ticket['id']);
(new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket);
});
}
2026-06-04 14:15:12 +08:00
protected $autoWriteTimestamp = 'integer';
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $deleteTime = false;
protected $append = [
'ticket_type_text',
'number_type_text',
'link_code_text',
'start_time_text',
'end_time_text',
'status_text',
'click_count',
'ticket_progress_text',
'inbound_ratio_text',
'sync_display_text',
'sync_success_text',
'sync_tooltip_text',
'sync_auto_paused',
2026-06-04 14:15:12 +08:00
];
/**
* 工单类型
*
* @return array<string, string>
*/
public function getTicketTypeList(): array
{
return [
'xinghe' => __('Ticket type xinghe'),
'haiwang' => __('Ticket type haiwang'),
'taiji' => __('Ticket type taiji'),
'huojian' => __('Ticket type huojian'),
'ss_channel' => __('Ticket type ss_channel'),
'ss_customer' => __('Ticket type ss_customer'),
'yifafa' => __('Ticket type yifafa'),
'a2c' => __('Ticket type a2c'),
'ceo_scrm' => __('Ticket type ceo_scrm'),
2026-06-20 04:47:34 +08:00
'chatknow' => __('Ticket type chatknow'),
2026-06-04 14:15:12 +08:00
'whatshub' => __('Ticket type whatshub'),
'sihai' => __('Ticket type sihai'),
];
}
/**
* @return array<string, string>
*/
public function getStatusList(): array
{
return [
'normal' => __('Status normal'),
'hidden' => __('Status hidden'),
];
}
/**
* @return array<string, string>
*/
public function getSyncStatusList(): array
{
return [
'success' => __('Sync status success'),
'error' => __('Sync status error'),
'pending' => __('Sync status pending'),
];
}
/**
* 号码类型
*
* @return array<string, string>
*/
public function getNumberTypeList(): array
{
return [
'whatsapp' => 'Whatsapp',
'telegram' => 'Telegram',
'line' => 'Line',
'custom' => __('Number type custom'),
];
}
/**
* 关联分流链接
*/
public function splitLink()
{
// IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
}
/**
* 列表子查询注入的点击数,无则按关联号码汇总
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getClickCountAttr($value, $data): int
{
if (isset($data['click_count']) && $data['click_count'] !== '' && $data['click_count'] !== null) {
return (int) $data['click_count'];
}
return self::sumVisitCountForTicket($data);
}
/**
* 工单进度:完成数量 / 工单总量
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getTicketProgressTextAttr($value, $data): string
{
$total = (int) ($data['ticket_total'] ?? 0);
$complete = (int) ($data['complete_count'] ?? 0);
if ($total <= 0) {
2026-06-09 03:36:30 +08:00
return '0%';
2026-06-04 14:15:12 +08:00
}
$percent = round($complete / $total * 100, 2);
return $percent . '%';
}
/**
* 进线比例:进线人数 / 点击数(点击数=关联号码 visit_count 之和)
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getInboundRatioTextAttr($value, $data): string
{
$clicks = $this->getClickCountAttr(null, $data);
$inbound = (int) ($data['inbound_count'] ?? 0);
if ($clicks <= 0) {
return '—';
}
$percent = round($inbound / $clicks * 100, 2);
return $percent . '%';
}
/**
* 是否因连续失败暂停自动同步(工单仍为开启状态)
*
* @param array<string, mixed> $data
*/
public static function isAutoSyncPaused(array $data): bool
{
if ((string) ($data['status'] ?? '') !== 'normal') {
return false;
}
2026-07-13 23:06:53 +08:00
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return true;
}
if ($retryService->isInRetryMode($data)) {
return true;
}
$threshold = SplitSyncConfigService::getFailPauseThreshold();
if ($threshold <= 0) {
return false;
}
2026-07-13 23:06:53 +08:00
if (SplitSyncConfigService::hasFailRetrySchedule()) {
return false;
}
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
}
/**
* 同步状态展示文案(列表列默认显示,异常详情见 tooltip)
2026-06-04 14:15:12 +08:00
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getSyncDisplayTextAttr($value, $data): string
{
2026-07-13 23:06:53 +08:00
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return (string) __('Sync display auto sync stopped');
}
if ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
return sprintf(
(string) __('Sync display retry waiting'),
date('H:i', $nextAt),
$attempt
);
}
}
if (self::isAutoSyncPaused($data)) {
2026-07-13 23:06:53 +08:00
return (string) __('Sync display auto paused');
}
2026-06-04 14:15:12 +08:00
$status = (string) ($data['sync_status'] ?? 'pending');
$online = (int) ($data['online_count'] ?? 0);
if ($status === 'success') {
return sprintf((string) __('Sync display success'), $online);
}
2026-06-09 03:36:30 +08:00
if ($status === 'pending') {
return (string) __('Sync display pending');
}
2026-06-04 14:15:12 +08:00
return (string) __('Sync display error');
}
/**
* 同步状态列 tooltip 完整说明
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getSyncTooltipTextAttr($value, $data): string
{
$parts = [];
2026-07-13 23:06:53 +08:00
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
$parts[] = (string) __('Sync tooltip auto sync stopped');
} elseif ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
$parts[] = sprintf(
(string) __('Sync tooltip retry waiting'),
(int) ($data['sync_fail_count'] ?? 0),
date('Y-m-d H:i:s', $nextAt),
$attempt
);
}
} elseif (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold();
$parts[] = sprintf(
(string) __('Sync tooltip auto paused'),
(int) ($data['sync_fail_count'] ?? 0),
$threshold
);
}
$message = trim((string) ($data['sync_message'] ?? ''));
if ($message !== '') {
$parts[] = (string) __('Sync tooltip error prefix') . $message;
}
return implode("\n", $parts);
}
/**
* 是否暂停自动同步(供前端样式判断)
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getSyncAutoPausedAttr($value, $data): bool
{
return self::isAutoSyncPaused($data);
}
/**
* 最近一次同步成功时间及方式
*
* @param mixed $value
* @param array<string, mixed> $data
*/
public function getSyncSuccessTextAttr($value, $data): string
{
$ts = (int) ($data['sync_success_time'] ?? 0);
if ($ts <= 0) {
return '';
}
$timeText = date('Y-m-d H:i:s', $ts);
$mode = (string) ($data['sync_success_mode'] ?? '');
if ($mode === 'auto') {
return $timeText . ' (' . (string) __('Sync mode auto') . ')';
}
if ($mode === 'manual') {
return $timeText . ' (' . (string) __('Sync mode manual') . ')';
}
return $timeText;
}
2026-06-04 14:15:12 +08:00
public function setStartTimeAttr($value): ?int
{
return self::parseTimeValue($value);
}
public function setEndTimeAttr($value): ?int
{
return self::parseTimeValue($value);
}
public function setNumberTypeCustomAttr($value): string
{
return trim((string) $value);
}
public function setTicketTotalAttr($value): int
{
return max(0, (int) $value);
}
public function getTicketTypeTextAttr($value, $data): string
{
$key = (string) ($data['ticket_type'] ?? '');
$list = $this->getTicketTypeList();
return $list[$key] ?? $key;
}
public function getNumberTypeTextAttr($value, $data): string
{
$type = (string) ($data['number_type'] ?? '');
if ($type === 'custom') {
$custom = trim((string) ($data['number_type_custom'] ?? ''));
return $custom !== '' ? $custom : (string) __('Number type custom');
}
$list = $this->getNumberTypeList();
return $list[$type] ?? $type;
}
public function getLinkCodeTextAttr($value, $data): string
{
if ($value !== '' && $value !== null) {
return (string) $value;
}
$linkId = (int) ($data['split_link_id'] ?? 0);
if ($linkId <= 0) {
return '';
}
$code = Link::where('id', $linkId)->value('link_code');
return (string) $code;
}
public function getStartTimeTextAttr($value, $data): string
{
return self::formatTimeText($data['start_time'] ?? null);
}
public function getEndTimeTextAttr($value, $data): string
{
return self::formatTimeText($data['end_time'] ?? null);
}
public function getStatusTextAttr($value, $data): string
{
$key = (string) ($data['status'] ?? '');
$list = $this->getStatusList();
return $list[$key] ?? $key;
}
/**
* 构建列表用 click_count 子查询 SQL 片段
*/
public static function buildClickCountSubQuery(string $ticketTableAlias = ''): string
{
$ticketTable = (new self())->getTable();
$numberTable = (new Number())->getTable();
$t = $ticketTableAlias !== '' ? $ticketTableAlias : $ticketTable;
return "(SELECT COALESCE(SUM(n.visit_count), 0) FROM `{$numberTable}` n "
. "WHERE n.ticket_name = `{$t}`.ticket_name "
. "AND n.split_link_id = `{$t}`.split_link_id "
. "AND n.admin_id = `{$t}`.admin_id) AS click_count";
}
/**
* @param array<string, mixed> $data
*/
private static function sumVisitCountForTicket(array $data): int
{
$ticketName = (string) ($data['ticket_name'] ?? '');
$linkId = (int) ($data['split_link_id'] ?? 0);
$adminId = (int) ($data['admin_id'] ?? 0);
if ($ticketName === '' || $linkId <= 0) {
return 0;
}
$sum = Db::name('split_number')
->where('ticket_name', $ticketName)
->where('split_link_id', $linkId)
->where('admin_id', $adminId)
->sum('visit_count');
return (int) $sum;
}
/**
* @param mixed $value
*/
private static function parseTimeValue($value): ?int
{
if ($value === '' || $value === null) {
return null;
}
if (is_numeric($value)) {
return (int) $value;
}
$ts = strtotime((string) $value);
return $ts === false ? null : $ts;
}
/**
* @param mixed $value
*/
private static function formatTimeText($value): string
{
if ($value === '' || $value === null || !is_numeric($value)) {
return '';
}
$ts = (int) $value;
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
}
}