Shop Review A I1/I2: Statuswechsel und Bestandsabzug unter einer Sperre, fehlende Schritte (Bestand, G09, K25) per Marker nachholen
Der Bestandsabzug laeuft jetzt im selben Block unter shop-bestand und shop-produkte wie der Statuswechsel auf bezahlt, zieheBestandAb haelt keine eigene Sperre mehr. Im Bestell-JSON stehen bestandAbgezogenAt, mailsKaeuferAt und mailsHaendlerAt als Marker; schliesseAb (Webhook-Wiederholung, Statusabruf, Minutenjob) holt fehlende Schritte genau einmal nach, Mails hoechstens fuenfmal versucht. Bezahlte Bestellungen ohne Bestandsabzug zaehlen weiter als reserviert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42a6bb5a4a
commit
9c2897a0b0
4 files changed
+305
-83
No files matched your search
@@ -82,17 +82,22 @@ export function tokenPasst(order, token) {
|
||||
const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId)
|
||||
|
||||
/**
|
||||
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft).
|
||||
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft) oder in bezahlten
|
||||
* Bestellungen, deren Bestandsabzug noch aussteht (bestandAbgezogenAt === null, Teilfehler beim Abschluss). So gibt es nie einen
|
||||
* Moment, in dem eine Bestellung weder reserviert noch abgezogen ist.
|
||||
* @returns {Promise<Map<string, number>>} Schluessel produktId bzw. `${produktId}:${varianteId}`
|
||||
*/
|
||||
export async function reserviert(shopId, now = new Date()) {
|
||||
const nowMs = new Date(now).getTime()
|
||||
const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', 'offen')])
|
||||
const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', ['offen', 'bezahlt'])])
|
||||
const karte = new Map()
|
||||
for (const doc of docs) {
|
||||
const ablauf = new Date(doc.ablaufAt).getTime()
|
||||
if (!(ablauf > nowMs)) continue
|
||||
const positionen = bestellDaten(doc).positionen
|
||||
const daten = bestellDaten(doc)
|
||||
if (doc.status === 'offen') {
|
||||
const ablauf = new Date(doc.ablaufAt).getTime()
|
||||
if (!(ablauf > nowMs)) continue
|
||||
} else if (daten.bestandAbgezogenAt !== null) continue
|
||||
const positionen = daten.positionen
|
||||
if (!Array.isArray(positionen)) continue
|
||||
for (const pos of positionen) {
|
||||
if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue
|
||||
@@ -404,7 +409,8 @@ async function shopDokument(shopId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Unter den Sperren des Bestands und der Artikel.
|
||||
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Der AUFRUFER haelt die Sperren
|
||||
* `shop-bestand:<shopId>` und danach `shop-produkte:<shopId>` (Reihenfolge immer so; withLocks ist nicht reentrant).
|
||||
* @returns {Promise<boolean>} true, wenn der Bestand eines Artikels nicht reichte (auf 0 gesetzt)
|
||||
*/
|
||||
async function zieheBestandAb(shopId, positionen) {
|
||||
@@ -414,30 +420,83 @@ async function zieheBestandAb(shopId, positionen) {
|
||||
if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue
|
||||
nachArtikel.set(pos.produktId, [...(nachArtikel.get(pos.produktId) ?? []), pos])
|
||||
}
|
||||
if (nachArtikel.size === 0) return false
|
||||
await withLocks([`shop-bestand:${shopId}`, `shop-produkte:${shopId}`], async () => {
|
||||
for (const [produktId, liste] of nachArtikel) {
|
||||
const doc = await holeProdukt(shopId, produktId)
|
||||
const produkt = doc ? parseProdukt(doc) : null
|
||||
if (!produkt) continue
|
||||
let geaendert = false
|
||||
const abziehen = (objekt, menge) => {
|
||||
if (!Number.isInteger(objekt.bestand)) return
|
||||
if (objekt.bestand < menge) ueberverkauft = true
|
||||
objekt.bestand = Math.max(0, objekt.bestand - menge)
|
||||
geaendert = true
|
||||
}
|
||||
for (const pos of liste) {
|
||||
const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null
|
||||
if (pos.varianteId && !wert) continue
|
||||
abziehen(wert ?? produkt, pos.menge)
|
||||
}
|
||||
if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) })
|
||||
for (const [produktId, liste] of nachArtikel) {
|
||||
const doc = await holeProdukt(shopId, produktId)
|
||||
const produkt = doc ? parseProdukt(doc) : null
|
||||
if (!produkt) continue
|
||||
let geaendert = false
|
||||
const abziehen = (objekt, menge) => {
|
||||
if (!Number.isInteger(objekt.bestand)) return
|
||||
if (objekt.bestand < menge) ueberverkauft = true
|
||||
objekt.bestand = Math.max(0, objekt.bestand - menge)
|
||||
geaendert = true
|
||||
}
|
||||
})
|
||||
for (const pos of liste) {
|
||||
const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null
|
||||
if (pos.varianteId && !wert) continue
|
||||
abziehen(wert ?? produkt, pos.menge)
|
||||
}
|
||||
if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) })
|
||||
}
|
||||
return ueberverkauft
|
||||
}
|
||||
|
||||
const MAIL_VERSUCHE_MAX = 5
|
||||
const bestandSperren = (shopId) => [`shop-bestand:${shopId}`, `shop-produkte:${shopId}`]
|
||||
|
||||
/** Welche Schritte nach dem Statuswechsel noch fehlen (Marker im Bestell-JSON stehen dann explizit auf null). */
|
||||
function offeneSchritte(daten) {
|
||||
const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {}
|
||||
return {
|
||||
bestand: daten.bestandAbgezogenAt === null,
|
||||
kaeufer: daten.mailsKaeuferAt === null && (versuche.kaeufer ?? 0) < MAIL_VERSUCHE_MAX,
|
||||
haendler: daten.mailsHaendlerAt === null && (versuche.haendler ?? 0) < MAIL_VERSUCHE_MAX,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehlt bei einer bezahlten Bestellung noch der Bestandsabzug oder eine Mail? (Webhook-Wiederholung, Status, Minutenjob) */
|
||||
export function abschlussAusstehend(order) {
|
||||
if (!STATUS_BEZAHLT.includes(order?.status)) return false
|
||||
const daten = order.daten ?? bestellDaten(order)
|
||||
const o = offeneSchritte(daten)
|
||||
return o.bestand || o.kaeufer || o.haendler
|
||||
}
|
||||
|
||||
/** Bestandsabzug mit Marker; Sperren muessen gehalten werden. Gibt die neue Bestellung (mit daten) zurueck. */
|
||||
async function bestandSchritt(order, jetzt) {
|
||||
const ueberverkauft = await zieheBestandAb(order.shopId, order.daten.positionen)
|
||||
const daten = { ...order.daten, bestandAbgezogenAt: jetzt.toISOString(), ...(ueberverkauft ? { ueberverkauft: true } : {}) }
|
||||
const doc = await updateDocument(col(), order.$id, { bestellung: JSON.stringify(daten) })
|
||||
return { ...doc, daten: bestellDaten(doc) }
|
||||
}
|
||||
|
||||
/** Fehlende Mails (G09, K25) einzeln verschicken; Marker erst nach Erfolg, Versuche gezaehlt. Unter der Bestellsperre. */
|
||||
async function mailSchritte(order, siteKey, jetzt) {
|
||||
let aktuell = order
|
||||
const merke = async (aenderung) => {
|
||||
const doc = await updateDocument(col(), aktuell.$id, { bestellung: JSON.stringify({ ...aktuell.daten, ...aenderung }) })
|
||||
aktuell = { ...doc, daten: bestellDaten(doc) }
|
||||
}
|
||||
const zaehle = (art) => ({ mailsVersuche: { ...(istObjekt(aktuell.daten.mailsVersuche) ? aktuell.daten.mailsVersuche : {}), [art]: (aktuell.daten.mailsVersuche?.[art] ?? 0) + 1 } })
|
||||
try {
|
||||
leereCache(siteKey)
|
||||
const ctx = await shopPerSiteKey(siteKey, { now: jetzt })
|
||||
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
|
||||
if (offeneSchritte(aktuell.daten).kaeufer) {
|
||||
const r = await anKaeufer('G09', ctx, aktuell, {}, jetzt)
|
||||
await merke(r.ok ? { mailsKaeuferAt: jetzt.toISOString() } : zaehle('kaeufer'))
|
||||
}
|
||||
if (offeneSchritte(aktuell.daten).haendler) {
|
||||
const k25 = haendlerK25(ctx, aktuell, { ueberverkauft: aktuell.daten.ueberverkauft === true })
|
||||
const r = await anHaendler('K25', ctx, k25.values, k25.blocks)
|
||||
await merke(r.ok ? { mailsHaendlerAt: jetzt.toISOString() } : zaehle('haendler'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[shop] Mails zur bezahlten Bestellung ${String(order.$id).slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||||
}
|
||||
return aktuell
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst eine bezahlte Checkout-Sitzung ab: einzige Stelle fuer den Status bezahlt. Idempotent (zweiter Aufruf aendert nichts
|
||||
* und schickt keine Mail), nimmt auch spaete Zahlungen einer abgelaufenen Bestellung an. Mailfehler machen die Zahlung nie rueckgaengig.
|
||||
@@ -449,8 +508,8 @@ export async function schliesseAb({ accountId, session, now = new Date() }) {
|
||||
if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { order: null, neu: false }
|
||||
const jetzt = new Date(now)
|
||||
|
||||
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
|
||||
const order = await bestellungLesen(orderId)
|
||||
return withLocks([`shop-order:${orderId}`], async () => {
|
||||
let order = await bestellungLesen(orderId)
|
||||
if (!order) return { order: null, neu: false }
|
||||
const shop = await shopDokument(order.shopId)
|
||||
if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) {
|
||||
@@ -459,62 +518,60 @@ export async function schliesseAb({ accountId, session, now = new Date() }) {
|
||||
}
|
||||
const daten = order.daten
|
||||
const spaet = order.status === 'storniert' && Array.isArray(daten.verlauf) && daten.verlauf.some((v) => v?.was === 'abgelaufen')
|
||||
if (order.status !== 'offen' && !spaet) return { order, neu: false }
|
||||
if (session.payment_status !== 'paid') return { order, neu: false }
|
||||
let neu = false
|
||||
|
||||
const kunde = istObjekt(session.customer_details) ? session.customer_details : {}
|
||||
const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details
|
||||
const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null
|
||||
const rechnungsadresse = anschrift(kunde.address, kunde.name)
|
||||
const zahlungsart = zahlungsartVon(session)
|
||||
const email = kurz(kunde.email, 320)
|
||||
const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent
|
||||
if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`)
|
||||
if (order.status === 'offen' || spaet) {
|
||||
if (session.payment_status !== 'paid') return { order, neu: false }
|
||||
const kunde = istObjekt(session.customer_details) ? session.customer_details : {}
|
||||
const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details
|
||||
const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null
|
||||
const rechnungsadresse = anschrift(kunde.address, kunde.name)
|
||||
const zahlungsart = zahlungsartVon(session)
|
||||
const email = kurz(kunde.email, 320)
|
||||
const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent
|
||||
if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`)
|
||||
|
||||
const at = jetzt.toISOString()
|
||||
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
|
||||
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
|
||||
const neueDaten = {
|
||||
...daten,
|
||||
kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
|
||||
...(lieferadresse ? { lieferadresse } : {}),
|
||||
...(rechnungsadresse ? { rechnungsadresse } : {}),
|
||||
...(zahlungsart ? { zahlungsart } : {}),
|
||||
...(spaet ? { spaet: true } : {}),
|
||||
verlauf,
|
||||
}
|
||||
const felder = {
|
||||
status: 'bezahlt',
|
||||
bezahltAt: at,
|
||||
bestellung: JSON.stringify(neueDaten),
|
||||
...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}),
|
||||
...(email ? { kaeuferEmail: email } : {}),
|
||||
}
|
||||
if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined })
|
||||
let doc = await updateDocument(col(), orderId, felder)
|
||||
const at = jetzt.toISOString()
|
||||
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
|
||||
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
|
||||
const neueDaten = {
|
||||
...daten,
|
||||
kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
|
||||
...(lieferadresse ? { lieferadresse } : {}),
|
||||
...(rechnungsadresse ? { rechnungsadresse } : {}),
|
||||
...(zahlungsart ? { zahlungsart } : {}),
|
||||
...(spaet ? { spaet: true } : {}),
|
||||
// Marker: null = Schritt steht noch aus, wird erst nach Erfolg mit der Zeit gesetzt (siehe offeneSchritte)
|
||||
bestandAbgezogenAt: null,
|
||||
mailsKaeuferAt: null,
|
||||
mailsHaendlerAt: null,
|
||||
verlauf,
|
||||
}
|
||||
const felder = {
|
||||
status: 'bezahlt',
|
||||
bezahltAt: at,
|
||||
bestellung: JSON.stringify(neueDaten),
|
||||
...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}),
|
||||
...(email ? { kaeuferEmail: email } : {}),
|
||||
}
|
||||
if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined })
|
||||
// Statuswechsel und Bestandsabzug unter derselben Sperre: legeBestellungAn sieht nie "weder reserviert noch abgezogen"
|
||||
order = await withLocks(bestandSperren(order.shopId), async () => {
|
||||
const doc = await updateDocument(col(), orderId, felder)
|
||||
return bestandSchritt({ ...doc, daten: bestellDaten(doc) }, jetzt)
|
||||
})
|
||||
neu = true
|
||||
} else if (STATUS_BEZAHLT.includes(order.status) && offeneSchritte(daten).bestand) {
|
||||
// Wiederholung nach Teilfehler: Bestandsabzug nachholen (genau einmal, Marker)
|
||||
order = await withLocks(bestandSperren(order.shopId), async () => {
|
||||
const frisch = await bestellungLesen(orderId)
|
||||
return frisch && offeneSchritte(frisch.daten).bestand ? bestandSchritt(frisch, jetzt) : (frisch ?? order)
|
||||
})
|
||||
} else if (!STATUS_BEZAHLT.includes(order.status)) return { order, neu: false }
|
||||
|
||||
const ueberverkauft = await zieheBestandAb(order.shopId, daten.positionen)
|
||||
if (ueberverkauft) {
|
||||
const mitHinweis = { ...neueDaten, ueberverkauft: true }
|
||||
doc = await updateDocument(col(), orderId, { bestellung: JSON.stringify(mitHinweis) }).catch(() => doc)
|
||||
}
|
||||
return { order: { ...doc, daten: bestellDaten(doc) }, neu: true, ueberverkauft, siteKey: shop.siteKey }
|
||||
if (offeneSchritte(order.daten).kaeufer || offeneSchritte(order.daten).haendler) order = await mailSchritte(order, shop.siteKey, jetzt)
|
||||
return { order, neu }
|
||||
})
|
||||
if (!ergebnis.neu) return { order: ergebnis.order, neu: false }
|
||||
|
||||
// Mails: ausserhalb der Sperre, Fehler zaehlen nicht
|
||||
const { order } = ergebnis
|
||||
try {
|
||||
leereCache(ergebnis.siteKey)
|
||||
const ctx = await shopPerSiteKey(ergebnis.siteKey, { now: jetzt })
|
||||
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
|
||||
await anKaeufer('G09', ctx, order, {}, jetzt)
|
||||
const k25 = haendlerK25(ctx, order, { ueberverkauft: ergebnis.ueberverkauft })
|
||||
await anHaendler('K25', ctx, k25.values, k25.blocks)
|
||||
} catch (err) {
|
||||
console.warn(`[shop] Mails zur bezahlten Bestellung ${orderId.slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||||
}
|
||||
return { order, neu: true }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,6 +629,23 @@ export async function pruefeOffene(now = new Date()) {
|
||||
const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter(
|
||||
(d) => new Date(d.ablaufAt).getTime() < grenze
|
||||
)
|
||||
// Bezahlte Bestellungen, bei denen nach einem Teilfehler noch Bestandsabzug oder Mails fehlen: ueber schliesseAb nachholen
|
||||
const unfertig = shop.stripeAccountId
|
||||
? (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'bezahlt')])).filter(
|
||||
(d) => new Date(d.bezahltAt).getTime() < grenze && abschlussAusstehend({ ...d, daten: bestellDaten(d) })
|
||||
)
|
||||
: []
|
||||
for (const order of unfertig) {
|
||||
zaehler.geprueft += 1
|
||||
try {
|
||||
if (!order.stripeSessionId) continue
|
||||
const session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId)
|
||||
if (session) await schliesseAb({ accountId: shop.stripeAccountId, session, now })
|
||||
} catch (err) {
|
||||
zaehler.fehler += 1
|
||||
console.warn(`[shop] Nachholen der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
for (const order of offene) {
|
||||
zaehler.geprueft += 1
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user