This commit is contained in:
dzc committed 2026-09-10 13:51:51 +08:00
1 parent 2e0036af45
commit c75b441dea
37 files changed
+1894 -271

No files matched your search

+249 -165
View File
@@ -1,3 +1,5 @@
import 'dart:isolate';
import 'package:common_utils/common_utils.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
@@ -168,11 +170,12 @@ abstract class SyncProductTool {
// 同步商品分类 spu end
// **************************************************************************
/// 同步超市所有数据
/// 1-删除所有商品分类
/// 2-删除所有商品
/// 3-同步商品分类
/// 4-同步商品
/// 同步超市所有数据(全量 diff)
/// 1-同步商品分类
/// 2-分页拉取全部商品,与本地快照比对后只写入变化部分
/// 3-清理服务端已不存在的商品
///
/// 不再先清空本地表:未变化的商品直接跳过,拼音等派生数据全部沿用本地旧值。
static Future<void> syncSupermarket() async {
final stopwatch = Stopwatch()..start();
EasyLoading.show(
@@ -180,11 +183,17 @@ abstract class SyncProductTool {
maskType: EasyLoadingMaskType.black,
);
try {
// 删除
await delDatabase();
// 同步
await syncClassify();
await syncProduct();
final success = await _syncSupermarketProduct(
mode: _SupermarketSyncMode.full,
);
if (success) {
// 全量同步成功才推进水位,失败保留完整窗口下次重试
final sysSetting = await AppSettingTool.getSysSetting();
sysSetting.supermarketProductSyncTime = DateTime.now();
await AppSettingTool.setSysSetting(sysSetting);
}
} catch (error, stack) {
logger.e(
"|$_tag|同步|${SysType.SUPERMARKET.title}|商品信息意外的错误error:|$error|,stack:|$stack|");
@@ -212,36 +221,13 @@ abstract class SyncProductTool {
);
if (!success) return;
} else if (sysType == SysType.SUPERMARKET) {
// 超市
final app = Get.find<AppControl>();
final dataBase = app.dataBase;
final supermarketSpuDao = dataBase.getSupermarketSpuDao;
final supermarketSkuDao = dataBase.getSupermarketSkuDao;
final supermarketCodeDao = dataBase.getSupermarketCodeDao;
int currentPage = 1;
Set<String> serviceCodes = {};
while (true) {
final pa = {"current": currentPage, "size": 100};
final supermarket = await ProductApi.supermarketSpu(pa);
if (supermarket == null) break;
if (ObjectUtil.isEmptyList(supermarket.records)) break;
final ids = await _batchUpdateSupermarketProduct(
list: supermarket.records!,
);
currentPage += 1;
serviceCodes.addAll(ids);
}
// 处理删除
final productList = await supermarketSpuDao.getAll(
"-1",
);
final dbCodes = productList.map((e) => e.serviceUniqueCode).toSet();
final deleteCodes = dbCodes.difference(serviceCodes).toList();
if (ObjectUtil.isNotEmpty(deleteCodes)) {
await supermarketSpuDao.delByIds(deleteCodes);
await supermarketSkuDao.delBySpuIds(deleteCodes);
await supermarketCodeDao.delBySpuIds(deleteCodes);
}
// 超市:已有同步水位时走增量(只请求上次同步后变化的商品,含删除墓碑),
// 首次同步退化为全量 diff。
final mode = sysSetting.supermarketProductSyncTime == null
? _SupermarketSyncMode.full
: _SupermarketSyncMode.incremental;
final success = await _syncSupermarketProduct(mode: mode);
if (!success) return;
}
// 更新同步时间
if (sysType == SysType.CANTEEN) {
@@ -448,59 +434,101 @@ abstract class SyncProductTool {
// **************************************************************************
// **************************************************************************
// 同步食堂商品 spu start
// 同步超市商品 spu start
// **************************************************************************
static Future<List<String>> _batchUpdateSupermarketProduct({
required List<ServiceSupermarketSpu> list,
/// 按服务端推送的编号定向同步超市商品。
///
/// MQTT SUPERMARKET_PRODUCT 消息携带 spuIds 时走此入口,
/// 只拉取变化的商品;接口未返回的编号视为服务端已删除。
static Future<bool> syncSupermarketProductByIds(Set<String> spuIds) async {
final sysSetting = await AppSettingTool.getSysSetting();
if (sysSetting.sysType != SysType.SUPERMARKET) return false;
if (ObjectUtil.isEmpty(spuIds)) return false;
try {
return await _syncSupermarketProduct(
mode: _SupermarketSyncMode.surgical,
onlySpuIds: spuIds,
);
} catch (e, stack) {
logger.e("|$_tag|定向同步|超市|商品信息意外的错误error:|$e|,stack:|$stack|");
return false;
}
}
/// 超市商品同步主体,返回 false 表示接口无有效响应,调用方不应推进同步水位。
static Future<bool> _syncSupermarketProduct({
required _SupermarketSyncMode mode,
Set<String>? onlySpuIds,
}) async {
final app = Get.find<AppControl>();
final dataBase = app.dataBase;
final supermarketSpuDao = dataBase.getSupermarketSpuDao;
final productList = await supermarketSpuDao.getAll(
"-1",
);
if (ObjectUtil.isEmpty(productList)) {
await _batchAddOrUpdateSupermarketProduct(
list: list,
isUpdate: false,
);
await _batchAddOrUpdateSupermarketSpuAndCode(list);
return list.map((e) => e.id.toString()).toSet().toList();
}
final apiServiceCodes = list.map((e) => e.id.toString()).toSet();
final dbServiceCodes = productList.map((e) => e.serviceUniqueCode).toSet();
final supermarketSkuDao = dataBase.getSupermarketSkuDao;
final supermarketCodeDao = dataBase.getSupermarketCodeDao;
final addCodes = apiServiceCodes.difference(dbServiceCodes).toList();
final updateCodes = apiServiceCodes.intersection(dbServiceCodes).toList();
if (ObjectUtil.isNotEmpty(addCodes)) {
await _batchAddOrUpdateSupermarketProduct(
list: _filterCaneenSupermarketByCodes(list, addCodes),
isUpdate: false,
);
}
if (ObjectUtil.isNotEmpty(updateCodes)) {
await _batchAddOrUpdateSupermarketProduct(
list: _filterCaneenSupermarketByCodes(list, updateCodes),
isUpdate: true,
);
}
await _batchAddOrUpdateSupermarketSpuAndCode(list);
return apiServiceCodes.toList();
}
// 本地快照只取一次,分页循环内全部走内存比对,替代逐页全表查询和逐条 getByCode。
final productList = await supermarketSpuDao.getAll("-1");
final dbMap = {
for (final v in productList) v.serviceUniqueCode: v,
};
static Future<void> _batchAddOrUpdateSupermarketProduct({
required List<ServiceSupermarketSpu> list,
required bool isUpdate,
}) async {
if (ObjectUtil.isEmpty(list)) return;
final app = Get.find<AppControl>();
final dataBase = app.dataBase;
final productDao = dataBase.getSupermarketSpuDao;
List<SupermarketSpuEntity> changeList = [];
for (final v in list) {
SupermarketSpuEntity item = SupermarketSpuEntity(
serviceUniqueCode: v.id ?? "-1",
// 增量模式多回看几分钟容忍终端与服务端时钟偏差,重复拉取是幂等的。
String? updateTime;
if (mode == _SupermarketSyncMode.incremental) {
final sysSetting = await AppSettingTool.getSysSetting();
final last = sysSetting.supermarketProductSyncTime;
if (last != null) {
updateTime = DateUtil.formatDate(
last.subtract(const Duration(minutes: 5)),
format: "yyyy-MM-dd HH:mm:ss",
);
}
}
final serviceCodes = <String>{};
final tombstoneCodes = <String>{};
final apiList = <ServiceSupermarketSpu>[];
int currentPage = 1;
while (true) {
final pa = <String, dynamic>{"current": currentPage, "size": 100};
if (updateTime != null) pa["updateTime"] = updateTime;
if (onlySpuIds != null) pa["ids"] = onlySpuIds.toList();
final supermarket = await ProductApi.supermarketSpu(pa);
if (supermarket == null) return false;
if (ObjectUtil.isEmptyList(supermarket.records)) break;
for (final v in supermarket.records!) {
final code = v.id.toString();
if (v.delFlag == "1") {
// 服务端逻辑删除的墓碑行,本地有才需要清理
tombstoneCodes.add(code);
continue;
}
serviceCodes.add(code);
apiList.add(v);
}
currentPage += 1;
}
// 拼音只对新增或改名的商品计算,名称未变的沿用本地旧值。
final pendingNames = <String>{};
for (final v in apiList) {
final old = dbMap[v.id.toString()];
final name = v.productName ?? "";
if (old == null || old.productName != name) {
pendingNames.add(name);
}
}
final pyMap = await _batchTransitionPy(pendingNames.toList());
final addList = <SupermarketSpuEntity>[];
final editList = <SupermarketSpuEntity>[];
final detailList = <ServiceSupermarketSpu>[];
for (final v in apiList) {
final code = v.id.toString();
final old = dbMap[code];
final item = SupermarketSpuEntity(
serviceUniqueCode: code,
classifyServiceUniqueCode: v.categoryId ?? "-1",
productCode: v.productCode ?? "-1",
productName: v.productName ?? "",
@@ -518,44 +546,78 @@ abstract class SyncProductTool {
retailPrice: v.retailPrice ?? 0,
promotionPrice: v.promotionPrice ?? 0,
sort: 0,
createTime: DateTime.now(),
createTime: old?.createTime ?? DateTime.now(),
updateTime: DateTime.now(),
delFlag: false,
);
// 计算一下拼音
final productNamePy = await transitionPy(item.productName);
if (ObjectUtil.isNotEmpty(productNamePy)) {
item.productNamePy = productNamePy;
if (old != null && old.productName == item.productName) {
item.productNamePy = old.productNamePy;
} else {
item.productNamePy = pyMap[item.productName] ?? "";
}
if (isUpdate) {
final detail = await productDao.getByCode(item.serviceUniqueCode);
if (ObjectUtil.isEmpty(detail)) continue;
if (detail == item) continue;
item.id = detail?.id;
if (old == null) {
addList.add(item);
detailList.add(v);
continue;
}
changeList.add(item);
// Equatable 只比较业务字段:未变化的商品跳过写库与详情请求
if (old == item) continue;
item.id = old.id;
editList.add(item);
detailList.add(v);
}
if (ObjectUtil.isNotEmpty(changeList)) {
isUpdate
? await productDao.batchEdit(changeList)
: await productDao.batchSave(changeList);
if (ObjectUtil.isNotEmpty(addList)) {
await supermarketSpuDao.batchSave(addList);
}
if (ObjectUtil.isNotEmpty(editList)) {
await supermarketSpuDao.batchEdit(editList);
}
// sku 与辅助条码只对变化的商品重建
await _batchSaveSupermarketSkuAndCode(detailList);
// 删除:增量模式看服务端墓碑,全量/定向模式比对差集
List<String> deleteCodes;
if (mode == _SupermarketSyncMode.incremental) {
deleteCodes = tombstoneCodes
.where((code) => dbMap.containsKey(code))
.toList();
} else if (mode == _SupermarketSyncMode.surgical) {
deleteCodes = onlySpuIds!
.where((code) =>
!serviceCodes.contains(code) && dbMap.containsKey(code))
.toList();
} else {
deleteCodes = dbMap.keys
.where((code) => !serviceCodes.contains(code))
.toList();
}
if (ObjectUtil.isNotEmpty(deleteCodes)) {
// 分批删除,避免 IN 占位符超出 SQLite 变量数上限
for (final codes in _chunked(deleteCodes, 500)) {
await supermarketSpuDao.delByIds(codes);
await supermarketSkuDao.delBySpuIds(codes);
await supermarketCodeDao.delBySpuIds(codes);
}
}
return true;
}
static List<ServiceSupermarketSpu> _filterCaneenSupermarketByCodes(
List<ServiceSupermarketSpu> sourceList,
List<String> codes,
) {
return sourceList
.where((item) => codes.contains(item.id.toString()))
.toList();
/// 按固定大小切分列表。
static List<List<T>> _chunked<T>(List<T> source, int size) {
final result = <List<T>>[];
for (var i = 0; i < source.length; i += size) {
result.add(
source.sublist(i, i + size > source.length ? source.length : i + size),
);
}
return result;
}
// 更新辅助条码和spu
static Future<void> _batchAddOrUpdateSupermarketSpuAndCode(
// 重建指定商品的 sku 与辅助条码
static Future<void> _batchSaveSupermarketSkuAndCode(
List<ServiceSupermarketSpu> list,
) async {
if (list.isEmpty) return;
if (ObjectUtil.isEmpty(list)) return;
final app = Get.find<AppControl>();
final dataBase = app.dataBase;
final skuDao = dataBase.getSupermarketSkuDao;
@@ -564,53 +626,56 @@ abstract class SyncProductTool {
final spuIds =
list.where((v) => ObjectUtil.isNotEmpty(v)).map((v) => v.id!).toList();
try {
final pa = {"ids": spuIds};
final skuDetailList = await ProductApi.supermarketSupDetial(pa);
if (skuDetailList == null) {
return;
}
// 删除 sku
skuDao.delBySpuIds(spuIds);
// 删除辅助条码
codeDao.delBySpuIds(spuIds);
List<SupermarketCodeEntity> codeList = [];
List<SupermarketSkuEntity> skuList = [];
for (final v in skuDetailList) {
// 辅助条码
if (v.supermarketAuxiliaryCodeEntityList != null) {
for (final i in v.supermarketAuxiliaryCodeEntityList!) {
SupermarketCodeEntity item = SupermarketCodeEntity(
serviceUniqueCode: i.id ?? "-1",
spuId: i.spuId ?? "-1",
unitId: i.unitId ?? "",
barCode: i.barCode ?? "",
);
codeList.add(item);
// 详情接口的 ids 拼在 GET URL 上,分批请求避免超出网关 URL 长度限制
for (final chunkIds in _chunked(spuIds, 100)) {
final pa = {"ids": chunkIds};
final skuDetailList = await ProductApi.supermarketSupDetial(pa);
if (skuDetailList == null) {
return;
}
// 删除 sku
await skuDao.delBySpuIds(chunkIds);
// 删除辅助条码
await codeDao.delBySpuIds(chunkIds);
List<SupermarketCodeEntity> codeList = [];
List<SupermarketSkuEntity> skuList = [];
for (final v in skuDetailList) {
// 辅助条码
if (v.supermarketAuxiliaryCodeEntityList != null) {
for (final i in v.supermarketAuxiliaryCodeEntityList!) {
SupermarketCodeEntity item = SupermarketCodeEntity(
serviceUniqueCode: i.id ?? "-1",
spuId: i.spuId ?? "-1",
unitId: i.unitId ?? "",
barCode: i.barCode ?? "",
);
codeList.add(item);
}
}
// sku
if (v.supermarketUnitEntityList != null) {
for (final i in v.supermarketUnitEntityList!) {
SupermarketSkuEntity item = SupermarketSkuEntity(
serviceUniqueCode: i.id ?? "-1",
spuId: i.spuId ?? "-1",
barCode: i.barCode ?? "",
spec: i.spec ?? "",
unit: i.unit ?? "",
packageNum: i.packageNum ?? 0,
purchasePrice: i.purchasePrice ?? 0,
retailPrice: i.retailPrice ?? 0,
promotionPrice: i.promotionPrice ?? 0,
);
skuList.add(item);
}
}
}
// sku
if (v.supermarketUnitEntityList != null) {
for (final i in v.supermarketUnitEntityList!) {
SupermarketSkuEntity item = SupermarketSkuEntity(
serviceUniqueCode: i.id ?? "-1",
spuId: i.spuId ?? "-1",
barCode: i.barCode ?? "",
spec: i.spec ?? "",
unit: i.unit ?? "",
packageNum: i.packageNum ?? 0,
purchasePrice: i.purchasePrice ?? 0,
retailPrice: i.retailPrice ?? 0,
promotionPrice: i.promotionPrice ?? 0,
);
skuList.add(item);
}
if (codeList.isNotEmpty) {
await codeDao.batchSave(codeList);
}
if (skuList.isNotEmpty) {
await skuDao.batchSave(skuList);
}
}
if (codeList.isNotEmpty) {
await codeDao.batchSave(codeList);
}
if (skuList.isNotEmpty) {
await skuDao.batchSave(skuList);
}
} catch (e, stack) {
logger.e("|$_tag|添加超市spu detail 错误error:|$e|stack:|$stack|");
@@ -618,22 +683,41 @@ abstract class SyncProductTool {
}
// **************************************************************************
// 同步食堂商品 spu end
// 同步超市商品 spu end
// **************************************************************************
// 中文转拼音
static Future<String?> transitionPy(String str) async {
// 中文转短拼音;纯数字内容返回空串
static String _shortPinyin(String str) {
if (ObjectUtil.isEmpty(str)) return "";
if (double.tryParse(str) != null) return "";
return PinyinHelper.getShortPinyin(str).replaceAll(" ", "");
}
/// 批量中文转拼音。计算量随商品数增长,放后台 isolate 避免卡住收银界面;
/// isolate 异常时退回主 isolate 逐个计算,保证同步流程不中断。
static Future<Map<String, String>> _batchTransitionPy(
List<String> names,
) async {
if (ObjectUtil.isEmpty(names)) return {};
try {
if (ObjectUtil.isEmpty(str)) return "";
// 尝试转换为数字,如果是数字,返回空
if (double.tryParse(str) != null) return "";
final py = PinyinHelper.getShortPinyin(str);
logger.d("|$_tag|中文转拼音|中文:|$str|转换到拼音:|$py|");
// 去除所有空格
final pyNoSpace = py.replaceAll(" ", "");
return pyNoSpace;
} catch (error, stack) {
logger.e("|$_tag|添加超市spu detail 错误error:|$error|stack:|$stack|");
return await Isolate.run(() {
return {for (final name in names) name: _shortPinyin(name)};
});
} catch (e, stack) {
logger.e("|$_tag|批量中文转拼音isolate错误error:|$e|stack:|$stack|");
return {for (final name in names) name: _shortPinyin(name)};
}
}
}
/// 超市商品同步模式。
enum _SupermarketSyncMode {
/// 全量分页拉取,并清理服务端已不存在的商品。
full,
/// 只拉取上次同步时间之后变化的商品(含删除墓碑),不做全量差集清理。
incremental,
/// 只同步指定的商品编号,接口未返回的编号按已删除处理。
surgical,
}
+112
View File
@@ -1,9 +1,34 @@
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 一次离线人员同步的不可变结果汇总。
class OfflineUserSyncSummary {
/// 创建一次同步的统计结果。
const OfflineUserSyncSummary({
required this.added,
required this.updated,
required this.unchanged,
});
/// 本机新增的人员数量。
final int added;
/// 更新钱包等快照字段的人员数量。
final int updated;
/// 与本地数据一致而跳过写入的人员数量。
final int unchanged;
}
abstract class SyncUserTool {
static const _tag = "sync商品工具";
/// 离线人员快照单页数量,与服务端默认值保持一致。
static const int _offlineSyncPageSize = 200;
/// 游标保护页数,避免异常服务端持续返回 hasMore 造成无限循环。
static const int _maxOfflineSyncPages = 10000;
// 获取存储用户
static Future<UserEntity?> storageUserByCarNumber(String cardNumber) async {
final app = Get.find<AppControl>();
@@ -82,4 +107,91 @@ abstract class SyncUserTool {
}
return userEntity;
}
/// 按设备绑定部门树(或全租户)同步脱敏后的离线人员到本机缓存。
///
/// [isAll] 只允许非生产调试使用;生产调用即使误传也会被服务端收敛为本部门。
/// 服务端返回的记录姓名已脱敏且不含手机号头像;本地已有的完整消费缓存
/// (真实姓名、手机号)优先保留,同步只补齐缺失人员和刷新钱包快照字段。
/// 分页期间快照上界变化会立即中断,已写入的本地数据保持有效。
static Future<OfflineUserSyncSummary> syncOfflineUsers({bool isAll = false}) async {
if (!AppNetworkTool.isAutoNetwork()) {
throw StateError('网络不可用,无法同步离线人员');
}
final app = Get.find<AppControl>();
final userDao = app.dataBase.getUserDao;
var afterId = '0';
String? snapshotMaxId;
var added = 0;
var updated = 0;
var unchanged = 0;
for (var page = 0; page < _maxOfflineSyncPages; page++) {
final snapshot = await HardApi.getOfflineUserSyncSnapshot(
afterId: afterId,
limit: _offlineSyncPageSize,
snapshotMaxId: snapshotMaxId,
allTenant: isAll,
);
snapshotMaxId ??= snapshot.snapshotMaxId;
if (snapshot.snapshotMaxId != snapshotMaxId) {
throw StateError('离线人员快照上界在分页期间发生变化');
}
for (final record in snapshot.records) {
final existing = await userDao.findByUserId(record.userId);
if (existing == null) {
final now = DateTime.now();
await userDao.save(
UserEntity(
userId: record.userId,
nickname: record.username,
walletCardNumber: record.walletCardNumber,
walletId: record.walletId,
walletTypeId: record.walletTypeId,
walletBalance: record.walletBalance,
canteenId: record.canteenId?.toString(),
createTime: now,
updateTime: now,
),
);
added++;
continue;
}
// 姓名、手机号、头像和优惠保留本地消费缓存的真实数据,仅刷新钱包快照。
final merged = UserEntity(
id: existing.id,
userId: existing.userId,
nickname: existing.nickname,
mobile: existing.mobile,
avatar: existing.avatar,
walletCardNumber: record.walletCardNumber,
walletId: record.walletId,
walletTypeId: record.walletTypeId,
walletBalance: record.walletBalance,
canteenId: record.canteenId?.toString() ?? existing.canteenId,
discountsList: existing.discountsList,
discountsUpdateTime: existing.discountsUpdateTime,
createTime: existing.createTime,
updateTime: DateTime.now(),
);
// UserEntity 等值比较不含时间戳;完全一致时避免无谓写库。
if (merged == existing) {
unchanged++;
} else {
await userDao.edit(merged);
updated++;
}
}
if (!snapshot.hasMore) break;
afterId = snapshot.nextAfterId;
}
final summary = OfflineUserSyncSummary(
added: added,
updated: updated,
unchanged: unchanged,
);
logger.i(
'|$_tag|离线人员同步完成|新增:$added|更新:$updated|无变化:$unchanged|',
);
return summary;
}
}