diff --git a/public/shop-tab-artikel.js b/public/shop-tab-artikel.js index 792ad5a..3c468bb 100644 --- a/public/shop-tab-artikel.js +++ b/public/shop-tab-artikel.js @@ -103,6 +103,12 @@ function bestandAnzeige(p) { return Number.isInteger(p?.bestand) ? stueck(p.bestand) : 'unbegrenzt' } +/** + * Der Bestand, den der Haendler beim Laden gesehen hat (bestandGeladen am Artikel und je Variantenwert mit id). Der Server schreibt + * einen Bestand nur, wenn er sich davon unterscheidet; sonst bleibt der aktuelle Stand (zwischenzeitliche Verkaeufe gehen nicht verloren). + */ +const bestandOderNull = (wert) => (Number.isInteger(wert) ? wert : null) + /** Der Artikel in der Form, die der Server beim Speichern erwartet (aus der Listenansicht). */ function produktKoerper(p, ueberschreibe = {}) { return { @@ -114,8 +120,11 @@ function produktKoerper(p, ueberschreibe = {}) { steuersatz: p.steuersatz, grundpreis: p.grundpreis ?? null, bestand: p.bestand ?? null, + bestandGeladen: bestandOderNull(p.bestand), aktiv: p.aktiv, - varianten: p.varianten ?? null, + varianten: istObjekt(p.varianten) && Array.isArray(p.varianten.werte) + ? { ...p.varianten, werte: p.varianten.werte.map((w) => (typeof w?.id === 'string' ? { ...w, bestandGeladen: bestandOderNull(w.bestand) } : w)) } + : (p.varianten ?? null), bilder: Array.isArray(p.bilder) ? p.bilder : [], lieferart: p.lieferart, widerruf: p.widerruf, @@ -298,7 +307,7 @@ export async function render(panel, ctx) { const variantenAn = h('input', { type: 'checkbox', checked: hatVarianten }) const variantenName = kurzesFeld(hatVarianten ? text(p.varianten.name) : '') const werte = hatVarianten - ? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '' })) + ? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '', geladen: bestandOderNull(w.bestand) })) : [] const werteBox = h('div', { class: 'sh-werte' }) const wertNeuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-neu', onclick: () => { werte.push({ id: undefined, name: '', preis: '', bestand: '' }); zeichneWerte() } }, 'Wert hinzufügen') @@ -450,9 +459,20 @@ export async function render(panel, ctx) { steuersatz: stand.kleinunternehmer ? 0 : Number(steuer.value), grundpreis: gpAn ? { menge: /^\d+([.,]\d+)?$/.test(menge) ? Number(menge.replace(',', '.')) : menge, einheit: gpEinheit.value } : null, bestand: mitVarianten ? null : ganzeZahl(bestand.value), + // Beim Bearbeiten: der beim Laden gesehene Bestand (der Server behaelt den aktuellen Stand, wenn er nicht geaendert wurde) + ...(id ? { bestandGeladen: bestandOderNull(p.bestand) } : {}), aktiv: aktivHaken.checked, varianten: mitVarianten - ? { name: variantenName.value, werte: werte.map((w) => ({ id: w.id, name: w.name, preisCent: euroZuCent(String(w.preis)), bestand: ganzeZahl(w.bestand) })) } + ? { + name: variantenName.value, + werte: werte.map((w) => ({ + id: w.id, + name: w.name, + preisCent: euroZuCent(String(w.preis)), + bestand: ganzeZahl(w.bestand), + ...(id && typeof w.id === 'string' ? { bestandGeladen: w.geladen } : {}), + })), + } : null, bilder: bilder.map((b) => b.id), lieferart: lieferart.value, diff --git a/server/routes/shop.js b/server/routes/shop.js index d2c46c5..3460a0f 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -15,7 +15,7 @@ import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' import { - bildIdsInGebrauch, holeProdukt, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, + bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, } from '../services/shop/produkte.js' import { BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, @@ -333,7 +333,7 @@ async function speichereArtikel(req, res, id) { const meta = await bildMeta(shop.siteKey, bildId) if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' }) } - const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt) + const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt, { geladen: leseBestandGeladen(req.body?.produkt) }) await beanspruche(shop.siteKey, neueBilder) await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b))) return gespeichert diff --git a/server/services/shop/produkte.js b/server/services/shop/produkte.js index 9ab166e..d6497c3 100644 --- a/server/services/shop/produkte.js +++ b/server/services/shop/produkte.js @@ -424,19 +424,74 @@ export async function holeProdukt(shopId, id) { return doc && doc.shopId === shopId ? doc : null } +const bestandRoh = (wert) => { + if (wert === null || wert === undefined || (typeof wert === 'string' && wert.trim() === '')) return { ok: true, wert: null } + const n = ganzeZahl(wert) + return n !== null && n >= 0 ? { ok: true, wert: n } : { ok: false } +} + +/** + * Liest aus dem rohen Artikel des Browsers, welchen Bestand er beim Laden gesehen hat (bestandGeladen am Artikel, je Variantenwert + * mit id am Wert). hat: false, wenn der Browser nichts (Brauchbares) gesendet hat. Das Ergebnis geht an speichereProdukt. + * @returns {{ hat: boolean, bestand?: number|null, werte: Map }} + */ +export function leseBestandGeladen(roh) { + const werte = new Map() + const liste = hole(hole(roh, 'varianten'), 'werte') + if (Array.isArray(liste)) { + for (const w of liste) { + const id = hole(w, 'id') + if (typeof id !== 'string' || !WERT_ID_RE.test(id) || !Object.hasOwn(w, 'bestandGeladen')) continue + const b = bestandRoh(w.bestandGeladen) + if (b.ok) werte.set(id, b.wert) + } + } + if (!istObjekt(roh) || !Object.hasOwn(roh, 'bestandGeladen')) return { hat: false, werte } + const b = bestandRoh(roh.bestandGeladen) + return b.ok ? { hat: true, bestand: b.wert, werte } : { hat: false, werte } +} + +/** Bestand uebernehmen: hat der Haendler den Wert nicht angefasst (gesendet == geladen), bleibt der aktuell gespeicherte Stand. */ +function uebernehmeBestand(neu, alt, geladen) { + if (!istObjekt(alt) || !istObjekt(geladen)) return neu + const kopie = { ...neu } + if (geladen.hat && !neu.varianten && !alt.varianten && (neu.bestand ?? null) === geladen.bestand) kopie.bestand = alt.bestand ?? null + if (neu.varianten && Array.isArray(neu.varianten.werte) && alt.varianten && Array.isArray(alt.varianten.werte) && geladen.werte instanceof Map) { + const aktuell = new Map(alt.varianten.werte.map((w) => [w.id, w])) + kopie.varianten = { + ...neu.varianten, + werte: neu.varianten.werte.map((w) => { + const vorher = aktuell.get(w.id) + return vorher && geladen.werte.has(w.id) && (w.bestand ?? null) === geladen.werte.get(w.id) ? { ...w, bestand: vorher.bestand ?? null } : w + }), + } + } + return kopie +} + /** * Legt einen Artikel an (id = null, ans Ende sortiert, hoechstens config.shop.maxArtikel) oder aktualisiert ihn (Sortierung bleibt). * produkt ist das Ergebnis von normalisiereProdukt. Fehler tragen .status (400/404/409) und einen deutschen Text. + * opt.geladen (leseBestandGeladen): Beim Aendern behalten Bestaende, die der Haendler nicht angefasst hat (gesendet == geladen), + * den aktuell gespeicherten Stand, damit zwischenzeitliche Verkaeufe nicht ueberschrieben werden. Ohne geladen gilt der gesendete Bestand. */ -export async function speichereProdukt({ shopId, customerId }, id, produkt) { +export async function speichereProdukt({ shopId, customerId }, id, produkt, { geladen } = {}) { if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.') - const json = JSON.stringify(produkt) - if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + const pruefeGroesse = (obj) => { + const json = JSON.stringify(obj) + if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.') + return json + } + const json = pruefeGroesse(produkt) const daten = { name: produkt.name, aktiv: produkt.aktiv !== false, produkt: json } return withLocks([sperre(shopId)], async () => { if (id) { const vorhanden = await holeProdukt(shopId, id) if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).') + if (geladen) { + const zusammen = uebernehmeBestand(produkt, parseProdukt(vorhanden), geladen) + return updateDocument(col(), id, { ...daten, produkt: pruefeGroesse(zusammen) }) + } return updateDocument(col(), id, daten) } const alle = await listeProdukte(shopId) diff --git a/test/shopProdukte.test.js b/test/shopProdukte.test.js index 9671070..f07f189 100644 --- a/test/shopProdukte.test.js +++ b/test/shopProdukte.test.js @@ -396,3 +396,73 @@ test('parallele Anlage vergibt verschiedene sort-Werte', async () => { const docs = await Promise.all(Array.from({ length: 5 }, (_, i) => speichereProdukt(shop, null, ok(basis({ name: `P${i}` }))))) assert.deepEqual(docs.map((d) => d.sort).sort(), [0, 1, 2, 3, 4]) }) + +// ---- Bestand beim Bearbeiten (Review A, I3): nur ein vom Haendler geaenderter Bestand wird geschrieben ------------------ + +const bestandVon = async (id) => JSON.parse((await holeProdukt('shop-a', id)).produkt) + +async function mitGeladen(id, roh, normalisiert = ok(roh)) { + return speichereProdukt(SHOP, id, normalisiert, { geladen: P.leseBestandGeladen(roh) }) +} + +test('leseBestandGeladen: Zahl, null und leer werden gelesen, Unbrauchbares gilt als nicht gesendet', () => { + assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: 5 }), { hat: true, bestand: 5, werte: new Map() }) + assert.deepEqual(P.leseBestandGeladen({ bestandGeladen: null }), { hat: true, bestand: null, werte: new Map() }) + assert.equal(P.leseBestandGeladen({ bestandGeladen: '3' }).bestand, 3) + assert.equal(P.leseBestandGeladen({ bestandGeladen: '' }).bestand, null) + assert.equal(P.leseBestandGeladen({ bestandGeladen: 'abc' }).hat, false) + assert.equal(P.leseBestandGeladen({}).hat, false) + assert.equal(P.leseBestandGeladen(null).hat, false) + const w = P.leseBestandGeladen({ varianten: { werte: [{ id: 'aaaaaaaa', bestandGeladen: 2 }, { id: 'bbbbbbbb', bestandGeladen: null }, { id: 'cccccccc' }, { bestandGeladen: 1 }] } }) + assert.deepEqual([...w.werte], [['aaaaaaaa', 2], ['bbbbbbbb', null]]) +}) + +test('speichern I3: unveraenderter Bestand ueberschreibt nicht den inzwischen verkauften Stand', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + // Es wurden 3 Stueck verkauft + await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 }) + const g = await mitGeladen(a.$id, basis({ name: 'Kerze neu', bestand: 5, bestandGeladen: 5 })) + assert.equal(JSON.parse(g.produkt).name, 'Kerze neu') + assert.equal((await bestandVon(a.$id)).bestand, 2) +}) + +test('speichern I3: geaenderter Bestand wird geschrieben (auch auf unbegrenzt und auf 0)', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + await speichereProdukt(SHOP, a.$id, { ...(await bestandVon(a.$id)), bestand: 2 }) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 9, bestandGeladen: 5 })) + assert.equal((await bestandVon(a.$id)).bestand, 9) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: '', bestandGeladen: 9 })) + assert.equal((await bestandVon(a.$id)).bestand, null) + await mitGeladen(a.$id, basis({ name: 'Kerze', bestand: 0, bestandGeladen: null })) + assert.equal((await bestandVon(a.$id)).bestand, 0) +}) + +test('speichern I3: ohne bestandGeladen gilt der gesendete Bestand wie bisher', async () => { + const a = await speichereProdukt(SHOP, null, ok(basis({ name: 'Kerze', bestand: 5 }))) + await speichereProdukt(SHOP, a.$id, ok(basis({ name: 'Kerze', bestand: 7 }))) + assert.equal((await bestandVon(a.$id)).bestand, 7) +}) + +test('speichern I3: Variantenbestand je Wert - unveraenderte Werte behalten den aktuellen Stand, geaenderte und neue Werte nicht', async () => { + const roh = (w) => basis({ name: 'Shirt', preisCent: undefined, varianten: { name: 'Größe', werte: w } }) + const a = await speichereProdukt(SHOP, null, ok(roh([{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 5 }, { id: 'cccccccc', name: 'L', preisCent: 900, bestand: null }]))) + // S: 2 verkauft, M: 5 verkauft + const stand = await bestandVon(a.$id) + stand.varianten.werte[0].bestand = 3 + stand.varianten.werte[1].bestand = 0 + await speichereProdukt(SHOP, a.$id, stand) + await mitGeladen(a.$id, roh([ + { id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 5, bestandGeladen: 5 }, // unberuehrt + { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: 20, bestandGeladen: 5 }, // Haendler hat aufgestockt + { id: 'cccccccc', name: 'L', preisCent: 900, bestand: null, bestandGeladen: null }, // unbegrenzt bleibt + { name: 'XL', preisCent: 900, bestand: 4 }, // neuer Wert + ])) + const nachher = (await bestandVon(a.$id)).varianten.werte + assert.deepEqual(nachher.map((w) => [w.name, w.bestand]), [['S', 3], ['M', 20], ['L', null], ['XL', 4]]) +}) + +test('speichern I3: Bestand beim Anlegen bleibt wie gesendet; bestandGeladen landet nie im gespeicherten Artikel', async () => { + const a = await mitGeladen(null, basis({ name: 'Neu', bestand: 4, bestandGeladen: 4 })) + assert.equal((await bestandVon(a.$id)).bestand, 4) + assert.ok(!JSON.stringify(await bestandVon(a.$id)).includes('Geladen')) +}) diff --git a/test/shopRoutes.test.js b/test/shopRoutes.test.js index 514935d..888b441 100644 --- a/test/shopRoutes.test.js +++ b/test/shopRoutes.test.js @@ -241,6 +241,29 @@ test('Einstellungen speichern leert den 30-s-Speicher von shopPerSiteKey', async // ---------------------------------------------------------------- Artikel +test('Artikel I3: Speichern ohne Bestandsaenderung behaelt den inzwischen verkauften Bestand, geaenderter Bestand wird geschrieben', async () => { + await aktiviere() + await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) + const neu = await schreiben('POST', 'produkte', { produkt: { ...ARTIKEL, bestand: 5, bestandGeladen: 5 } }) + assert.equal(neu.status, 201) + const id = neu.body.produkt.id + assert.equal(neu.body.produkt.bestand, 5) + assert.equal(neu.body.produkt.bestandGeladen, undefined) + // Zwischenzeitlich wurden 3 Stueck verkauft + const doc = aw.docs('shopProducts').get(id) + await updateDocument(config.collections.shopProducts, id, { produkt: JSON.stringify({ ...JSON.parse(doc.produkt), bestand: 2 }) }) + + const nurText = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, name: 'Tasse 2', bestand: 5, bestandGeladen: 5 } }) + assert.equal(nurText.status, 200) + assert.deepEqual([nurText.body.produkt.name, nurText.body.produkt.bestand], ['Tasse 2', 2]) + assert.ok(!aw.docs('shopProducts').get(id).produkt.includes('bestandGeladen')) + + const aufgestockt = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 12, bestandGeladen: 5 } }) + assert.equal(aufgestockt.body.produkt.bestand, 12) + const ohneMarker = await schreiben('PUT', `produkte/${id}`, { produkt: { ...ARTIKEL, bestand: 7 } }) + assert.equal(ohneMarker.body.produkt.bestand, 7, 'alter Browser ohne bestandGeladen: wie bisher') +}) + test('Artikel: anlegen 201, lesen, aendern, Fehler 400 je Feld, unbekannte ID 404, Kleinunternehmer-Steuer', async () => { await aktiviere() await schreiben('PUT', 'einstellungen', { einstellungen: EINSTELLUNGEN }) // Kleinunternehmer => 0 % diff --git a/test/shopTabArtikel.test.js b/test/shopTabArtikel.test.js index 273afbb..f7a7e19 100644 --- a/test/shopTabArtikel.test.js +++ b/test/shopTabArtikel.test.js @@ -38,6 +38,14 @@ const artikel = (id, name, extra = {}) => { return a } +/** Wie koerper, dazu der beim Laden gesehene Bestand (bestandGeladen, je Variantenwert mit id) - so geht ein bestehender Artikel zum Server. */ +const geladenKoerper = (a, extra = {}) => { + const k = koerper(a) + k.bestandGeladen = a.bestand ?? null + if (k.varianten) k.varianten = { ...k.varianten, werte: k.varianten.werte.map((w) => ({ ...w, bestandGeladen: w.bestand ?? null })) } + return { ...k, ...extra } +} + /** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */ const koerper = (a, extra = {}) => { const { id, sort, bildUrls, ...rest } = a @@ -168,7 +176,7 @@ test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, da assert.equal(rest.length, 0) assert.equal(put.method, 'PUT') assert.equal(put.url, '/api/shop/produkte/a1') - assert.deepEqual(put.body, { projectId: P, produkt: koerper(DREI()[0], { aktiv: false }) }) + assert.deepEqual(put.body, { projectId: P, produkt: geladenKoerper(DREI()[0], { aktiv: false }) }) assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false) assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/) })) @@ -365,7 +373,7 @@ test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({ await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten')) await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern')) assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb']) - assert.deepEqual(aenderungen(fx)[0].body.produkt, koerper(DREI()[2])) + assert.deepEqual(aenderungen(fx)[0].body.produkt, geladenKoerper(DREI()[2])) })) /* --------------------------------------------------------------------- Bilder --- */ @@ -576,3 +584,32 @@ test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; } assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js') }) + +test('Bearbeiten I3: der Körper trägt den beim Laden gesehenen Bestand (bestandGeladen) neben dem Feldwert, je Variantenwert; neue Werte und neue Artikel nicht', mit({ + produkte: DREI(), + sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null), +}, async ({ fx, panel }) => { + await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten')) + const form = fx.finde(panel, 'form') + await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '9') + await fx.klick(knopf(fx, form, 'wert-neu')) + await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'XL') + await fx.setze(feldVon(fx, form, 'varianten.werte.2.preisCent'), '11') + await fx.setze(feldVon(fx, form, 'varianten.werte.2.bestand'), '3') + await fx.klick(knopf(fx, form, 'speichern')) + const { produkt } = aenderungen(fx)[0].body + assert.equal(produkt.bestandGeladen, null) + assert.deepEqual(produkt.varianten.werte.map((w) => [w.id, w.bestand, w.bestandGeladen]), [['aaaaaaaa', 9, 2], ['bbbbbbbb', null, null], [undefined, 3, undefined]]) +})) + +test('Bearbeiten I3: Einzelbestand - geändert wird der neue Wert gesendet, geladen bleibt der alte', mit({ + produkte: DREI(), + sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze') } } : null), +}, async ({ fx, panel }) => { + await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten')) + const form = fx.finde(panel, 'form') + await fx.setze(feldVon(fx, form, 'bestand'), '8') + await fx.klick(knopf(fx, form, 'speichern')) + const { produkt } = aenderungen(fx)[0].body + assert.deepEqual([produkt.bestand, produkt.bestandGeladen], [8, 5]) +}))