Shop Review A I3: Artikel-Editor ueberschreibt den Bestand nicht mehr (bestandGeladen, Bestand nur bei echter Aenderung schreiben)

Der Editor sendet neben bestand den beim Laden gesehenen Wert (bestandGeladen, je Variantenwert mit id). speichereProdukt behaelt unter der Artikelsperre den aktuell gespeicherten Stand, wenn gesendet gleich geladen ist, und schreibt nur vom Haendler geaenderte Bestaende. bestandGeladen wird nie gespeichert; ohne den Wert (alter Browser) gilt der gesendete Bestand wie bisher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:32:31 +00:00
1 parent 9c2897a0b0
commit 55fe1c567f
6 files changed
+215 -10

No files matched your search

+23 -3
View File
@@ -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,
+2 -2
View File
@@ -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
+58 -3
View File
@@ -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<string, number|null> }}
*/
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)
+70
View File
@@ -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'))
})
+23
View File
@@ -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 %
+39 -2
View File
@@ -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])
}))