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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user