2026-06-04 14:15:12 +08:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace app\admin\model\split;
|
|
|
|
|
|
2026-06-20 05:07:11 +08:00
|
|
|
use app\common\service\SplitSyncConfigService;
|
2026-06-26 21:24:31 +08:00
|
|
|
use app\common\service\SplitTicketRuleService;
|
|
|
|
|
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';
|
|
|
|
|
|
2026-06-26 21:24:31 +08:00
|
|
|
protected static function init(): void
|
|
|
|
|
{
|
2026-07-05 23:03:50 +08:00
|
|
|
// 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
|
2026-06-26 21:24:31 +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',
|
2026-06-20 05:07:11 +08:00
|
|
|
'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()
|
|
|
|
|
{
|
2026-06-09 03:36:30 +08:00
|
|
|
// IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064
|
|
|
|
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
|
2026-06-04 14:15:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 列表子查询注入的点击数,无则按关联号码汇总
|
|
|
|
|
*
|
|
|
|
|
* @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 . '%';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-20 05:07:11 +08:00
|
|
|
* 是否因连续失败暂停自动同步(工单仍为开启状态)
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
*/
|
|
|
|
|
public static function isAutoSyncPaused(array $data): bool
|
|
|
|
|
{
|
|
|
|
|
if ((string) ($data['status'] ?? '') !== 'normal') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
|
|
|
|
if ($threshold <= 0) {
|
|
|
|
|
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-06-20 05:07:11 +08:00
|
|
|
if (self::isAutoSyncPaused($data)) {
|
|
|
|
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
|
|
|
|
return sprintf((string) __('Sync display auto paused'), $threshold);
|
|
|
|
|
}
|
|
|
|
|
|
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-10 06:44:57 +08:00
|
|
|
if ($status === 'pending') {
|
|
|
|
|
return (string) __('Sync display pending');
|
|
|
|
|
}
|
2026-06-04 14:15:12 +08:00
|
|
|
return (string) __('Sync display error');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 05:07:11 +08:00
|
|
|
/**
|
|
|
|
|
* 同步状态列 tooltip 完整说明
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $value
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
*/
|
|
|
|
|
public function getSyncTooltipTextAttr($value, $data): string
|
|
|
|
|
{
|
|
|
|
|
$parts = [];
|
|
|
|
|
if (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) : '';
|
|
|
|
|
}
|
|
|
|
|
}
|