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>
|
<p className="text-sm text-slate-500">{config.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 ? 'همگامسازی…' : 'بروزرسانی'}
|
{syncing ? 'همگامسازی…' : 'بروزرسانی'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { findExistingParty, mergeFunZoneParties, partyNeedsSync } from './funzon
|
|||||||
|
|
||||||
/** Syncs FunZone owners/customers into accounting parties on app load. */
|
/** Syncs FunZone owners/customers into accounting parties on app load. */
|
||||||
export function GlobalPartySync() {
|
export function GlobalPartySync() {
|
||||||
const { ready, upsertPartyAsync, reload } = useStore()
|
const { ready, upsertPartyAsync } = useStore()
|
||||||
const synced = useRef(false)
|
const synced = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,16 +36,21 @@ export function GlobalPartySync() {
|
|||||||
for (const incoming of batches) {
|
for (const incoming of batches) {
|
||||||
const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource)
|
const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource)
|
||||||
if (existing && !partyNeedsSync(existing, incoming)) continue
|
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 {
|
} catch {
|
||||||
synced.current = false
|
synced.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void run()
|
void run()
|
||||||
}, [ready, upsertPartyAsync, reload])
|
}, [ready, upsertPartyAsync])
|
||||||
|
|
||||||
return null
|
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. */
|
/** Returns true when party data from FunZone differs from the stored accounting record. */
|
||||||
export function partyNeedsSync(stored: Party, incoming: Party): boolean {
|
export function partyNeedsSync(stored: Party, incoming: Party): boolean {
|
||||||
const keys: Array<keyof Party> = [
|
if (normalizeText(stored.name) !== normalizeText(incoming.name)) return true
|
||||||
'name',
|
if (normalizeText(stored.phone) !== normalizeText(incoming.phone)) return true
|
||||||
'phone',
|
if (normalizeText(stored.address) !== normalizeText(incoming.address)) return true
|
||||||
'address',
|
if (normalizeText(stored.firstName) !== normalizeText(incoming.firstName)) return true
|
||||||
'firstName',
|
if (normalizeText(stored.lastName) !== normalizeText(incoming.lastName)) return true
|
||||||
'lastName',
|
if (normalizeText(stored.username) !== normalizeText(incoming.username)) return true
|
||||||
'username',
|
if (normalizeText(stored.email) !== normalizeText(incoming.email)) return true
|
||||||
'email',
|
if (normalizeText(stored.nationalCode) !== normalizeText(incoming.nationalCode)) return true
|
||||||
'nationalCode',
|
if (normalizeText(stored.iban) !== normalizeText(incoming.iban)) return true
|
||||||
'iban',
|
if (!sameNumber(stored.walletBalance, incoming.walletBalance)) return true
|
||||||
'walletBalance',
|
if (Boolean(stored.isActive) !== Boolean(incoming.isActive)) return true
|
||||||
'isActive',
|
if (Boolean(stored.isVerified) !== Boolean(incoming.isVerified)) return true
|
||||||
'isVerified',
|
if (normalizeBirthday(stored.birthday) !== normalizeBirthday(incoming.birthday)) return true
|
||||||
'birthday',
|
if (!sameVenues(stored.venues, incoming.venues)) return true
|
||||||
]
|
return false
|
||||||
if (JSON.stringify(stored.venues ?? []) !== JSON.stringify(incoming.venues ?? [])) return true
|
|
||||||
return keys.some((key) => stored[key] !== incoming[key])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { apiGet, asList } from '../api/client'
|
||||||
import { ENDPOINTS } from '../api/config'
|
import { ENDPOINTS } from '../api/config'
|
||||||
import type { ApiCustomer, ApiOwner } from '../api/types'
|
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||||
@@ -37,6 +37,13 @@ export function useFunZoneParties(config: FunZonePartyConfig) {
|
|||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -50,27 +57,45 @@ export function useFunZoneParties(config: FunZonePartyConfig) {
|
|||||||
}, [config.kind])
|
}, [config.kind])
|
||||||
|
|
||||||
const syncToAccounting = useCallback(async () => {
|
const syncToAccounting = useCallback(async () => {
|
||||||
if (apiRecords.length === 0) return
|
if (apiRecords.length === 0 || syncInFlightRef.current) return
|
||||||
|
syncInFlightRef.current = true
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
try {
|
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) {
|
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
|
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 {
|
} finally {
|
||||||
|
syncInFlightRef.current = false
|
||||||
setSyncing(false)
|
setSyncing(false)
|
||||||
}
|
}
|
||||||
}, [apiRecords, config.kind, data.parties, upsertPartyAsync])
|
}, [apiRecords, config.kind, upsertPartyAsync])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready) void load()
|
if (ready) void load()
|
||||||
}, [ready, load])
|
}, [ready, load])
|
||||||
|
|
||||||
|
// Auto-sync once per loaded FunZone snapshot — never on every parties store update.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready && apiRecords.length > 0) void syncToAccounting()
|
if (!ready || apiRecords.length === 0) return
|
||||||
}, [ready, apiRecords, syncToAccounting])
|
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 parties = useMemo(() => {
|
||||||
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
||||||
|
|||||||
Reference in New Issue
Block a user