个人编制软件展示

PSI - Purchase Sale Inventory 进销存软件

PSI进销存系统离线模式与数据同步

引言

在实际业务场景中,网络不稳定或完全没有网络的情况经常出现。PSI 进销存系统支持离线模式,允许用户在离线状态下继续进行业务操作,待网络恢复后自动同步数据。本文将介绍离线模式的设计与实现。

离线模式架构

离线数据处理架构:

层次 组件 功能
本地存储层 IndexedDB本地数据库 结构化数据存储
操作队列层 离线操作队列 待同步操作缓存
同步引擎层 数据同步服务 增量同步、冲突处理
状态管理层 网络状态监听 在线/离线切换处理

本地数据存储

使用 IndexedDB 实现本地数据存储:

// 本地数据库管理器
class LocalDatabase {
  constructor(dbName, version) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

  // 初始化数据库
  async initialize() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        // 商品表
        if (!db.objectStoreNames.contains('products')) {
          const productStore = db.createObjectStore('products', { keyPath: 'id' });
          productStore.createIndex('category', 'category', { unique: false });
          productStore.createIndex('updatedAt', 'updatedAt', { unique: false });
        }

        // 库存表
        if (!db.objectStoreNames.contains('inventory')) {
          const inventoryStore = db.createObjectStore('inventory', { keyPath: 'productId' });
          inventoryStore.createIndex('warehouse', 'warehouse', { unique: false });
        }

        // 客户表
        if (!db.objectStoreNames.contains('customers')) {
          const customerStore = db.createObjectStore('customers', { keyPath: 'id' });
          customerStore.createIndex('type', 'type', { unique: false });
        }

        // 订单表
        if (!db.objectStoreNames.contains('orders')) {
          const orderStore = db.createObjectStore('orders', { keyPath: 'id' });
          orderStore.createIndex('status', 'status', { unique: false });
          orderStore.createIndex('createdAt', 'createdAt', { unique: false });
          orderStore.createIndex('syncStatus', 'syncStatus', { unique: false });
        }

        // 操作队列表
        if (!db.objectStoreNames.contains('operationQueue')) {
          const queueStore = db.createObjectStore('operationQueue', { keyPath: 'id', autoIncrement: true });
          queueStore.createIndex('status', 'status', { unique: false });
          queueStore.createIndex('createdAt', 'createdAt', { unique: false });
        }

        // 变更日志表
        if (!db.objectStoreNames.contains('changeLog')) {
          const logStore = db.createObjectStore('changeLog', { keyPath: 'id', autoIncrement: true });
          logStore.createIndex('entityType', 'entityType', { unique: false });
          logStore.createIndex('entityId', 'entityId', { unique: false });
          logStore.createIndex('timestamp', 'timestamp', { unique: false });
        }
      };
    });
  }

  // 添加记录
  async add(storeName, data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.add(data);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  // 更新记录
  async put(storeName, data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.put(data);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  // 查询记录
  async get(storeName, key) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.get(key);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  // 查询所有记录
  async getAll(storeName, indexName, queryValue) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readonly');
      const store = transaction.objectStore(storeName);

      let request;
      if (indexName && queryValue !== undefined) {
        const index = store.index(indexName);
        request = index.getAll(queryValue);
      } else {
        request = store.getAll();
      }

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  // 删除记录
  async delete(storeName, key) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.delete(key);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  // 清除所有数据
  async clear(storeName) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(storeName, 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.clear();

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
}

// 本地数据库单例
const localDB = new LocalDatabase('PSI_Offline_DB', 1);
await localDB.initialize();

离线操作队列

缓存离线操作请求:

// 离线操作管理器
class OfflineOperationManager {
  constructor(localDB, syncService) {
    this.localDB = localDB;
    this.syncService = syncService;
    this.isOnline = navigator.onLine;

    this.init();
  }

  init() {
    // 监听网络状态变化
    window.addEventListener('online', () => this.onNetworkOnline());
    window.addEventListener('offline', () => this.onNetworkOffline());

    // 定期尝试同步
    setInterval(() => {
      if (this.isOnline) {
        this.processQueue();
      }
    }, 30000); // 每30秒尝试同步
  }

  // 网络恢复
  onNetworkOnline() {
    console.log('Network online, starting sync...');
    this.isOnline = true;
    this.processQueue();
  }

  // 网络断开
  onNetworkOffline() {
    console.log('Network offline, switching to offline mode');
    this.isOnline = false;
    this.showOfflineNotification();
  }

  // 记录操作到队列
  async recordOperation(operation) {
    const queueItem = {
      id: this.generateId(),
      type: operation.type, // CREATE, UPDATE, DELETE
      entityType: operation.entityType, // order, product, customer
      entityId: operation.entityId,
      data: operation.data,
      timestamp: Date.now(),
      status: 'pending', // pending, syncing, completed, failed
      retryCount: 0,
      error: null
    };

    await this.localDB.add('operationQueue', queueItem);

    // 如果在线,立即尝试同步
    if (this.isOnline) {
      await this.processQueue();
    }

    return queueItem;
  }

  // 处理队列
  async processQueue() {
    const pendingOps = await this.localDB.getAll('operationQueue', 'status', 'pending');

    if (pendingOps.length === 0) {
      return;
    }

    console.log(`Processing ${pendingOps.length} pending operations`);

    for (const op of pendingOps) {
      // 标记为同步中
      op.status = 'syncing';
      await this.localDB.put('operationQueue', op);

      try {
        // 执行同步
        await this.syncService.syncOperation(op);

        // 标记为完成
        op.status = 'completed';
        op.completedAt = Date.now();
        await this.localDB.put('operationQueue', op);

        // 清理变更日志
        await this.localDB.delete('changeLog', op.entityId);
      } catch (error) {
        // 处理失败
        op.status = 'failed';
        op.error = error.message;
        op.retryCount++;
        await this.localDB.put('operationQueue', op);

        // 如果重试次数过多,记录警告
        if (op.retryCount >= 5) {
          console.error(`Operation ${op.id} failed after 5 retries`, error);
        }
      }
    }
  }

  // 创建订单(在线/离线通用)
  async createOrder(orderData) {
    const order = {
      id: this.generateId(),
      ...orderData,
      status: 'pending',
      syncStatus: this.isOnline ? 'synced' : 'pending',
      createdAt: Date.now(),
      updatedAt: Date.now(),
      clientId: this.generateId() // 客户端生成的临时ID
    };

    // 保存到本地
    await this.localDB.add('orders', order);

    if (!this.isOnline) {
      // 离线模式,记录操作
      await this.recordOperation({
        type: 'CREATE',
        entityType: 'order',
        entityId: order.id,
        data: order
      });
    }

    return order;
  }

  // 更新订单
  async updateOrder(orderId, updates) {
    const order = await this.localDB.get('orders', orderId);
    if (!order) {
      throw new Error('Order not found');
    }

    const updatedOrder = {
      ...order,
      ...updates,
      updatedAt: Date.now()
    };

    await this.localDB.put('orders', updatedOrder);

    if (!this.isOnline) {
      await this.recordOperation({
        type: 'UPDATE',
        entityType: 'order',
        entityId: orderId,
        data: updatedOrder
      });
    }

    return updatedOrder;
  }

  // 删除订单
  async deleteOrder(orderId) {
    await this.localDB.delete('orders', orderId);

    if (!this.isOnline) {
      await this.recordOperation({
        type: 'DELETE',
        entityType: 'order',
        entityId: orderId,
        data: { id: orderId }
      });
    }
  }

  // 查询待同步数量
  async getPendingCount() {
    const pending = await this.localDB.getAll('operationQueue', 'status', 'pending');
    const syncing = await this.localDB.getAll('operationQueue', 'status', 'syncing');
    return pending.length + syncing.length;
  }
}

数据同步服务

实现增量数据同步:

// 数据同步服务
class SyncService {
  constructor(apiBaseUrl, localDB) {
    this.apiBaseUrl = apiBaseUrl;
    this.localDB = localDB;
    this.lastSyncTime = localStorage.getItem('lastSyncTime') || 0;
  }

  // 同步单个操作
  async syncOperation(operation) {
    const endpoint = `${this.apiBaseUrl}/${operation.entityType}`;

    switch (operation.type) {
      case 'CREATE':
        return this.syncCreate(endpoint, operation);
      case 'UPDATE':
        return this.syncUpdate(endpoint, operation);
      case 'DELETE':
        return this.syncDelete(endpoint, operation);
      default:
        throw new Error(`Unknown operation type: ${operation.type}`);
    }
  }

  // 同步创建操作
  async syncCreate(endpoint, operation) {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(operation.data)
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }

    const serverData = await response.json();

    // 更新本地记录的服务器ID(用于后续关联)
    const localEntity = await this.localDB.get(operation.entityType + 's', operation.entityId);
    if (localEntity) {
      localEntity.serverId = serverData.id;
      localEntity.syncStatus = 'synced';
      localEntity.syncError = null;
      await this.localDB.put(operation.entityType + 's', localEntity);
    }

    return serverData;
  }

  // 同步更新操作
  async syncUpdate(endpoint, operation) {
    const response = await fetch(`${endpoint}/${operation.entityId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(operation.data)
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }

    // 更新本地状态
    const localEntity = await this.localDB.get(operation.entityType + 's', operation.entityId);
    if (localEntity) {
      localEntity.syncStatus = 'synced';
      localEntity.syncError = null;
      localEntity.updatedAt = Date.now();
      await this.localDB.put(operation.entityType + 's', localEntity);
    }
  }

  // 同步删除操作
  async syncDelete(endpoint, operation) {
    const response = await fetch(`${endpoint}/${operation.entityId}`, {
      method: 'DELETE'
    });

    // 忽略404错误(可能已被其他设备删除)
    if (!response.ok && response.status !== 404) {
      throw new Error(`Sync failed: ${response.status}`);
    }
  }

  // 增量同步获取最新数据
  async pullChanges() {
    const response = await fetch(
      `${this.apiBaseUrl}/sync?since=${this.lastSyncTime}`,
      { method: 'GET' }
    );

    if (!response.ok) {
      throw new Error(`Pull failed: ${response.status}`);
    }

    const changes = await response.json();

    // 应用变更到本地
    for (const change of changes) {
      await this.applyChange(change);
    }

    // 更新最后同步时间
    this.lastSyncTime = Date.now();
    localStorage.setItem('lastSyncTime', this.lastSyncTime);

    return changes.length;
  }

  // 应用服务器变更
  async applyChange(change) {
    const storeName = change.entityType + 's';
    const entity = change.data;

    switch (change.operation) {
      case 'CREATE':
      case 'UPDATE':
        entity.syncStatus = 'synced';
        await this.localDB.put(storeName, entity);
        break;
      case 'DELETE':
        await this.localDB.delete(storeName, entity.id);
        break;
    }
  }

  // 完整同步
  async fullSync() {
    // 1. 先推送上未完成的操作
    await this.pushPendingChanges();

    // 2. 拉取服务器最新数据
    const changedCount = await this.pullChanges();

    console.log(`Full sync completed, ${changedCount} changes pulled`);

    return changedCount;
  }

  // 推送待处理变更
  async pushPendingChanges() {
    const pending = await this.localDB.getAll('operationQueue', 'status', 'pending');

    for (const op of pending) {
      try {
        await this.syncOperation(op);

        op.status = 'completed';
        op.completedAt = Date.now();
        await this.localDB.put('operationQueue', op);
      } catch (error) {
        op.error = error.message;
        await this.localDB.put('operationQueue', op);
      }
    }
  }
}

冲突解决策略

处理数据同步冲突:

// 冲突检测与解决
class ConflictResolver {
  constructor(localDB) {
    this.localDB = localDB;
  }

  // 检测冲突
  async detectConflict(localEntity, serverEntity) {
    if (!serverEntity) return null;

    const localTime = localEntity.updatedAt;
    const serverTime = serverEntity.updatedAt;

    // 如果本地修改时间晚于服务器,且本地有未同步的修改
    if (localTime > serverTime && localEntity.syncStatus === 'pending') {
      return {
        type: 'CONFLICT',
        localEntity,
        serverEntity,
        localTime,
        serverTime
      };
    }

    return null;
  }

  // 解决策略
  resolveConflict(conflict, strategy = 'local_wins') {
    switch (strategy) {
      case 'local_wins':
        return conflict.localEntity;
      case 'server_wins':
        return conflict.serverEntity;
      case 'manual':
        // 返回冲突信息,等待用户决策
        return { needsResolution: true, conflict };
      case 'merge':
        return this.mergeEntities(conflict.localEntity, conflict.serverEntity);
      default:
        return conflict.localEntity;
    }
  }

  // 合并实体
  mergeEntities(local, server) {
    const merged = { ...server };

    // 本地修改的字段优先
    for (const key in local) {
      if (local[key] !== server[key]) {
        merged[key] = local[key];
      }
    }

    return merged;
  }

  // 自动解决冲突(基于规则)
  async autoResolve(conflict) {
    const entity = conflict.localEntity;

    // 规则1:订单状态以服务器为准(避免重复处理)
    if (entity.entityType === 'order') {
      return conflict.serverEntity;
    }

    // 规则2:库存数据以服务器为准(避免超卖)
    if (entity.entityType === 'inventory') {
      // 记录本地修改用于后续分析
      await this.logConflict(conflict);
      return conflict.serverEntity;
    }

    // 规则3:客户信息使用本地版本(可能已人工修改)
    if (entity.entityType === 'customer') {
      return conflict.localEntity;
    }

    // 默认使用本地版本
    return conflict.localEntity;
  }

  // 记录冲突日志
  async logConflict(conflict) {
    await this.localDB.add('changeLog', {
      entityType: conflict.localEntity.entityType,
      entityId: conflict.localEntity.id,
      localData: conflict.localEntity,
      serverData: conflict.serverEntity,
      timestamp: Date.now(),
      resolved: false,
      resolution: null
    });
  }

  // 获取所有未解决的冲突
  async getUnresolvedConflicts() {
    return this.localDB.getAll('changeLog', 'resolved', false);
  }

  // 手动解决冲突
  async resolveManually(conflictId, resolution) {
    const conflict = await this.localDB.get('changeLog', conflictId);
    conflict.resolved = true;
    conflict.resolution = resolution;
    conflict.resolvedAt = Date.now();

    await this.localDB.put('changeLog', conflict);

    // 应用解决后的数据
    if (resolution === 'use_local') {
      await this.localDB.put(conflict.entityType + 's', conflict.localData);
    } else if (resolution === 'use_server') {
      await this.localDB.put(conflict.entityType + 's', conflict.serverData);
    }

    return conflict;
  }
}

最佳实践建议

总结

离线模式让 PSI 进销存系统更加实用:

通过离线模式设计,提升用户体验和工作效率。

← 下一篇:PSI进销存系统低代码平台设计