diff --git a/internal/controllers/dashboard/ticket_controller.go b/internal/controllers/dashboard/ticket_controller.go index 5b34945..58b177f 100644 --- a/internal/controllers/dashboard/ticket_controller.go +++ b/internal/controllers/dashboard/ticket_controller.go @@ -168,6 +168,21 @@ func (c *TicketController) PostUpdate() *web.JsonResult { 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 { operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign) if err != nil { diff --git a/internal/pkg/dto/request/ticket_request.go b/internal/pkg/dto/request/ticket_request.go index 10c75ce..6a4c2c3 100644 --- a/internal/pkg/dto/request/ticket_request.go +++ b/internal/pkg/dto/request/ticket_request.go @@ -27,6 +27,11 @@ type UpdateTicketRequest struct { CurrentAssigneeID int64 `json:"currentAssigneeId"` } +type LinkTicketCustomerRequest struct { + TicketID int64 `json:"ticketId"` + CustomerID int64 `json:"customerId"` +} + type AssignTicketRequest struct { TicketID int64 `json:"ticketId"` ToUserID int64 `json:"toUserId"` diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go index 5a88f5d..16e1ba2 100644 --- a/internal/services/ticket_service.go +++ b/internal/services/ticket_service.go @@ -230,8 +230,8 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator * func withSQLiteTicketCreateLock(db *gorm.DB, fn func() error) error { if db != nil && db.Dialector.Name() == "sqlite" { - ticketNoSQLiteMu.Lock() - defer ticketNoSQLiteMu.Unlock() + TicketNoSequenceService.ticketNoSQLiteMu.Lock() + defer TicketNoSequenceService.ticketNoSQLiteMu.Unlock() } 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 { if operator == nil { return errorsx.Unauthorized("未登录或登录已过期") diff --git a/internal/services/ticket_service_test.go b/internal/services/ticket_service_test.go index 402d032..b79eccf 100644 --- a/internal/services/ticket_service_test.go +++ b/internal/services/ticket_service_test.go @@ -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) { setupTicketTestDB(t) operator := createTestOperator(t, "status-operator") diff --git a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx index 58a8f7d..c8ceb63 100644 --- a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx +++ b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner" import { type CustomerFormSavePayload } from "@/components/customer-form" import { CustomerFormDialog } from "@/components/customer-form-dialog" +import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog" import { ProjectDialog } from "@/components/project-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -58,6 +59,10 @@ function metadataValue(value?: string | number | null) { return String(value) } +function getTicketCustomerId(ticket?: TicketDetail["ticket"] | null) { + return Number(ticket?.customer?.id || ticket?.customerId || 0) +} + export function TicketDetailDialog({ ticketId, open, @@ -74,6 +79,7 @@ export function TicketDetailDialog({ const [editSaving, setEditSaving] = useState(false) const [customerEditOpen, setCustomerEditOpen] = useState(false) const [customerEditSaving, setCustomerEditSaving] = useState(false) + const [customerLinkOpen, setCustomerLinkOpen] = useState(false) const loadSeqRef = useRef(0) const dialogSeqRef = useRef(0) const currentTicketIdRef = useRef(null) @@ -124,6 +130,7 @@ export function TicketDetailDialog({ setAssignOpen(false) setEditOpen(false) setCustomerEditOpen(false) + setCustomerLinkOpen(false) setProgressContent("") }, [open, ticketId]) @@ -245,7 +252,8 @@ export function TicketDetailDialog({ } async function handleUpdateCustomer(payload: CustomerFormSavePayload) { - if (!ticket?.id || !ticket.customerId) { + const activeCustomerId = getTicketCustomerId(ticket) + if (!ticket?.id || activeCustomerId <= 0) { toast.error("当前工单未关联客户") return } @@ -253,7 +261,6 @@ export function TicketDetailDialog({ return } const activeTicketId = ticket.id - const activeCustomerId = ticket.customerId const activeDialogSeq = dialogSeqRef.current setCustomerEditSaving(true) 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 customerId = getTicketCustomerId(ticket) return ( <> @@ -378,18 +399,22 @@ export function TicketDetailDialog({
客户信息
- {ticket.customerId > 0 ? ( - - ) : null} +
@@ -477,9 +502,15 @@ export function TicketDetailDialog({ open={customerEditOpen} onOpenChange={setCustomerEditOpen} saving={customerEditSaving} - itemId={ticket?.customerId ? ticket.customerId : null} + itemId={customerId > 0 ? customerId : null} onSave={handleUpdateCustomer} /> + ) } diff --git a/web/components/customer-link-or-create-dialog.tsx b/web/components/customer-link-or-create-dialog.tsx index f6d4392..5eeabf0 100644 --- a/web/components/customer-link-or-create-dialog.tsx +++ b/web/components/customer-link-or-create-dialog.tsx @@ -9,12 +9,15 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { linkConversationToCustomer } from "@/lib/api/agent" import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer" +import { linkTicketToCustomer } from "@/lib/api/ticket" export type CustomerLinkOrCreateDialogProps = { open: boolean onOpenChange: (open: boolean) => void /** 传入时会话侧:关联已有或新建后绑定该会话 */ conversationId?: number | null + /** 传入时工单侧:关联已有或新建后绑定该工单 */ + ticketId?: number | null /** 绑定成功或仅新建成功后的回调 */ onSuccess?: () => void | Promise } @@ -25,6 +28,7 @@ export function CustomerLinkOrCreateDialog({ open, onOpenChange, conversationId, + ticketId, onSuccess, }: CustomerLinkOrCreateDialogProps) { const [searchText, setSearchText] = useState("") @@ -70,7 +74,7 @@ export function CustomerLinkOrCreateDialog({ } const handleLinkExisting = async (customer: AdminCustomer) => { - if (!conversationId) { + if (!conversationId && !ticketId) { toast.success(`已选择客户:${customer.name || `#${customer.id}`}`) onOpenChange(false) await onSuccess?.() @@ -84,6 +88,12 @@ export function CustomerLinkOrCreateDialog({ customerId: customer.id, }) } + if (ticketId) { + await linkTicketToCustomer({ + ticketId, + customerId: customer.id, + }) + } toast.success("已关联客户") onOpenChange(false) await onSuccess?.() @@ -103,7 +113,15 @@ export function CustomerLinkOrCreateDialog({ conversationId, customerId: created.id, }) - toast.success("已创建客户并关联当前会话") + } + if (ticketId) { + await linkTicketToCustomer({ + ticketId, + customerId: created.id, + }) + } + if (conversationId || ticketId) { + toast.success(conversationId ? "已创建客户并关联当前会话" : "已创建客户并关联当前工单") } else { toast.success("已创建客户") } @@ -119,12 +137,12 @@ export function CustomerLinkOrCreateDialog({ const description = ( <> 先搜索已有客户; - {conversationId - ? "选中即可关联当前会话。" + {conversationId || ticketId + ? `选中即可关联当前${conversationId ? "会话" : "工单"}。` : "未接入上下文时仅创建或定位客户。"} 若无结果,可填写下方新客户 - {conversationId - ? ",保存后将自动关联会话。" + {conversationId || ticketId + ? `,保存后将自动关联${conversationId ? "会话" : "工单"}。` : "。"} ) diff --git a/web/lib/api/ticket.ts b/web/lib/api/ticket.ts index a2057bf..57d0b29 100644 --- a/web/lib/api/ticket.ts +++ b/web/lib/api/ticket.ts @@ -175,6 +175,16 @@ export function updateTicket(payload: UpdateTicketPayload) { }) } +export function linkTicketToCustomer(payload: { + ticketId: number + customerId: number +}) { + return request("/api/dashboard/ticket/link_customer", { + method: "POST", + body: JSON.stringify(payload), + }) +} + export function assignTicket(payload: { ticketId: number toUserId: number