diff --git a/README.md b/README.md
index 7258b53..b4f9904 100644
--- a/README.md
+++ b/README.md
@@ -44,11 +44,12 @@ Aplikace sestává ze tří (čtyř) modulů.
- [x] Implementovat Pizza day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- - [ ] Automatické generování a zobrazení QR kódů
- - [ ] https://qr-platba.cz/pro-vyvojare/restful-api/
+ - [x] Automatické generování a zobrazení QR kódů
+ - [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [ ] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Umožnit přidat k objednávce poznámku (např. "bez oliv")
+ - [ ] Negenerovat QR kód pro objednávajícího
- [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku)
- [ ] Vylepšit dokumentaci projektu
@@ -59,7 +60,7 @@ Aplikace sestává ze tří (čtyř) modulů.
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
-- [ ] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
+- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek)
diff --git a/client/src/Api.ts b/client/src/Api.ts
index 2c3782b..e013d2a 100644
--- a/client/src/Api.ts
+++ b/client/src/Api.ts
@@ -18,6 +18,10 @@ const api = {
post:
(url: string, body: TBody) => request(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
+export const getQrUrl = (login: string) => {
+ return `${getBaseUrl()}/api/qr?login=${login}`;
+}
+
export const getData = async () => {
return await api.get('/api/data');
}
@@ -50,8 +54,8 @@ export const finishOrder = async (login) => {
return await api.post('/api/finishOrder', JSON.stringify({ login }));
}
-export const finishDelivery = async (login) => {
- return await api.post('/api/finishDelivery', JSON.stringify({ login }));
+export const finishDelivery = async (login, bankAccount, bankAccountHolder) => {
+ return await api.post('/api/finishDelivery', JSON.stringify({ login, bankAccount, bankAccountHolder }));
}
export const updateChoice = async (name: string, choice: number | null) => {
diff --git a/client/src/App.css b/client/src/App.css
index a3e12bb..2642021 100644
--- a/client/src/App.css
+++ b/client/src/App.css
@@ -71,4 +71,8 @@
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
+}
+
+.qr-code {
+ text-align: center;
}
\ No newline at end of file
diff --git a/client/src/App.tsx b/client/src/App.tsx
index f8cad23..7e9d3b6 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,10 +1,10 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
-import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } from './Api';
+import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, getQrUrl, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } from './Api';
import { useAuth } from './context/auth';
import Login from './Login';
-import { Locations, ClientData, Pizza, PizzaOrder, State } from './Types';
+import { Locations, ClientData, Pizza, PizzaOrder, State, Order } from './Types';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
@@ -14,16 +14,19 @@ import 'react-select-search/style.css';
import './App.css';
import { SelectSearchOption } from 'react-select-search';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
+import { useBank } from './context/bank';
const EVENT_CONNECT = "connect"
function App() {
const auth = useAuth();
+ const bank = useBank();
const [isConnected, setIsConnected] = useState(false);
const [data, setData] = useState();
const [food, setFood] = useState();
const [pizzy, setPizzy] = useState();
+ const [myOrder, setMyOrder] = useState();
const socket = useContext(SocketContext);
const choiceRef = useRef(null);
@@ -76,6 +79,16 @@ function App() {
}
}, [auth, auth?.login, data?.choices])
+ // Reference na mojí objednávku
+ useEffect(() => {
+ if (data?.pizzaDay?.orders) {
+ const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login);
+ if (myOrder) {
+ setMyOrder(myOrder);
+ }
+ }
+ }, [data?.pizzaDay?.orders])
+
const changeChoice = async (event: React.ChangeEvent) => {
const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations);
if (auth?.login) {
@@ -183,10 +196,8 @@ function App() {
Poslední změny:
- Žárovka praskla a vyhodila hlavní jistič
- Už jsem jí vyměnil
- Dobrá práce
- Aplikace zatím nic nového neumí
+ Nová žárovka zatím funguje
+ Funkční generování a zobrazení QR kódů pro Pizza day
Dnes je {data.date}
@@ -288,7 +299,7 @@ function App() {
await lockPizzaDay(auth.login);
}}>Vrátit do "uzamčeno"
{
- await finishDelivery(auth.login);
+ await finishDelivery(auth.login, bank?.bankAccount, bank?.holderName);
}}>Doručeno
}
@@ -297,7 +308,7 @@ function App() {
{
data.pizzaDay.state === State.DELIVERED &&
-
Pizzy byly doručeny.
+
Pizzy byly doručeny. Objednávku můžete uhradit pomocí QR kódu níže.
}
@@ -309,6 +320,15 @@ function App() {
disabled={data.pizzaDay.state !== State.CREATED}
/>
+ {
+ data.pizzaDay.state === State.DELIVERED && myOrder &&
+
+
QR platba
+
Částka: {myOrder.totalPrice} Kč
+
+
Generování QR kódů je v experimentální fázi - doporučujeme si překontrolovat údaje před odesláním platby.
+
+ }
}
diff --git a/server/src/chefie.ts b/server/src/chefie.ts
index 059976e..a9932c0 100644
--- a/server/src/chefie.ts
+++ b/server/src/chefie.ts
@@ -3,6 +3,7 @@ import os from 'os';
import path from 'path';
import fs from 'fs';
import axios from 'axios';
+import { formatDate } from './utils';
type PizzaSize = {
varId: number,
@@ -85,10 +86,7 @@ const downloadPizzy = async () => {
export const fetchPizzy = async (): Promise => {
const tmpDir = os.tmpdir();
const date_ob = new Date();
- const date = ("0" + date_ob.getDate()).slice(-2);
- const month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
- const year = date_ob.getFullYear();
- const dateStr = year + "-" + month + "-" + date;
+ const dateStr = formatDate(date_ob);
const dataPath = path.join(tmpDir, `chefie-${dateStr}.json`);
if (fs.existsSync(dataPath)) {
diff --git a/server/src/index.ts b/server/src/index.ts
index 37e74c6..03ef1cb 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -7,6 +7,7 @@ import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, fin
import dotenv from 'dotenv';
import path from 'path';
import { fetchMenus } from "./restaurants";
+import { getQr } from "./qr";
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -134,7 +135,7 @@ app.post("/api/finishDelivery", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
- const data = finishPizzaDelivery(req.body.login);
+ const data = finishPizzaDelivery(req.body.login, req.body.bankAccount, req.body.bankAccountHolder);
io.emit("message", data);
res.status(200).json({});
});
@@ -148,6 +149,18 @@ app.post("/api/updateChoice", (req, res) => {
res.status(200).json(data);
});
+app.get("/api/qr", (req, res) => {
+ if (!req.query?.login || typeof req.query.login !== 'string') {
+ throw Error("Nebyl předán login");
+ }
+ const img = getQr(req.query.login);
+ res.writeHead(200, {
+ 'Content-Type': 'image/png',
+ 'Content-Length': img.length
+ });
+ res.end(img);
+});
+
io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`);
diff --git a/server/src/qr.ts b/server/src/qr.ts
new file mode 100644
index 0000000..2493bc1
--- /dev/null
+++ b/server/src/qr.ts
@@ -0,0 +1,98 @@
+import fs from "fs";
+import axios from "axios";
+import os from "os";
+import path from "path";
+import crypto from "crypto";
+import { formatDate } from "./utils";
+
+const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
+const COUNTRY_CODE = 'CZ';
+const CURRENCY_CODE = 'CZK';
+const QR_PIXEL_SIZE = 256;
+const tmpDir = os.tmpdir();
+
+/**
+ * Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
+ *
+ * @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
+ */
+function convertBbanToIban(bankAccountNumber: string): string {
+ // TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl
+ let prefix: string = '';
+ let accountNumber: string = bankAccountNumber;
+ if (bankAccountNumber.indexOf('-') >= 0) {
+ const split = bankAccountNumber.split('-');
+ prefix = split[0];
+ accountNumber = split[1];
+ }
+ prefix = prefix.padStart(6, '0');
+ const split = accountNumber.split('/');
+ accountNumber = split[0].padStart(10, '0');
+ const bankCode = split[1].padStart(4, '0');
+ let iban = `${bankCode}${prefix}${accountNumber}${COUNTRY_CODE}00`;
+ // Zatím napevno, nemá smysl řešit nic jiného než CZ
+ iban = iban.replace('C', '12').replace('Z', '35');
+ const remainder = BigInt(iban) % BigInt(97);
+ const checkDigits = BigInt(98) - remainder;
+ iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
+ if (iban.length !== 24) {
+ throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
+ }
+ return iban;
+}
+
+function createNameHash(customerName: string): string {
+ return crypto.createHash('md5').update(customerName).digest('hex');
+}
+
+function createFilePath(nameHash: string): string {
+ const fileName = `${formatDate(new Date())}_${nameHash}.png`;
+ return path.join(tmpDir, fileName);
+}
+
+/**
+ * Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry.
+ *
+ * @param customerName jméno uživatele, pro kterého je QR kód generován
+ * @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
+ * @param bankAccountHolder jméno držitele cílového bankovního účtu
+ * @param amount částka v Kč
+ * @param message zpráva pro příjemce
+ * @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek
+ */
+export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise {
+ // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
+ if (message.indexOf('*') >= 0) {
+ message = message.replace('*', '');
+ }
+ if (message.length > 60) {
+ message = message.substring(0, 60);
+ }
+ const payload = {
+ iban: convertBbanToIban(bankAccountNumber),
+ amount,
+ currency: CURRENCY_CODE,
+ message,
+ recipientName: bankAccountHolder,
+ branding: false,
+ compress: false,
+ size: QR_PIXEL_SIZE,
+ }
+ const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } });
+ // Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele
+ const nameHash = createNameHash(customerName);
+ const imgPath = createFilePath(nameHash);
+ response.data.pipe(fs.createWriteStream(imgPath));
+ return nameHash;
+}
+
+/**
+ * Vrátí obrázek s QR kódem, pokud existuje.
+ *
+ * @param customerName jméno uživatele
+ * @returns data obrázku
+ */
+export function getQr(customerName: string): Buffer {
+ const imgPath = createFilePath(createNameHash(customerName));
+ return fs.readFileSync(imgPath);
+}
\ No newline at end of file
diff --git a/server/src/service.ts b/server/src/service.ts
index 921cb12..88ed6b3 100644
--- a/server/src/service.ts
+++ b/server/src/service.ts
@@ -1,7 +1,8 @@
-import {ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum} from "./types";
-import {db} from "./database";
-import {formatDate, getHumanDate, getIsWeekend} from "./utils";
-import {callNotifikace} from "./notifikace";
+import { ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum } from "./types";
+import { db } from "./database";
+import { formatDate, getHumanDate, getIsWeekend } from "./utils";
+import { callNotifikace } from "./notifikace";
+import { generateQr } from "./qr";
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
function getToday(): Date {
@@ -191,17 +192,18 @@ export function finishPizzaOrder(login: string) {
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
db.set(today, clientData);
- callNotifikace({input:{udalost:UdalostEnum.OBJEDNANA_PIZZA,user:clientData?.pizzaDay?.creator}})
+ callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
+ * Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
-export function finishPizzaDelivery(login: string) {
+export function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
@@ -214,6 +216,15 @@ export function finishPizzaDelivery(login: string) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
+
+ // Vygenerujeme QR kód, pokud k tomu máme data
+ if (bankAccount?.length && bankAccountHolder?.length) {
+ for (const order of clientData.pizzaDay.orders) {
+ let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
+ const price = order.pizzaList.map(pizza => pizza.price).reduce((partial, a) => partial + a, 0);
+ generateQr(order.customer, bankAccount, bankAccountHolder, price, message).then(() => order.hasQr = true);
+ }
+ }
db.set(today, clientData);
return clientData;
}
diff --git a/server/src/types.ts b/server/src/types.ts
index 0671901..2bbc844 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -33,6 +33,7 @@ export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
+ hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
}
/** Stav pizza dne */