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:
JiriUhlir
2026-08-14 07:38:20 +02:00
co-authored by Claude Opus 5
parent 202d525905
commit fbe5b8ff6f
7 changed files with 118 additions and 84 deletions
+22
View File
@@ -2,6 +2,28 @@
Nejnovejsi nahore.
## 2026-08-13 - vyrizeno je vyslovny priznak, ne hadani ze stavu
### Zmeneno
- **`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**
(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.
- Podminka se muze zeptat na `closed`, takze jde napsat "kdyz je vyrizeny".
### Overeno
8 kontrol: ticket se stavem `completed` **neni** automaticky vyrizeny, dokud
to nekdo nerekne. Automatizace s podminkou `status = completed` zabere na
ticketu, ktery do toho stavu prejde, prida stitek a nastavi vyrizeno. Na uz
existujici tickety nesahne, dokud se s nimi neco nestane - spousti ji udalost,
ne stav.
## 2026-08-13 - stav ticketu je volny retezec, ciselnik pryc
### Zmeneno
+27 -10
View File
@@ -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
View File
@@ -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;
+1 -7
View File
@@ -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[];
}
+7 -8
View File
@@ -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
View File
@@ -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 } };
},
+2
View File
@@ -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,