Stop party sync feedback loop causing repeated PATCH overfetch.

Cache FunZone snapshot sync keys and harden partyNeedsSync comparisons so purchases/suppliers no longer re-patch the same party on every store update.
This commit is contained in:
2026-07-08 15:49:33 +03:30
parent 11dfe28623
commit f1a8380a58
4 changed files with 91 additions and 30 deletions

View File

@@ -233,7 +233,17 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
<p className="text-sm text-slate-500">{config.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
<Button
variant="secondary"
icon="↻"
disabled={loading || syncing}
onClick={() => {
void (async () => {
await reload()
await syncToAccounting()
})()
}}
>
{syncing ? 'همگام‌سازی…' : 'بروزرسانی'}
</Button>
</div>

View File

@@ -9,7 +9,7 @@ import { findExistingParty, mergeFunZoneParties, partyNeedsSync } from './funzon
/** Syncs FunZone owners/customers into accounting parties on app load. */
export function GlobalPartySync() {
const { ready, upsertPartyAsync, reload } = useStore()
const { ready, upsertPartyAsync } = useStore()
const synced = useRef(false)
useEffect(() => {
@@ -36,16 +36,21 @@ export function GlobalPartySync() {
for (const incoming of batches) {
const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource)
if (existing && !partyNeedsSync(existing, incoming)) continue
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
const next = { ...incoming, id: existing?.id ?? createId('party-') }
await upsertPartyAsync(next)
// Keep the local snapshot in sync so later comparisons in this pass don't re-PATCH.
currentParties = existing
? currentParties.map((p) => (p.id === existing.id ? { ...p, ...next } : p))
: [...currentParties, next]
}
reload()
// upsertPartyAsync already updates AppStore; avoid a full reload that re-fetches every resource.
} catch {
synced.current = false
}
}
void run()
}, [ready, upsertPartyAsync, reload])
}, [ready, upsertPartyAsync])
return null
}

View File

@@ -84,23 +84,44 @@ export function mergeFunZoneParties(
})
}
function normalizeText(value: unknown): string {
return value == null ? '' : String(value).trim()
}
function normalizeBirthday(value: unknown): string {
if (value == null || value === '') return ''
return String(value).slice(0, 10)
}
function sameNumber(a: unknown, b: unknown): boolean {
const left = Number(a ?? 0)
const right = Number(b ?? 0)
if (Number.isNaN(left) && Number.isNaN(right)) return true
return left === right
}
function sameVenues(a: unknown, b: unknown): boolean {
const left = Array.isArray(a) ? a.map(String) : []
const right = Array.isArray(b) ? b.map(String) : []
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
/** Returns true when party data from FunZone differs from the stored accounting record. */
export function partyNeedsSync(stored: Party, incoming: Party): boolean {
const keys: Array<keyof Party> = [
'name',
'phone',
'address',
'firstName',
'lastName',
'username',
'email',
'nationalCode',
'iban',
'walletBalance',
'isActive',
'isVerified',
'birthday',
]
if (JSON.stringify(stored.venues ?? []) !== JSON.stringify(incoming.venues ?? [])) return true
return keys.some((key) => stored[key] !== incoming[key])
if (normalizeText(stored.name) !== normalizeText(incoming.name)) return true
if (normalizeText(stored.phone) !== normalizeText(incoming.phone)) return true
if (normalizeText(stored.address) !== normalizeText(incoming.address)) return true
if (normalizeText(stored.firstName) !== normalizeText(incoming.firstName)) return true
if (normalizeText(stored.lastName) !== normalizeText(incoming.lastName)) return true
if (normalizeText(stored.username) !== normalizeText(incoming.username)) return true
if (normalizeText(stored.email) !== normalizeText(incoming.email)) return true
if (normalizeText(stored.nationalCode) !== normalizeText(incoming.nationalCode)) return true
if (normalizeText(stored.iban) !== normalizeText(incoming.iban)) return true
if (!sameNumber(stored.walletBalance, incoming.walletBalance)) return true
if (Boolean(stored.isActive) !== Boolean(incoming.isActive)) return true
if (Boolean(stored.isVerified) !== Boolean(incoming.isVerified)) return true
if (normalizeBirthday(stored.birthday) !== normalizeBirthday(incoming.birthday)) return true
if (!sameVenues(stored.venues, incoming.venues)) return true
return false
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { apiGet, asList } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiCustomer, ApiOwner } from '../api/types'
@@ -37,6 +37,13 @@ export function useFunZoneParties(config: FunZonePartyConfig) {
const [syncing, setSyncing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Keep the latest parties without putting them in callback deps (avoids PATCH → state → sync loop).
const partiesRef = useRef(data.parties)
partiesRef.current = data.parties
const autoSyncKeyRef = useRef<string | null>(null)
const syncInFlightRef = useRef(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
@@ -50,27 +57,45 @@ export function useFunZoneParties(config: FunZonePartyConfig) {
}, [config.kind])
const syncToAccounting = useCallback(async () => {
if (apiRecords.length === 0) return
if (apiRecords.length === 0 || syncInFlightRef.current) return
syncInFlightRef.current = true
setSyncing(true)
try {
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
// Snapshot parties for this pass; do not re-read reactive store mid-loop.
let currentParties = partiesRef.current
const merged = mergeFunZoneParties(apiRecords, currentParties, config.kind)
for (const incoming of merged) {
const existing = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource)
const existing = findExistingParty(
currentParties,
incoming.externalId!,
incoming.externalSource,
)
if (existing && !partyNeedsSync(existing, incoming)) continue
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
const next = { ...incoming, id: existing?.id ?? createId('party-') }
await upsertPartyAsync(next)
currentParties = existing
? currentParties.map((p) => (p.id === existing.id ? { ...p, ...next } : p))
: [...currentParties, next]
partiesRef.current = currentParties
}
} finally {
syncInFlightRef.current = false
setSyncing(false)
}
}, [apiRecords, config.kind, data.parties, upsertPartyAsync])
}, [apiRecords, config.kind, upsertPartyAsync])
useEffect(() => {
if (ready) void load()
}, [ready, load])
// Auto-sync once per loaded FunZone snapshot — never on every parties store update.
useEffect(() => {
if (ready && apiRecords.length > 0) void syncToAccounting()
}, [ready, apiRecords, syncToAccounting])
if (!ready || apiRecords.length === 0) return
const key = `${config.kind}:${apiRecords.map((r) => r.id).join(',')}`
if (autoSyncKeyRef.current === key) return
autoSyncKeyRef.current = key
void syncToAccounting()
}, [ready, apiRecords, config.kind, syncToAccounting])
const parties = useMemo(() => {
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)