Vyrizeno je vyslovny priznak, ne hadani ze stavu
`Ticket.closed` se nastavuje vyslovne. Predchozi verze ho odvozovala ze jmena stavu (completed, vyreseno, ...), coz nikdo nechtel a hlavne to uhodne spatne pokazde, kdyz si nekdo pojmenuje stavy po svem. `TicketType.closedStatuses` zruseno, byla to tatáz obchazka o uroven vys. Kroky `ticket/upsert` a `ticket/set-status` maji vstup Vyrizeny s trema stavy: ano, ne, prazdne. Prazdne znamena nemenit - jinak by kazda zmena textu stavu mimochodem otevrela vyrizeny ticket. `POST /tickets/:id/status` prijima `closed` jako nepovinny bool a podminka ve strome se na nej muze zeptat. Overeno 8 kontrolami: ticket se stavem completed neni automaticky vyrizeny, dokud to nekdo nerekne. Automatizace s podminkou status = completed zabere na ticketu, ktery do toho stavu prejde, ale na uz existujici tickety nesahne - spousti ji udalost, ne stav. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
202d525905
commit
fbe5b8ff6f
+27
-10
@@ -452,7 +452,7 @@ export const services: Service[] = [
|
||||
{ id: 'ticket.status', name: 'status', type: 'string', required: true },
|
||||
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
|
||||
{ id: 'ticket.typeId', name: 'typeId', type: 'string', required: false },
|
||||
{ id: 'ticket.stage', name: 'stage', type: 'string', required: false },
|
||||
{ id: 'ticket.closed', name: 'closed', type: 'boolean', required: true },
|
||||
{ id: 'ticket.tags', name: 'tags', type: 'list', required: false },
|
||||
{ id: 'ticket.assigneeId', name: 'assigneeId', type: 'string', required: false },
|
||||
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
|
||||
@@ -521,9 +521,18 @@ export const services: Service[] = [
|
||||
label: 'Stav',
|
||||
kind: 'text',
|
||||
required: false,
|
||||
hint:
|
||||
'Nový, v řešení, čeká nebo vyřešeno. Jinou hodnotu (například stav hovoru ' +
|
||||
'od voicebota) uložíme jako fázi, protože to přesně je.',
|
||||
hint: 'Cokoliv chcete, například ringing nebo Připraveno k expedici.',
|
||||
},
|
||||
{
|
||||
id: 'closed',
|
||||
label: 'Vyřízený',
|
||||
kind: 'choice',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'true', label: 'Ano' },
|
||||
{ value: 'false', label: 'Ne' },
|
||||
],
|
||||
hint: 'Prázdné = nechat, jak je. Podle tohohle se počítá fronta a statistiky.',
|
||||
},
|
||||
{
|
||||
id: 'stage',
|
||||
@@ -702,20 +711,28 @@ export const services: Service[] = [
|
||||
{
|
||||
id: 'set-status',
|
||||
name: 'Změnit stav ticketu',
|
||||
description: 'Například vyřešeno, když automatizace dokončila, co měla.',
|
||||
description:
|
||||
'Stav je libovolný text, žádný číselník. Jestli je ticket vyřízený, ' +
|
||||
'říká samostatné pole - podle něj se počítá fronta a statistiky.',
|
||||
implementation: 'script',
|
||||
inputs: [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Stav',
|
||||
kind: 'choice',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
hint: 'Cokoliv chcete, například completed nebo Předáno dopravci.',
|
||||
},
|
||||
{
|
||||
id: 'closed',
|
||||
label: 'Vyřízený',
|
||||
kind: 'choice',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'new', label: 'Nový' },
|
||||
{ value: 'open', label: 'V řešení' },
|
||||
{ value: 'waiting', label: 'Čeká na klienta' },
|
||||
{ value: 'resolved', label: 'Vyřešeno' },
|
||||
{ value: 'true', label: 'Ano' },
|
||||
{ value: 'false', label: 'Ne' },
|
||||
],
|
||||
hint: 'Prázdné = nechat, jak je.',
|
||||
},
|
||||
{ id: 'ticketId', label: 'Ticket', kind: 'text', required: false },
|
||||
],
|
||||
|
||||
+25
-48
@@ -37,33 +37,8 @@ import { withMirror } from './store/mirror.js';
|
||||
*/
|
||||
export type TicketStatus = string;
|
||||
|
||||
/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni workflow. */
|
||||
/** Vychozi stavy, kdyz si typ ticketu nenadefinuje vlastni. Jen nabidka. */
|
||||
export const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'] as const;
|
||||
|
||||
/**
|
||||
* Stavy, ktere znamenaji hotovo.
|
||||
*
|
||||
* Fronta i statistiky potrebuji vedet, co uz nikdo neresi. Kdyz je stav volny
|
||||
* retezec, nejde to poznat z nej samotneho - proto se to drzi jako priznak
|
||||
* `closed` na ticketu a tenhle seznam rika, ze se ma nastavit sam.
|
||||
*
|
||||
* Anglicke varianty jsou tu kvuli cizim aplikacim: voicebot posle `completed`
|
||||
* a nikdo by nemel muset rucne rikat, ze to znamena hotovo.
|
||||
*/
|
||||
const CLOSING = new Set(
|
||||
['vyřešeno', 'hotovo', 'uzavřeno', 'resolved', 'closed', 'done', 'completed'].map((value) =>
|
||||
value.toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
/** Znamena tenhle stav, ze uz to nikdo neresi? */
|
||||
export function isClosingStatus(status: string, closedStatuses?: string[]): boolean {
|
||||
const value = status.trim().toLowerCase();
|
||||
if (closedStatuses && closedStatuses.length > 0) {
|
||||
return closedStatuses.some((item) => item.trim().toLowerCase() === value);
|
||||
}
|
||||
return CLOSING.has(value);
|
||||
}
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
|
||||
@@ -180,14 +155,13 @@ export interface Ticket {
|
||||
/** Hodnoty vlastnich poli typu. Klic je `TicketTypeField.key`. */
|
||||
fields: Record<string, string | number | boolean | null>;
|
||||
/**
|
||||
* Uz to nikdo neresi?
|
||||
* Je vyrizeny?
|
||||
*
|
||||
* Kdyz je stav volny retezec, nejde z nej poznat, jestli je hotovo - `ringing`
|
||||
* a `completed` jsou pro nas jen texty. Fronta, vytizeni i statistiky se
|
||||
* proto ptaji na tenhle priznak, ne na stav.
|
||||
* **Nastavuje se vyslovne**, neodvozuje se ze stavu. Stav je volny retezec
|
||||
* a hadat z nej, jestli `completed` nebo `hotovo` znamena konec, by znamenalo
|
||||
* uhodnout spatne pokazde, kdyz si nekdo pojmenuje stavy po svem.
|
||||
*
|
||||
* Nastavuje se sam podle stavu (viz `isClosingStatus`), takze bezny uzivatel
|
||||
* o nem nemusi vedet.
|
||||
* Fronta, vytizeni i statistiky se ptaji na tenhle priznak.
|
||||
*/
|
||||
closed: boolean;
|
||||
/**
|
||||
@@ -853,8 +827,7 @@ function toTicket(stored: StoredTicket): Ticket {
|
||||
typeId: stored.typeId ?? null,
|
||||
fields: stored.fields ?? {},
|
||||
tags: stored.tags ?? [],
|
||||
// Starsi zaznamy priznak nemaji, dopocita se ze stavu.
|
||||
closed: stored.closed ?? isClosingStatus(stored.status),
|
||||
closed: stored.closed ?? false,
|
||||
externalId: stored.externalId ?? null,
|
||||
externalSource: stored.externalSource ?? null,
|
||||
firstResponseAt: stored.firstResponseAt ?? null,
|
||||
@@ -947,7 +920,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef
|
||||
/** Prvni nevyrizeny ticket. */
|
||||
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
|
||||
const stored = tickets.find(
|
||||
(t) => !(t.closed ?? isClosingStatus(t.status)) && tenantIds.includes(t.tenantId),
|
||||
(t) => !t.closed && tenantIds.includes(t.tenantId),
|
||||
);
|
||||
return stored ? toTicket(stored) : undefined;
|
||||
}
|
||||
@@ -980,7 +953,7 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const mine = visible.filter((t) => t.assigneeId === person.id);
|
||||
const open = mine.filter((t) => !(t.closed ?? isClosingStatus(t.status)));
|
||||
const open = mine.filter((t) => !t.closed);
|
||||
const oldest = open.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
@@ -1002,9 +975,9 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload {
|
||||
return {
|
||||
rows,
|
||||
unassigned: visible.filter(
|
||||
(t) => t.assigneeId === null && !(t.closed ?? isClosingStatus(t.status)),
|
||||
(t) => t.assigneeId === null && !t.closed,
|
||||
).length,
|
||||
openTotal: visible.filter((t) => !(t.closed ?? isClosingStatus(t.status))).length,
|
||||
openTotal: visible.filter((t) => !t.closed).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1210,7 +1183,7 @@ export function getAgentStats(
|
||||
|
||||
const rows = everyone.map((person) => {
|
||||
const open = visible.filter(
|
||||
(t) => t.assigneeId === person.id && !(t.closed ?? isClosingStatus(t.status)),
|
||||
(t) => t.assigneeId === person.id && !t.closed,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1278,6 +1251,8 @@ export interface CreateTicketInput {
|
||||
priority: TicketPriority;
|
||||
/** Vychozi stav, kdyz se nezada. Volny retezec, ne ciselnik. */
|
||||
status?: string;
|
||||
/** Je uz vyrizeny? Vychozi ne. */
|
||||
closed?: boolean;
|
||||
assigneeId?: string | null;
|
||||
assigneeGroupId?: string | null;
|
||||
typeId?: string | null;
|
||||
@@ -1323,7 +1298,7 @@ export function createTicket(input: CreateTicketInput): Ticket {
|
||||
typeId: input.typeId ?? null,
|
||||
fields: input.fields ?? {},
|
||||
tags: input.tags ?? [],
|
||||
closed: isClosingStatus(input.status ?? defaultStatuses[0]),
|
||||
closed: input.closed ?? false,
|
||||
automationId: input.automationId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -1356,17 +1331,18 @@ function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmeni stav.
|
||||
* Zmeni stav, pripadne i priznak vyrizeni.
|
||||
*
|
||||
* Stav je **volny retezec**, takze se neoveruje proti ciselniku. Kdyz typ
|
||||
* ticketu ma vlastni workflow, `closedStatuses` rekne, ktere z nich znamenaji
|
||||
* hotovo - jinak se to pozna podle bezneho pojmenovani (`vyreseno`, `completed`).
|
||||
* Stav je **volny retezec** a neoveruje se proti nicemu. Jestli je ticket
|
||||
* vyrizeny, rika `closed` - vyslovne, ne odvozene ze jmena stavu. Kdyz se
|
||||
* nepreda, priznak zustava, jak byl: zmena textu stavu sama o sobe neznamena,
|
||||
* ze je hotovo.
|
||||
*/
|
||||
export function updateTicketStatus(
|
||||
id: string,
|
||||
status: TicketStatus,
|
||||
tenantIds: string[],
|
||||
closedStatuses?: string[],
|
||||
closed?: boolean,
|
||||
): Ticket | undefined {
|
||||
const ticket = findWritable(id, tenantIds);
|
||||
if (!ticket) {
|
||||
@@ -1375,15 +1351,16 @@ export function updateTicketStatus(
|
||||
}
|
||||
|
||||
const previous = ticket.status;
|
||||
const wasClosed = ticket.closed ?? false;
|
||||
ticket.status = status;
|
||||
ticket.closed = isClosingStatus(status, closedStatuses);
|
||||
if (closed !== undefined) ticket.closed = closed;
|
||||
|
||||
if (ticket.closed) {
|
||||
if (ticket.closed && !wasClosed) {
|
||||
ticket.resolvedAt = new Date().toISOString();
|
||||
// Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane -
|
||||
// radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel.
|
||||
ticket.resolvedById = ticket.assigneeId;
|
||||
} else if (isClosingStatus(previous, closedStatuses)) {
|
||||
} else if (!ticket.closed && wasClosed) {
|
||||
// Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo.
|
||||
ticket.reopenCount = (ticket.reopenCount ?? 0) + 1;
|
||||
ticket.resolvedAt = null;
|
||||
|
||||
@@ -44,13 +44,7 @@ export interface TicketType extends TenantEntity {
|
||||
* i stav, ktery poslala cizi aplikace a v seznamu neni.
|
||||
*/
|
||||
statuses: string[];
|
||||
/**
|
||||
* Ktere z tech stavu znamenaji, ze uz to nikdo neresi.
|
||||
*
|
||||
* Fronta a statistiky se ptaji na tohle, ne na text stavu. Prazdne = pozna
|
||||
* se to podle bezneho pojmenovani (`vyreseno`, `hotovo`, `completed`).
|
||||
*/
|
||||
closedStatuses?: string[];
|
||||
|
||||
fields: TicketTypeField[];
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,10 @@ import { listNotifications, markRead, unreadCount } from '../data/notifications.
|
||||
import { queueStats, recentRuns } from '../runtime/queue.js';
|
||||
import { recordAudit } from '../data/audit.js';
|
||||
import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { listTicketTypes } from '../data/ticketTypes.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
findTicket,
|
||||
getAgentStats,
|
||||
getTicket,
|
||||
getWorkload,
|
||||
@@ -608,6 +607,11 @@ const statusSchema = z.object({
|
||||
* nesmysl. Omezena je jen delka, aby se do pole nedal cely dokument.
|
||||
*/
|
||||
status: z.string().trim().min(1).max(60),
|
||||
/**
|
||||
* Je vyrizeny? Kdyz se neposle, priznak zustava - zmena textu stavu sama
|
||||
* o sobe neznamena, ze je hotovo.
|
||||
*/
|
||||
closed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
@@ -616,16 +620,11 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
|
||||
}
|
||||
|
||||
// Typ rika, ktere z jeho stavu znamenaji hotovo. Bez nej se to pozna
|
||||
// podle bezneho pojmenovani (vyreseno, completed).
|
||||
const current = findTicket(req.params.id, writableTenants(req));
|
||||
const type = current?.typeId ? findTicketType(current.typeId) : undefined;
|
||||
|
||||
const ticket = updateTicketStatus(
|
||||
req.params.id,
|
||||
parsed.data.status,
|
||||
writableTenants(req),
|
||||
type?.closedStatuses,
|
||||
parsed.data.closed,
|
||||
);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
|
||||
+34
-11
@@ -11,7 +11,6 @@
|
||||
|
||||
import { createIncident } from '../data/incidentStore.js';
|
||||
import { findGroup, findPerson, findPersonByExternalId, listPeople } from '../data/people.js';
|
||||
import { findTicketType } from '../data/ticketTypes.js';
|
||||
import {
|
||||
assignTicket,
|
||||
assignTicketGroup,
|
||||
@@ -63,6 +62,21 @@ const channels: TicketChannel[] = [
|
||||
'portal',
|
||||
];
|
||||
|
||||
/**
|
||||
* Zaskrtnuto, nebo ne?
|
||||
*
|
||||
* Ve strome se vsechno predava jako text, takze prijde "true", "1" nebo
|
||||
* prazdno. Nevyplneno znamena "nemenit", ne "false" - jinak by kazdy krok,
|
||||
* ktery meni stav, mimochodem otevrel vyrizeny ticket.
|
||||
*/
|
||||
function boolInput(value: string | undefined): boolean | undefined {
|
||||
const text = value?.trim().toLowerCase();
|
||||
if (!text) return undefined;
|
||||
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
|
||||
if (['false', '0', 'ne', 'no'].includes(text)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Z JSONu nechá jen hodnoty, ktere se do vlastniho pole vejdou.
|
||||
*
|
||||
@@ -147,17 +161,23 @@ const handlers: Record<string, Handler> = {
|
||||
});
|
||||
|
||||
/*
|
||||
* Stav se nastavuje az po zalozeni, aby se dohledal typ a jeho workflow.
|
||||
* Je to **volny retezec**: co posle cizi aplikace, to se ulozi. Podle
|
||||
* `closedStatuses` typu se pozna, jestli to znamena hotovo.
|
||||
* Stav je **volny retezec**: co posle cizi aplikace, to se ulozi. Jestli
|
||||
* je ticket vyrizeny, rika `closed` - vyslovne. Bez nej se priznak nemeni,
|
||||
* takze zmena textu stavu sama o sobe ticket nezavre ani neotevre.
|
||||
*/
|
||||
const wanted = inputs.status?.trim();
|
||||
const closed = boolInput(inputs.closed);
|
||||
let note = '';
|
||||
|
||||
if (wanted) {
|
||||
const type = result.ticket.typeId ? findTicketType(result.ticket.typeId) : undefined;
|
||||
updateTicketStatus(result.ticket.id, wanted, [context.tenantId], type?.closedStatuses);
|
||||
note = `, stav ${wanted}`;
|
||||
if (wanted || closed !== undefined) {
|
||||
updateTicketStatus(
|
||||
result.ticket.id,
|
||||
wanted || result.ticket.status,
|
||||
[context.tenantId],
|
||||
closed,
|
||||
);
|
||||
note = wanted ? `, stav ${wanted}` : '';
|
||||
if (closed !== undefined) note += closed ? ', vyřízený' : ', nevyřízený';
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -345,9 +365,12 @@ const handlers: Record<string, Handler> = {
|
||||
const status = inputs.status?.trim();
|
||||
if (!status) return missing('stav');
|
||||
|
||||
const ticket = findTicket(ticketId, [context.tenantId]);
|
||||
const type = ticket?.typeId ? findTicketType(ticket.typeId) : undefined;
|
||||
const updated = updateTicketStatus(ticketId, status, [context.tenantId], type?.closedStatuses);
|
||||
const updated = updateTicketStatus(
|
||||
ticketId,
|
||||
status,
|
||||
[context.tenantId],
|
||||
boolInput(inputs.closed),
|
||||
);
|
||||
if (!updated) return { ok: false, summary: 'stav se nepodařilo změnit', outputs: {} };
|
||||
return { ok: true, summary: status, detail: null, outputs: { status } };
|
||||
},
|
||||
|
||||
@@ -166,6 +166,8 @@ export function ticketPayload(ticket: Ticket): Record<string, unknown> {
|
||||
subject: ticket.subject,
|
||||
body: ticket.body,
|
||||
status: ticket.status,
|
||||
/** Aby slo napsat podminku "uz je vyrizeny". */
|
||||
closed: ticket.closed,
|
||||
priority: ticket.priority,
|
||||
channel: ticket.channel,
|
||||
typeId: ticket.typeId,
|
||||
|
||||
Reference in New Issue
Block a user