feat: nový stav "Doručeno" pro objednávky
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 1s

This commit is contained in:
2026-09-07 11:30:40 +02:00
parent ecc28ee82d
commit b6834cb7aa
6 changed files with 162 additions and 25 deletions
+36 -10
View File
@@ -29,6 +29,26 @@ function findGroup(data: ClientData, id: string): OrderGroup | undefined {
return data.groups?.find(g => g.id === id);
}
/** České názvy stavů pro chybová hlášení. */
const STATE_LABEL: Record<GroupState, string> = {
[GroupState.OPEN]: 'otevřeno',
[GroupState.LOCKED]: 'uzamčeno',
[GroupState.ORDERED]: 'objednáno',
[GroupState.DELIVERED]: 'doručeno',
};
/** Stavy, ve kterých je objednávka již odeslána do podniku a nelze ji tedy upravovat. */
function isSubmitted(state: GroupState): boolean {
return state === GroupState.ORDERED || state === GroupState.DELIVERED;
}
/** Ověří, že skupina není v odeslaném stavu (objednáno/doručeno), jinak vyhodí chybu. */
function assertNotSubmitted(group: OrderGroup): void {
if (isSubmitted(group.state)) {
throw new Error(`Skupinu ve stavu "${STATE_LABEL[group.state]}" nelze upravovat`);
}
}
/**
* Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina.
* Slouží ke zvýraznění dnů v date pickeru na stránce objednávání.
@@ -100,7 +120,7 @@ export async function addGroupMember(login: string, groupId: string, targetLogin
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
assertNotSubmitted(group);
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Přidat jiného uživatele může pouze zakladatel');
}
@@ -116,7 +136,7 @@ export async function removeGroupMember(login: string, groupId: string, targetLo
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
assertNotSubmitted(group);
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
}
@@ -133,7 +153,7 @@ export async function updateGroupMember(login: string, groupId: string, targetLo
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
assertNotSubmitted(group);
const isSelf = login === targetLogin;
const isCreator = login === group.creatorLogin;
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
@@ -148,7 +168,8 @@ export async function updateGroupMember(login: string, groupId: string, targetLo
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
[GroupState.ORDERED]: [GroupState.LOCKED],
[GroupState.ORDERED]: [GroupState.LOCKED, GroupState.DELIVERED],
[GroupState.DELIVERED]: [GroupState.ORDERED],
};
function getCurrentHHMM(): string {
@@ -162,7 +183,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
throw new Error(`Nelze přejít ze stavu "${STATE_LABEL[group.state]}" do stavu "${STATE_LABEL[newState]}"`);
}
if (newState === GroupState.ORDERED) {
group.orderedAt = getCurrentHHMM();
@@ -185,14 +206,19 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
return saveExtraData(data, date);
}
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
/**
* Označí skupinu za vygenerovanou (blokuje opakované generování QR) a zároveň ji
* překlopí do stavu "doručeno" — generování QR je poslední krok objednávky.
*/
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('QR kódy může generovat pouze zakladatel');
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
if (!isSubmitted(group.state)) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno" nebo "doručeno"');
group.qrGenerated = true;
await saveExtraData(data, date);
group.state = GroupState.DELIVERED;
return saveExtraData(data, date);
}
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
@@ -208,7 +234,7 @@ export async function updateGroupFees(login: string, groupId: string, fees?: num
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Poplatky může měnit pouze zakladatel');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
assertNotSubmitted(group);
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
@@ -238,7 +264,7 @@ export async function setGroupTracking(login: string, groupId: string, shareUrl?
group.trackingOrderState = undefined;
group.trackingCourierState = undefined;
} else {
if (group.state !== GroupState.ORDERED) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno"');
if (!isSubmitted(group.state)) throw new Error('Sledování objednávky lze nastavit pouze ve stavu "objednáno" nebo "doručeno"');
const tracking = extractTracking(shareUrl);
if (!tracking) throw new Error('Neplatný odkaz na sledování objednávky');
if (tracking.code !== group.trackingCode || tracking.provider !== group.trackingProvider) {
+4 -2
View File
@@ -4,7 +4,7 @@ import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr";
import { addPendingQr } from "../pizza";
import { markGroupQrGenerated } from "../groups";
import { emitToUser } from "../websocket";
import { emitToUser, getWebsocket } from "../websocket";
import { GenerateQrData } from "../../../types";
import crypto from "crypto";
@@ -59,7 +59,9 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
}
if (groupId) {
await markGroupQrGenerated(login, groupId);
// Skupina se zároveň překlopí do stavu "doručeno" — rozešleme nová data všem
const data = await markGroupQrGenerated(login, groupId);
getWebsocket().emit("message", data);
}
res.status(200).json({ success: true, count: recipients.length });
+74 -2
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory';
import { getStores, addStore } from '../stores';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupTracking, markGroupMemberPaid } from '../groups';
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, setGroupTracking, markGroupMemberPaid, markGroupQrGenerated } from '../groups';
import { GroupState } from '../../../types/gen/types.gen';
const CREATOR = 'tomas';
@@ -219,12 +219,48 @@ describe('setGroupState', () => {
await expect(setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY)).rejects.toThrow('Nelze přejít');
});
test('ordered je terminální stav', async () => {
test('ordered → open není povoleno', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await expect(setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY)).rejects.toThrow('Nelze přejít');
});
test('ordered → delivered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY);
expect(d.groups![0].state).toBe(GroupState.DELIVERED);
});
test('delivered → ordered (vrácení)', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY);
const d = await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
expect(d.groups![0].state).toBe(GroupState.ORDERED);
});
test('locked → delivered není povoleno', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await expect(setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY)).rejects.toThrow('Nelze přejít');
});
test('delivered → locked není povoleno', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY);
await expect(setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('Nelze přejít');
});
test('skupinu ve stavu delivered nelze upravovat', async () => {
await addGroupMember(CREATOR, groupId, USER, TODAY);
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY);
await expect(updateGroupMember(CREATOR, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('doručeno');
await expect(addGroupMember(CREATOR, groupId, 'dalsi', TODAY)).rejects.toThrow('doručeno');
});
test('nečlen nemůže měnit stav', async () => {
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
});
@@ -240,6 +276,42 @@ describe('setGroupState', () => {
});
});
describe('markGroupQrGenerated', () => {
let groupId: string;
beforeEach(async () => {
const d = await createGroup(CREATOR, STORE, TODAY);
groupId = d.groups![0].id;
});
test('ze stavu ordered přepne skupinu na delivered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
const d = await markGroupQrGenerated(CREATOR, groupId, TODAY);
expect(d.groups![0].state).toBe(GroupState.DELIVERED);
expect(d.groups![0].qrGenerated).toBe(true);
});
test('funguje i ve stavu delivered', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.DELIVERED, TODAY);
const d = await markGroupQrGenerated(CREATOR, groupId, TODAY);
expect(d.groups![0].state).toBe(GroupState.DELIVERED);
});
test('ve stavu locked selže', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await expect(markGroupQrGenerated(CREATOR, groupId, TODAY)).rejects.toThrow('objednáno');
});
test('QR může generovat pouze zakladatel', async () => {
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
await expect(markGroupQrGenerated(USER, groupId, TODAY)).rejects.toThrow('zakladatel');
});
});
describe('markGroupMemberPaid', () => {
test('označí člena jako zaplaceného pro daný den', async () => {
const d = await createGroup(CREATOR, STORE, TODAY);