offlineSyncManager.ts 8.34 KB
import { fetchLabelCategoryPage } from '../services/labelCategory'
import { fetchProductCategoryPage } from '../services/productCategory'
import { usAppFetchLocationDetail, usAppFetchMyLocations, usAppFetchMyProfile } from '../services/usAppAuth'
import {
  fetchUsAppLabelingTree,
  fetchUsAppPrintLogList,
  prefetchUsAppLabelPreviewsForLocation,
} from '../services/usAppLabeling'
import { fetchGlobalSupportContact } from '../services/locationSupport'
import {
  getAccessToken,
  getBoundLocations,
  isLoggedIn,
  setBoundLocations,
} from './authSession'
import { getCurrentStoreId } from './stores'
import {
  clearFailedOfflineMutations,
  getOfflineMutationById,
  getOfflineSyncSummary,
  getPendingOfflineMutations,
  getPendingOfflineMutationsPreview,
  initOfflineSqlite,
  isNetworkOnline,
  markOfflineMutationDone,
  markOfflineMutationFailed,
  removeOfflineMutationById,
  refreshOfflineAuthAccountSession,
} from './sqliteSync'
import type { MutationRow } from './sqliteSync'
import { usAppApiRequest } from './usAppApiRequest'

export type OfflineSyncStats = {
  online: boolean
  lastSyncAt: number
  labels: { total: number; synced: number; pending: number }
  photos: { total: number; synced: number; pending: number }
  pendingMutations: number
}

export type OfflinePendingMutationPreview = {
  id: number
  method: 'POST' | 'PUT' | 'DELETE'
  endpoint: string
  createdAt: number
  lastError: string | null
}

export type OfflineSyncExecutionDetail = {
  total: number
  success: number
  failed: number
  at: number
}

const KEY_LAST_SYNC_AT = '__offline_last_sync_at__'
const KEY_LAST_SYNC_EXECUTION = '__offline_last_sync_execution__'

function getLastSyncAt(): number {
  return Number(uni.getStorageSync(KEY_LAST_SYNC_AT) || 0)
}

function setLastSyncAt(value: number): void {
  uni.setStorageSync(KEY_LAST_SYNC_AT, value)
}

function getLastSyncExecutionDetail(): OfflineSyncExecutionDetail {
  try {
    const raw = String(uni.getStorageSync(KEY_LAST_SYNC_EXECUTION) || '').trim()
    if (!raw) return { total: 0, success: 0, failed: 0, at: 0 }
    const p = JSON.parse(raw) as Partial<OfflineSyncExecutionDetail>
    return {
      total: Number(p.total || 0),
      success: Number(p.success || 0),
      failed: Number(p.failed || 0),
      at: Number(p.at || 0),
    }
  } catch {
    return { total: 0, success: 0, failed: 0, at: 0 }
  }
}

function setLastSyncExecutionDetail(detail: OfflineSyncExecutionDetail): void {
  uni.setStorageSync(KEY_LAST_SYNC_EXECUTION, JSON.stringify(detail))
}

export async function performInitialOfflineSync(): Promise<void> {
  await initOfflineSqlite()
  const online = await isNetworkOnline()
  if (!online) return

  const profile = await usAppFetchMyProfile()
  if (profile.fullName?.trim()) {
    uni.setStorageSync('userName', profile.fullName.trim())
  }

  const locations = await usAppFetchMyLocations()
  setBoundLocations(locations)
  const currentStoreId = getCurrentStoreId() || locations?.[0]?.id || ''
  const targetLocationIds = Array.from(
    new Set(
      [currentStoreId, ...locations.map((x) => x.id).filter(Boolean)].filter((x) => !!String(x).trim())
    )
  )
  for (const locId of targetLocationIds) {
    // 每个门店一组缓存,确保离线切店后仍有可读数据
    const [, treeResult] = await Promise.allSettled([
      usAppFetchLocationDetail(locId),
      fetchUsAppLabelingTree({ locationId: locId }),
      fetchUsAppPrintLogList({ locationId: locId, skipCount: 1, maxResultCount: 20 }),
    ])
    if (treeResult.status === 'fulfilled' && Array.isArray(treeResult.value)) {
      await prefetchUsAppLabelPreviewsForLocation(locId, treeResult.value)
    }
  }
  await Promise.allSettled([
    fetchLabelCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }),
    fetchProductCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }),
    fetchGlobalSupportContact(),
  ])

  if (isLoggedIn()) {
    const email = String(uni.getStorageSync('user_email') || '').trim()
    if (email) {
      await refreshOfflineAuthAccountSession({
        email,
        token: getAccessToken(),
        refreshToken: String(uni.getStorageSync('refresh_token') || ''),
        locations: getBoundLocations(),
        displayName: String(uni.getStorageSync('userName') || email),
      })
    }
  }

  setLastSyncAt(Date.now())
}

export async function flushPendingMutations(): Promise<number> {
  const online = await isNetworkOnline()
  if (!online) throw new Error('No network connection')
  await initOfflineSqlite()
  const list = await getPendingOfflineMutations()
  let ok = 0
  for (const row of list) {
    try {
      const payload = JSON.parse(row.payloadJson || 'null')
      await usAppApiRequest<unknown>({
        path: row.endpoint,
        method: row.method as 'POST' | 'PUT' | 'DELETE',
        auth: true,
        data: payload,
      })
      await markOfflineMutationDone(row.id)
      ok += 1
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : String(e)
      await markOfflineMutationFailed(row.id, msg)
    }
  }
  return ok
}

async function flushPendingMutationsWithDetail(): Promise<OfflineSyncExecutionDetail> {
  const online = await isNetworkOnline()
  if (!online) throw new Error('No network connection')
  await initOfflineSqlite()
  const list = await getPendingOfflineMutations()
  let success = 0
  let failed = 0
  for (const row of list) {
    try {
      const payload = JSON.parse(row.payloadJson || 'null')
      await usAppApiRequest<unknown>({
        path: row.endpoint,
        method: row.method as 'POST' | 'PUT' | 'DELETE',
        auth: true,
        data: payload,
      })
      await markOfflineMutationDone(row.id)
      success += 1
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : String(e)
      await markOfflineMutationFailed(row.id, msg)
      failed += 1
    }
  }
  const d: OfflineSyncExecutionDetail = {
    total: list.length,
    success,
    failed,
    at: Date.now(),
  }
  setLastSyncExecutionDetail(d)
  return d
}

export async function syncNowAndRefreshCaches(): Promise<OfflineSyncStats> {
  // 先回放本地待同步变更,再拉最新云端数据刷新本地缓存,避免“刚同步又被旧缓存覆盖”
  await flushPendingMutationsWithDetail()
  await performInitialOfflineSync()
  setLastSyncAt(Date.now())
  return getOfflineSyncStats()
}

export async function getOfflineSyncStats(): Promise<OfflineSyncStats> {
  const online = await isNetworkOnline()
  const summary = await getOfflineSyncSummary()
  const lastSyncAt = getLastSyncAt()
  return {
    online,
    lastSyncAt,
    labels: {
      total: summary.cacheCount,
      synced: summary.cacheCount,
      pending: summary.pendingMutationCount,
    },
    photos: {
      total: 0,
      synced: 0,
      pending: 0,
    },
    pendingMutations: summary.pendingMutationCount,
  }
}

function toPreviewRow(x: MutationRow): OfflinePendingMutationPreview {
  return {
    id: x.id,
    method: x.method,
    endpoint: x.endpoint,
    createdAt: x.createdAt,
    lastError: x.lastError ?? null,
  }
}

export async function getOfflinePendingMutationPreviewList(limit = 20): Promise<OfflinePendingMutationPreview[]> {
  const rows = await getPendingOfflineMutationsPreview(limit)
  return rows.map(toPreviewRow)
}

export function getOfflineLastSyncExecutionDetail(): OfflineSyncExecutionDetail {
  return getLastSyncExecutionDetail()
}

export async function retryOfflineMutationById(id: number): Promise<{ ok: boolean; message?: string }> {
  const online = await isNetworkOnline()
  if (!online) return { ok: false, message: 'No network connection' }
  await initOfflineSqlite()
  const row = await getOfflineMutationById(id)
  if (!row) return { ok: false, message: 'Queue item not found' }
  try {
    const payload = JSON.parse(row.payloadJson || 'null')
    await usAppApiRequest<unknown>({
      path: row.endpoint,
      method: row.method as 'POST' | 'PUT' | 'DELETE',
      auth: true,
      data: payload,
    })
    await markOfflineMutationDone(row.id)
    return { ok: true }
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : String(e)
    await markOfflineMutationFailed(row.id, msg)
    return { ok: false, message: msg || 'Retry failed' }
  }
}

export async function removeOfflineMutation(id: number): Promise<void> {
  await removeOfflineMutationById(id)
}

export async function clearFailedOfflineMutationItems(): Promise<number> {
  return clearFailedOfflineMutations()
}