feat(ticket): add customer linking functionality to tickets
This commit is contained in:
@@ -168,6 +168,21 @@ func (c *TicketController) PostUpdate() *web.JsonResult {
|
|||||||
return web.JsonSuccess()
|
return web.JsonSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *TicketController) PostLink_customer() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
req := request.LinkTicketCustomerRequest{}
|
||||||
|
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
if err := services.TicketService.LinkCustomer(req.TicketID, req.CustomerID, operator); err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TicketController) PostAssign() *web.JsonResult {
|
func (c *TicketController) PostAssign() *web.JsonResult {
|
||||||
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign)
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type UpdateTicketRequest struct {
|
|||||||
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
CurrentAssigneeID int64 `json:"currentAssigneeId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LinkTicketCustomerRequest struct {
|
||||||
|
TicketID int64 `json:"ticketId"`
|
||||||
|
CustomerID int64 `json:"customerId"`
|
||||||
|
}
|
||||||
|
|
||||||
type AssignTicketRequest struct {
|
type AssignTicketRequest struct {
|
||||||
TicketID int64 `json:"ticketId"`
|
TicketID int64 `json:"ticketId"`
|
||||||
ToUserID int64 `json:"toUserId"`
|
ToUserID int64 `json:"toUserId"`
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator *
|
|||||||
|
|
||||||
func withSQLiteTicketCreateLock(db *gorm.DB, fn func() error) error {
|
func withSQLiteTicketCreateLock(db *gorm.DB, fn func() error) error {
|
||||||
if db != nil && db.Dialector.Name() == "sqlite" {
|
if db != nil && db.Dialector.Name() == "sqlite" {
|
||||||
ticketNoSQLiteMu.Lock()
|
TicketNoSequenceService.ticketNoSQLiteMu.Lock()
|
||||||
defer ticketNoSQLiteMu.Unlock()
|
defer TicketNoSequenceService.ticketNoSQLiteMu.Unlock()
|
||||||
}
|
}
|
||||||
return fn()
|
return fn()
|
||||||
}
|
}
|
||||||
@@ -309,6 +309,35 @@ func (s *ticketService) UpdateTicket(req request.UpdateTicketRequest, operator *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ticketService) LinkCustomer(ticketID int64, customerID int64, operator *dto.AuthPrincipal) error {
|
||||||
|
if operator == nil {
|
||||||
|
return errorsx.Unauthorized("未登录或登录已过期")
|
||||||
|
}
|
||||||
|
ticket := s.Get(ticketID)
|
||||||
|
if ticket == nil {
|
||||||
|
return errorsx.InvalidParam("工单不存在")
|
||||||
|
}
|
||||||
|
if customerID <= 0 || CustomerService.Get(customerID) == nil {
|
||||||
|
return errorsx.InvalidParam("客户不存在")
|
||||||
|
}
|
||||||
|
if ticket.ConversationID > 0 {
|
||||||
|
conversation := ConversationService.Get(ticket.ConversationID)
|
||||||
|
if conversation == nil {
|
||||||
|
return errorsx.InvalidParam("会话不存在")
|
||||||
|
}
|
||||||
|
if conversation.CustomerID > 0 && conversation.CustomerID != customerID {
|
||||||
|
return errorsx.InvalidParam("会话与客户不匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return repositories.TicketRepository.Updates(sqls.DB(), ticket.ID, map[string]any{
|
||||||
|
"customer_id": customerID,
|
||||||
|
"updated_at": now,
|
||||||
|
"update_user_id": operator.UserID,
|
||||||
|
"update_user_name": operator.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator *dto.AuthPrincipal) error {
|
func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator *dto.AuthPrincipal) error {
|
||||||
if operator == nil {
|
if operator == nil {
|
||||||
return errorsx.Unauthorized("未登录或登录已过期")
|
return errorsx.Unauthorized("未登录或登录已过期")
|
||||||
|
|||||||
@@ -119,6 +119,44 @@ func TestTicketServiceCreateTicketPublishesTicketCreatedEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTicketServiceLinkCustomerUpdatesTicketCustomerID(t *testing.T) {
|
||||||
|
setupTicketTestDB(t)
|
||||||
|
operator := createTestOperator(t, "link-ticket-customer")
|
||||||
|
customerID := createTestCustomer(t, "link-ticket-customer")
|
||||||
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-customer"), operator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTicket() error = %v", err)
|
||||||
|
}
|
||||||
|
if ticket.CustomerID != 0 {
|
||||||
|
t.Fatalf("expected ticket without customer, got %d", ticket.CustomerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.TicketService.LinkCustomer(ticket.ID, customerID, operator); err != nil {
|
||||||
|
t.Fatalf("LinkCustomer() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := services.TicketService.Get(ticket.ID)
|
||||||
|
if updated == nil {
|
||||||
|
t.Fatalf("expected ticket")
|
||||||
|
}
|
||||||
|
if updated.CustomerID != customerID {
|
||||||
|
t.Fatalf("expected customer id %d, got %d", customerID, updated.CustomerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketServiceLinkCustomerRejectsMissingCustomer(t *testing.T) {
|
||||||
|
setupTicketTestDB(t)
|
||||||
|
operator := createTestOperator(t, "link-ticket-missing-customer")
|
||||||
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-missing-customer"), operator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTicket() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.TicketService.LinkCustomer(ticket.ID, 999999, operator); err == nil {
|
||||||
|
t.Fatalf("expected LinkCustomer() to reject missing customer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTicketServiceChangeStatusSetsHandledAt(t *testing.T) {
|
func TestTicketServiceChangeStatusSetsHandledAt(t *testing.T) {
|
||||||
setupTicketTestDB(t)
|
setupTicketTestDB(t)
|
||||||
operator := createTestOperator(t, "status-operator")
|
operator := createTestOperator(t, "status-operator")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
import { type CustomerFormSavePayload } from "@/components/customer-form"
|
import { type CustomerFormSavePayload } from "@/components/customer-form"
|
||||||
import { CustomerFormDialog } from "@/components/customer-form-dialog"
|
import { CustomerFormDialog } from "@/components/customer-form-dialog"
|
||||||
|
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
|
||||||
import { ProjectDialog } from "@/components/project-dialog"
|
import { ProjectDialog } from "@/components/project-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -58,6 +59,10 @@ function metadataValue(value?: string | number | null) {
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTicketCustomerId(ticket?: TicketDetail["ticket"] | null) {
|
||||||
|
return Number(ticket?.customer?.id || ticket?.customerId || 0)
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketDetailDialog({
|
export function TicketDetailDialog({
|
||||||
ticketId,
|
ticketId,
|
||||||
open,
|
open,
|
||||||
@@ -74,6 +79,7 @@ export function TicketDetailDialog({
|
|||||||
const [editSaving, setEditSaving] = useState(false)
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
const [customerEditOpen, setCustomerEditOpen] = useState(false)
|
const [customerEditOpen, setCustomerEditOpen] = useState(false)
|
||||||
const [customerEditSaving, setCustomerEditSaving] = useState(false)
|
const [customerEditSaving, setCustomerEditSaving] = useState(false)
|
||||||
|
const [customerLinkOpen, setCustomerLinkOpen] = useState(false)
|
||||||
const loadSeqRef = useRef(0)
|
const loadSeqRef = useRef(0)
|
||||||
const dialogSeqRef = useRef(0)
|
const dialogSeqRef = useRef(0)
|
||||||
const currentTicketIdRef = useRef<number | null>(null)
|
const currentTicketIdRef = useRef<number | null>(null)
|
||||||
@@ -124,6 +130,7 @@ export function TicketDetailDialog({
|
|||||||
setAssignOpen(false)
|
setAssignOpen(false)
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
setCustomerEditOpen(false)
|
setCustomerEditOpen(false)
|
||||||
|
setCustomerLinkOpen(false)
|
||||||
setProgressContent("")
|
setProgressContent("")
|
||||||
}, [open, ticketId])
|
}, [open, ticketId])
|
||||||
|
|
||||||
@@ -245,7 +252,8 @@ export function TicketDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
|
async function handleUpdateCustomer(payload: CustomerFormSavePayload) {
|
||||||
if (!ticket?.id || !ticket.customerId) {
|
const activeCustomerId = getTicketCustomerId(ticket)
|
||||||
|
if (!ticket?.id || activeCustomerId <= 0) {
|
||||||
toast.error("当前工单未关联客户")
|
toast.error("当前工单未关联客户")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,7 +261,6 @@ export function TicketDetailDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const activeTicketId = ticket.id
|
const activeTicketId = ticket.id
|
||||||
const activeCustomerId = ticket.customerId
|
|
||||||
const activeDialogSeq = dialogSeqRef.current
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
setCustomerEditSaving(true)
|
setCustomerEditSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -280,7 +287,21 @@ export function TicketDetailDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCustomerLinked() {
|
||||||
|
const activeTicketId = ticket?.id
|
||||||
|
const activeDialogSeq = dialogSeqRef.current
|
||||||
|
if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadDetail(activeTicketId, activeDialogSeq)
|
||||||
|
if (!isCurrentOperation(activeTicketId, activeDialogSeq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = detail?.ticket
|
const ticket = detail?.ticket
|
||||||
|
const customerId = getTicketCustomerId(ticket)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -378,18 +399,22 @@ export function TicketDetailDialog({
|
|||||||
<section className="space-y-3 rounded-md border p-3 text-sm">
|
<section className="space-y-3 rounded-md border p-3 text-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="font-medium text-muted-foreground">客户信息</div>
|
<div className="font-medium text-muted-foreground">客户信息</div>
|
||||||
{ticket.customerId > 0 ? (
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
||||||
className="h-7 shrink-0 gap-1 px-2 text-xs"
|
onClick={() => {
|
||||||
onClick={() => setCustomerEditOpen(true)}
|
if (customerId > 0) {
|
||||||
>
|
setCustomerEditOpen(true)
|
||||||
<PencilIcon className="size-3.5" />
|
return
|
||||||
编辑
|
}
|
||||||
</Button>
|
setCustomerLinkOpen(true)
|
||||||
) : null}
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
{customerId > 0 ? "编辑" : "关联或创建"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} />
|
<MetadataItem label="客户" value={ticket.customer?.name || ticket.customerId} />
|
||||||
@@ -477,9 +502,15 @@ export function TicketDetailDialog({
|
|||||||
open={customerEditOpen}
|
open={customerEditOpen}
|
||||||
onOpenChange={setCustomerEditOpen}
|
onOpenChange={setCustomerEditOpen}
|
||||||
saving={customerEditSaving}
|
saving={customerEditSaving}
|
||||||
itemId={ticket?.customerId ? ticket.customerId : null}
|
itemId={customerId > 0 ? customerId : null}
|
||||||
onSave={handleUpdateCustomer}
|
onSave={handleUpdateCustomer}
|
||||||
/>
|
/>
|
||||||
|
<CustomerLinkOrCreateDialog
|
||||||
|
open={customerLinkOpen}
|
||||||
|
onOpenChange={setCustomerLinkOpen}
|
||||||
|
ticketId={ticket?.id ?? null}
|
||||||
|
onSuccess={handleCustomerLinked}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { linkConversationToCustomer } from "@/lib/api/agent"
|
import { linkConversationToCustomer } from "@/lib/api/agent"
|
||||||
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
|
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
|
||||||
|
import { linkTicketToCustomer } from "@/lib/api/ticket"
|
||||||
|
|
||||||
export type CustomerLinkOrCreateDialogProps = {
|
export type CustomerLinkOrCreateDialogProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** 传入时会话侧:关联已有或新建后绑定该会话 */
|
/** 传入时会话侧:关联已有或新建后绑定该会话 */
|
||||||
conversationId?: number | null
|
conversationId?: number | null
|
||||||
|
/** 传入时工单侧:关联已有或新建后绑定该工单 */
|
||||||
|
ticketId?: number | null
|
||||||
/** 绑定成功或仅新建成功后的回调 */
|
/** 绑定成功或仅新建成功后的回调 */
|
||||||
onSuccess?: () => void | Promise<void>
|
onSuccess?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
@@ -25,6 +28,7 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
ticketId,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: CustomerLinkOrCreateDialogProps) {
|
}: CustomerLinkOrCreateDialogProps) {
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
@@ -70,7 +74,7 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLinkExisting = async (customer: AdminCustomer) => {
|
const handleLinkExisting = async (customer: AdminCustomer) => {
|
||||||
if (!conversationId) {
|
if (!conversationId && !ticketId) {
|
||||||
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
|
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await onSuccess?.()
|
await onSuccess?.()
|
||||||
@@ -84,6 +88,12 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (ticketId) {
|
||||||
|
await linkTicketToCustomer({
|
||||||
|
ticketId,
|
||||||
|
customerId: customer.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
toast.success("已关联客户")
|
toast.success("已关联客户")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await onSuccess?.()
|
await onSuccess?.()
|
||||||
@@ -103,7 +113,15 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
conversationId,
|
conversationId,
|
||||||
customerId: created.id,
|
customerId: created.id,
|
||||||
})
|
})
|
||||||
toast.success("已创建客户并关联当前会话")
|
}
|
||||||
|
if (ticketId) {
|
||||||
|
await linkTicketToCustomer({
|
||||||
|
ticketId,
|
||||||
|
customerId: created.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (conversationId || ticketId) {
|
||||||
|
toast.success(conversationId ? "已创建客户并关联当前会话" : "已创建客户并关联当前工单")
|
||||||
} else {
|
} else {
|
||||||
toast.success("已创建客户")
|
toast.success("已创建客户")
|
||||||
}
|
}
|
||||||
@@ -119,12 +137,12 @@ export function CustomerLinkOrCreateDialog({
|
|||||||
const description = (
|
const description = (
|
||||||
<>
|
<>
|
||||||
先搜索已有客户;
|
先搜索已有客户;
|
||||||
{conversationId
|
{conversationId || ticketId
|
||||||
? "选中即可关联当前会话。"
|
? `选中即可关联当前${conversationId ? "会话" : "工单"}。`
|
||||||
: "未接入上下文时仅创建或定位客户。"}
|
: "未接入上下文时仅创建或定位客户。"}
|
||||||
若无结果,可填写下方新客户
|
若无结果,可填写下方新客户
|
||||||
{conversationId
|
{conversationId || ticketId
|
||||||
? ",保存后将自动关联会话。"
|
? `,保存后将自动关联${conversationId ? "会话" : "工单"}。`
|
||||||
: "。"}
|
: "。"}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ export function updateTicket(payload: UpdateTicketPayload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkTicketToCustomer(payload: {
|
||||||
|
ticketId: number
|
||||||
|
customerId: number
|
||||||
|
}) {
|
||||||
|
return request<void>("/api/dashboard/ticket/link_customer", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function assignTicket(payload: {
|
export function assignTicket(payload: {
|
||||||
ticketId: number
|
ticketId: number
|
||||||
toUserId: number
|
toUserId: number
|
||||||
|
|||||||
Reference in New Issue
Block a user