diff --git a/README.md b/README.md index 07f196d..f99c3b3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 1.4.6 --build-number 146 +flutter build apk --build-name 1.4.7 --build-number 147 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index 6abc702..b485b57 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -16,7 +16,7 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ - private static final int DATABASE_VERSION = 12; + private static final int DATABASE_VERSION = 13; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 0efcf6e..0ea0a12 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -284,6 +284,25 @@ abstract class HardApi { return ServiceGenerateInvoice.fromJson(res.data); } + /// 业务终端按 DEVICE-ID 创建打印任务,目标打印机只由服务端绑定关系决定。 + static Future createPrinterTask({ + required String orderId, + String printType = '1', + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/task', + data: { + 'orderId': int.parse(orderId), + 'printType': printType, + }, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + return res.data != null; + } + /// 打印成功 static Future printInvoice(dynamic formData) async { final res = await HttpManage().dio.get( @@ -294,6 +313,70 @@ abstract class HardApi { return true; } + /// 读取当前 EY_PRINT 的服务端绑定和 USB/ESC-POS 参数。 + static Future> getPrinterBootstrap() async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('打印终端尚未绑定设备 ID'); + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/bootstrap', + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data is! Map) throw const FormatException('打印终端启动配置格式错误'); + return Map.from(res.data as Map); + } + + /// 拉取当前打印终端未完成任务,补偿 MQTT 离线窗口。 + static Future> getPrinterTasks() async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return const []; + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/tasks', + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data is! List) return const []; + return (res.data as List).whereType().map((item) { + final json = Map.from(item); + json['historyId'] ??= json['id']; + return MqttInvoiceMsg.fromJson(json); + }).toList(growable: false); + } + + /// 上报完整 USB 写入结果;请求失败由本地队列保留并在网络恢复后补报。 + static Future reportPrinterResult({ + required String taskId, + required String orderId, + required int retryVersion, + required bool success, + required int attemptCount, + String? errorMessage, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('打印终端设备 ID 缺失'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/result', + data: { + 'taskId': int.parse(taskId), + 'orderId': int.parse(orderId), + 'retryVersion': retryVersion, + 'success': success, + 'attemptCount': attemptCount, + if (errorMessage != null && errorMessage.isNotEmpty) + 'errorMessage': errorMessage, + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + return res.data == true; + } + /// 离线同步 static Future?> syncOfflineOrder(dynamic formData) async { final res = await HttpManage().dio.post( diff --git a/lib/common/models/database/database_enum_models.dart b/lib/common/models/database/database_enum_models.dart index 4f9e1ef..e14d232 100644 --- a/lib/common/models/database/database_enum_models.dart +++ b/lib/common/models/database/database_enum_models.dart @@ -17,6 +17,12 @@ enum SysType implements BaseEnum { const SysType(this.code, this.title, this.cnStr, this.usStr); } +/// 终端运行角色与业务类型分离,打印终端仍沿用绑定来源的 [SysType] 获取业务配置。 +enum TerminalRole { + cashier, + printer, +} + /// 订单类型 enum OrderType implements BaseEnum { BELOW("1", "线下购买"), diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart index e4ccf28..e1f6df6 100644 --- a/lib/common/models/database/database_models.dart +++ b/lib/common/models/database/database_models.dart @@ -10,6 +10,10 @@ class SysSetting extends Equatable { /// 设备类型 SysType sysType; + /// 当前 APK 运行角色;旧配置默认保持收银终端行为。 + @JsonKey(defaultValue: TerminalRole.cashier) + TerminalRole terminalRole; + /// mac地址 String? androidId; @@ -22,6 +26,22 @@ class SysSetting extends Equatable { /// 商户id String? serviceMerchantId; + /// 打印终端所绑定的来源业务终端。 + String? printerSourceHardId; + + /// 打印终端所绑定的来源业务终端名称。 + String? printerSourceHardName; + + /// 真机打印机型号与 USB 参数,由服务端启动配置下发。 + String? printerModel; + int? printerUsbVendorId; + int? printerUsbProductId; + + /// 当前纸宽、切刀和图片指令模式。 + int printerPaperWidth; + bool printerAutoCut; + String printerImageMode; + /// 商户部门id String? merchantDeptId; @@ -131,6 +151,7 @@ class SysSetting extends Equatable { SysSetting({ required this.sysType, + this.terminalRole = TerminalRole.cashier, this.androidId, this.serviceId, this.mqSocketAddress, @@ -138,6 +159,9 @@ class SysSetting extends Equatable { this.sqlAddress, this.logAddress, this.hardGroupId, + this.printerPaperWidth = 80, + this.printerAutoCut = true, + this.printerImageMode = 'GS_V0', this.tenantList, this.manualHardwareInput = false, this.rootAccessStatus = RootAccessStatus.unknown, @@ -156,10 +180,19 @@ class SysSetting extends Equatable { @override List get props => [ sysType, + terminalRole, androidId, serviceId, serviceName, serviceMerchantId, + printerSourceHardId, + printerSourceHardName, + printerModel, + printerUsbVendorId, + printerUsbProductId, + printerPaperWidth, + printerAutoCut, + printerImageMode, merchantDeptId, hardGroupId, mqSocketAddress, diff --git a/lib/common/models/database/database_models.g.dart b/lib/common/models/database/database_models.g.dart index e065553..e9a8746 100644 --- a/lib/common/models/database/database_models.g.dart +++ b/lib/common/models/database/database_models.g.dart @@ -8,6 +8,9 @@ part of 'database_models.dart'; SysSetting _$SysSettingFromJson(Map json) => SysSetting( sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + terminalRole: + $enumDecodeNullable(_$TerminalRoleEnumMap, json['terminalRole']) ?? + TerminalRole.cashier, androidId: json['androidId'] as String?, serviceId: json['serviceId'] as String?, mqSocketAddress: json['mqSocketAddress'] as String?, @@ -15,6 +18,9 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( sqlAddress: json['sqlAddress'] as String?, logAddress: json['logAddress'] as String?, hardGroupId: json['hardGroupId'] as String?, + printerPaperWidth: (json['printerPaperWidth'] as num?)?.toInt() ?? 80, + printerAutoCut: json['printerAutoCut'] as bool? ?? true, + printerImageMode: json['printerImageMode'] as String? ?? 'GS_V0', tenantList: (json['tenantList'] as List?) ?.map((e) => ServiceTenant.fromJson(e as Map)) .toList(), @@ -38,6 +44,11 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( ) ..serviceName = json['serviceName'] as String? ..serviceMerchantId = json['serviceMerchantId'] as String? + ..printerSourceHardId = json['printerSourceHardId'] as String? + ..printerSourceHardName = json['printerSourceHardName'] as String? + ..printerModel = json['printerModel'] as String? + ..printerUsbVendorId = (json['printerUsbVendorId'] as num?)?.toInt() + ..printerUsbProductId = (json['printerUsbProductId'] as num?)?.toInt() ..merchantDeptId = json['merchantDeptId'] as String? ..mqSocketPort = json['mqSocketPort'] as String? ..mqUsername = json['mqUsername'] as String? @@ -83,10 +94,19 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( Map _$SysSettingToJson(SysSetting instance) => { 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'terminalRole': _$TerminalRoleEnumMap[instance.terminalRole]!, 'androidId': instance.androidId, 'serviceId': instance.serviceId, 'serviceName': instance.serviceName, 'serviceMerchantId': instance.serviceMerchantId, + 'printerSourceHardId': instance.printerSourceHardId, + 'printerSourceHardName': instance.printerSourceHardName, + 'printerModel': instance.printerModel, + 'printerUsbVendorId': instance.printerUsbVendorId, + 'printerUsbProductId': instance.printerUsbProductId, + 'printerPaperWidth': instance.printerPaperWidth, + 'printerAutoCut': instance.printerAutoCut, + 'printerImageMode': instance.printerImageMode, 'merchantDeptId': instance.merchantDeptId, 'hardGroupId': instance.hardGroupId, 'mqSocketAddress': instance.mqSocketAddress, @@ -135,6 +155,11 @@ const _$SysTypeEnumMap = { SysType.CANTEEN: 'CANTEEN', }; +const _$TerminalRoleEnumMap = { + TerminalRole.cashier: 'cashier', + TerminalRole.printer: 'printer', +}; + const _$RootAccessStatusEnumMap = { RootAccessStatus.unknown: 'unknown', RootAccessStatus.available: 'available', diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart index 9944ff5..88b8e1e 100644 --- a/lib/common/models/mq/mq_models.dart +++ b/lib/common/models/mq/mq_models.dart @@ -40,6 +40,18 @@ class MqttInvoiceMsg { @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) final String? androidOrderCode; + /// 来源业务终端与实际目标打印终端。 + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? sourceHardId; + + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? targetHardId; + + /// 打印类型、同订单打印序号和人工重试版本。 + final String? printType; + final int? printNo; + final int? retryVersion; + // 图片地址url final String? pic; @@ -52,6 +64,11 @@ class MqttInvoiceMsg { this.userId, this.orderId, this.androidOrderCode, + this.sourceHardId, + this.targetHardId, + this.printType, + this.printNo, + this.retryVersion, this.pic, this.isGlobalFace, }); diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart index 60b4b70..84cdbfc 100644 --- a/lib/common/models/mq/mq_models.g.dart +++ b/lib/common/models/mq/mq_models.g.dart @@ -34,6 +34,11 @@ MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map json) => userId: JsonTool.anyToString(json['userId']), orderId: JsonTool.anyToString(json['orderId']), androidOrderCode: JsonTool.anyToString(json['androidOrderCode']), + sourceHardId: JsonTool.anyToString(json['sourceHardId']), + targetHardId: JsonTool.anyToString(json['targetHardId']), + printType: json['printType'] as String?, + printNo: (json['printNo'] as num?)?.toInt(), + retryVersion: (json['retryVersion'] as num?)?.toInt(), pic: json['pic'] as String?, isGlobalFace: json['isGlobalFace'] as bool?, ); @@ -45,6 +50,11 @@ Map _$MqttInvoiceMsgToJson(MqttInvoiceMsg instance) => 'userId': JsonTool.stringToInt(instance.userId), 'orderId': JsonTool.stringToInt(instance.orderId), 'androidOrderCode': JsonTool.stringToInt(instance.androidOrderCode), + 'sourceHardId': JsonTool.stringToInt(instance.sourceHardId), + 'targetHardId': JsonTool.stringToInt(instance.targetHardId), + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, 'pic': instance.pic, 'isGlobalFace': instance.isGlobalFace, }; diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index b035ab1..90a2395 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -6,6 +6,12 @@ class RouteNames { /// 登录 static const login = '/login'; + /// 登录后为尚未绑定的安装实例输入服务端设备 ID。 + static const deviceSetup = '/device-setup'; + + /// EY_PRINT 无人值守打印状态面板,不装载收银业务页面。 + static const printer = '/printer'; + /// 顾客屏全窗口预览,不装载终端侧栏和顶栏。 static const customer = '/customer'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index 5d140c7..8f30d60 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -20,6 +20,14 @@ class RoutePages { name: RouteNames.login, page: () => const LoginPage(), ), + GetPage( + name: RouteNames.deviceSetup, + page: () => const DeviceSetupPage(), + ), + GetPage( + name: RouteNames.printer, + page: () => const PrinterStatusPage(), + ), GetPage( name: RouteNames.customer, page: () => CustomerDisplayPreviewPage(arguments: Get.arguments), diff --git a/lib/common/services/database/dao/print_history_dao.dart b/lib/common/services/database/dao/print_history_dao.dart index 3cbfe6b..d1952c4 100644 --- a/lib/common/services/database/dao/print_history_dao.dart +++ b/lib/common/services/database/dao/print_history_dao.dart @@ -31,4 +31,24 @@ abstract class PrintHistoryDao { (:status = '-1' OR status = :status) ''') Future> getAll(String status); + + /// 以服务端任务 ID 唯一定位本地记录,作为 MQTT/HTTP 双通道去重依据。 + @Query( + 'SELECT * FROM print_history WHERE servicePrintId = :servicePrintId LIMIT 1') + Future findByServicePrintId(String servicePrintId); + + /// 查询等待补报成功或失败结果的任务。 + @Query( + 'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC') + Future> getPendingResults(); + + @Query("SELECT COUNT(*) FROM print_history WHERE status = 'NONE'") + Future countPending(); + + @Query("SELECT COUNT(*) FROM print_history WHERE status = 'ERROR'") + Future countFailed(); + + @Query( + "SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != '' ORDER BY id DESC LIMIT 1") + Future findLatestError(); } diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index 4f6a47d..6c4d6fe 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -48,7 +48,7 @@ const typeConverters = [ ]; /// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。 -@Database(version: 12, entities: entities) +@Database(version: 13, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { /// 取餐广播队列 DAO。 diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 84f6377..1d53a59 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -112,7 +112,7 @@ class _$AppDatabase extends AppDatabase { Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 12, + version: 13, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -148,7 +148,7 @@ class _$AppDatabase extends AppDatabase { await database.execute( 'CREATE TABLE IF NOT EXISTS `classify` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `sysType` TEXT NOT NULL, `name` TEXT NOT NULL, `categoryType` TEXT NOT NULL, `sort` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `delFlag` INTEGER NOT NULL)'); await database.execute( - 'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)'); + 'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `printType` TEXT NOT NULL, `printNo` INTEGER NOT NULL, `retryVersion` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `resultPending` INTEGER NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)'); await database.execute( @@ -167,6 +167,8 @@ class _$AppDatabase extends AppDatabase { 'CREATE INDEX `index_canteen_spu_combo_item_parentServiceUniqueCode_saleDate` ON `canteen_spu_combo_item` (`parentServiceUniqueCode`, `saleDate`)'); await database.execute( 'CREATE UNIQUE INDEX `index_canteen_spu_combo_item_parentServiceUniqueCode_saleDate_childServiceUniqueCode` ON `canteen_spu_combo_item` (`parentServiceUniqueCode`, `saleDate`, `childServiceUniqueCode`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_print_history_servicePrintId` ON `print_history` (`servicePrintId`)'); await database.execute( 'CREATE INDEX `index_scheduled_task_log_taskCode_startedAt` ON `scheduled_task_log` (`taskCode`, `startedAt`)'); await database.execute( @@ -1580,6 +1582,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1595,6 +1603,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1610,6 +1624,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1638,10 +1658,94 @@ class _$PrintHistoryDao extends PrintHistoryDao { Future> getAll(String status) async { return _queryAdapter.queryList( 'SELECT * FROM print_history WHERE (?1 = \'-1\' OR status = ?1)', - mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)), + mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, printType: row['printType'] as String, printNo: row['printNo'] as int, retryVersion: row['retryVersion'] as int, attemptCount: row['attemptCount'] as int, lastError: row['lastError'] as String?, resultPending: _boolConverter.decode(row['resultPending'] as int), status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)), arguments: [status]); } + @override + Future findByServicePrintId( + String servicePrintId) async { + return _queryAdapter.query( + 'SELECT * FROM print_history WHERE servicePrintId = ?1 LIMIT 1', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), + createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [servicePrintId]); + } + + @override + Future> getPendingResults() async { + return _queryAdapter.queryList( + 'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), + createTime: + _dateTimeConverter.decode(row['createTime'] as String))); + } + + @override + Future countPending() async { + return _queryAdapter.query( + 'SELECT COUNT(*) FROM print_history WHERE status = \'NONE\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future countFailed() async { + return _queryAdapter.query( + 'SELECT COUNT(*) FROM print_history WHERE status = \'ERROR\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future findLatestError() async { + return _queryAdapter.query( + 'SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != \'\' ORDER BY id DESC LIMIT 1', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), + createTime: + _dateTimeConverter.decode(row['createTime'] as String))); + } + @override Future save(PrintHistoryEntity entity) async { await _printHistoryEntityInsertionAdapter.insert( diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 11b153f..f02eb87 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -386,6 +386,30 @@ abstract class DatabaseMigration { ); }); + /// 版本13把旧发票记录升级为可幂等恢复、可补报结果的打印任务队列。 + static Migration migration12To13 = Migration(12, 13, (database) async { + await database.execute( + "ALTER TABLE print_history ADD COLUMN printType TEXT NOT NULL DEFAULT '1'"); + await database.execute( + 'ALTER TABLE print_history ADD COLUMN printNo INTEGER NOT NULL DEFAULT 1'); + await database.execute( + 'ALTER TABLE print_history ADD COLUMN retryVersion INTEGER NOT NULL DEFAULT 0'); + await database.execute( + 'ALTER TABLE print_history ADD COLUMN attemptCount INTEGER NOT NULL DEFAULT 0'); + await database + .execute('ALTER TABLE print_history ADD COLUMN lastError TEXT'); + await database.execute( + 'ALTER TABLE print_history ADD COLUMN resultPending INTEGER NOT NULL DEFAULT 0'); + // 旧版本没有唯一约束,先保留每个服务端任务最新一条,再建立幂等索引。 + await database.execute(''' + DELETE FROM print_history + WHERE id NOT IN (SELECT MAX(id) FROM print_history GROUP BY servicePrintId) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_print_history_servicePrintId ON print_history(servicePrintId)', + ); + }); + /// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。 static List getDatabaseMigrations() { return [ @@ -400,6 +424,7 @@ abstract class DatabaseMigration { migration9To10, migration10To11, migration11To12, + migration12To13, ]; } } diff --git a/lib/common/services/database/entity/print_history_entity.dart b/lib/common/services/database/entity/print_history_entity.dart index dcc44c7..6a46e4b 100644 --- a/lib/common/services/database/entity/print_history_entity.dart +++ b/lib/common/services/database/entity/print_history_entity.dart @@ -6,7 +6,12 @@ import 'package:pj_supermarket_cloud/common/index.dart'; part 'print_history_entity.g.dart'; @JsonSerializable() -@Entity(tableName: "print_history") +@Entity( + tableName: "print_history", + indices: [ + Index(value: ['servicePrintId'], unique: true) + ], +) class PrintHistoryEntity { /// 自增ID @PrimaryKey(autoGenerate: true) @@ -24,6 +29,18 @@ class PrintHistoryEntity { /// 发票图片地址 String invoiceUrl; + /// 服务端凭证类型、打印序号与重试版本。 + String printType; + int printNo; + int retryVersion; + + /// 当前重试版本实际执行的 USB 尝试次数和最近错误。 + int attemptCount; + String? lastError; + + /// 打印结果尚未可靠提交到服务端时保持为 true,网络恢复后继续补报。 + bool resultPending; + /// 打印状态 PrintStatus status; @@ -42,6 +59,12 @@ class PrintHistoryEntity { required this.serviceOrderId, required this.servicePrintId, required this.invoiceUrl, + this.printType = '1', + this.printNo = 1, + this.retryVersion = 0, + this.attemptCount = 0, + this.lastError, + this.resultPending = false, required this.status, this.printTime, this.syncTime, diff --git a/lib/common/services/database/entity/print_history_entity.g.dart b/lib/common/services/database/entity/print_history_entity.g.dart index 6c5fac9..70b5777 100644 --- a/lib/common/services/database/entity/print_history_entity.g.dart +++ b/lib/common/services/database/entity/print_history_entity.g.dart @@ -13,6 +13,12 @@ PrintHistoryEntity _$PrintHistoryEntityFromJson(Map json) => serviceOrderId: json['serviceOrderId'] as String, servicePrintId: json['servicePrintId'] as String, invoiceUrl: json['invoiceUrl'] as String, + printType: json['printType'] as String? ?? '1', + printNo: (json['printNo'] as num?)?.toInt() ?? 1, + retryVersion: (json['retryVersion'] as num?)?.toInt() ?? 0, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + lastError: json['lastError'] as String?, + resultPending: json['resultPending'] as bool? ?? false, status: $enumDecode(_$PrintStatusEnumMap, json['status']), printTime: json['printTime'] as String?, syncTime: json['syncTime'] == null @@ -28,6 +34,12 @@ Map _$PrintHistoryEntityToJson(PrintHistoryEntity instance) => 'serviceOrderId': instance.serviceOrderId, 'servicePrintId': instance.servicePrintId, 'invoiceUrl': instance.invoiceUrl, + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, + 'attemptCount': instance.attemptCount, + 'lastError': instance.lastError, + 'resultPending': instance.resultPending, 'status': _$PrintStatusEnumMap[instance.status]!, 'printTime': instance.printTime, 'syncTime': instance.syncTime?.toIso8601String(), diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index bc83875..3e13f7f 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -14,13 +14,16 @@ class AppMqTool { /// 校验网络和设备绑定后重建 MQTT 连接,并注册当前设备的消息监听。 static Future mqRefresh() async { + final app = Get.find(); + if (!app.authenticatedServicesEnabled) { + logger.w('|$_tag|当前没有有效登录会话,暂不连接消息服务'); + return; + } final isNetwork = AppNetworkTool.isAutoNetwork(); if (!isNetwork) { logger.e("|$_tag|mqtt监听消息错误没有网络"); return; } - final app = Get.find(); - final sysInfo = await AppSettingTool.getSysSetting(); if (ObjectUtil.isEmpty(sysInfo.serviceId) || ObjectUtil.isEmpty(sysInfo.merchantDeptId) || @@ -76,6 +79,9 @@ class AppMqTool { logger.e("|$_tag|mqtt监听消息错误error:|$e|"); } }); + if (sysInfo.terminalRole == TerminalRole.printer) { + unawaited(SyncPrintTool.pullPendingTasks()); + } } /// 根据协议动作分发已完成设备 Topic 校验的 MQTT 消息。 @@ -85,9 +91,21 @@ class AppMqTool { if (ObjectUtil.isEmpty(msg.action)) return; final app = Get.find(); final mqUpdateEventBus = app.mqUpdateEventBus; + final setting = await AppSettingTool.getSysSetting(); + if (setting.terminalRole == TerminalRole.printer && + msg.action != MqtActionEnum.CONFIG && + msg.action != MqtActionEnum.PRINT && + msg.action != MqtActionEnum.PULL_TERMINAL_LOG) { + logger.w('|$_tag|打印角色忽略非打印消息action:${msg.action}|'); + return; + } switch (msg.action!) { case MqtActionEnum.CONFIG: await SyncConfigTool.autoSyncSysInfo(); + if (setting.terminalRole == TerminalRole.printer) { + final bootstrap = await HardApi.getPrinterBootstrap(); + await AppSettingTool.updatePrinterBootstrap(bootstrap); + } mqUpdateEventBus.fire(MqtActionEnum.CONFIG); break; case MqtActionEnum.CANTEEN_PRODUCT: @@ -123,14 +141,19 @@ class AppMqTool { final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); if (ObjectUtil.isNotEmpty(msg.historyId) && ObjectUtil.isNotEmpty(msg.orderId) && - ObjectUtil.isNotEmpty(msg.androidOrderCode) && + setting.terminalRole == TerminalRole.printer && + (msg.targetHardId == null || + msg.targetHardId == setting.serviceId) && ObjectUtil.isNotEmpty(msg.pic)) { - SyncPrintTool.addTask( + unawaited(SyncPrintTool.addTask( orderId: msg.orderId!, - androidOrderId: msg.androidOrderCode!, + androidOrderId: msg.androidOrderCode, historyId: msg.historyId!, pic: msg.pic!, - ); + printType: msg.printType ?? '1', + printNo: msg.printNo ?? 1, + retryVersion: msg.retryVersion ?? 0, + )); } break; case MqtActionEnum.TAKE_FOOD_BROADCAST: diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index e06fc62..8d0378a 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:common_utils/common_utils.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; @@ -89,15 +90,25 @@ abstract class AppNetworkTool { /// 网络恢复后的消息连接和离线订单同步允许失败,不能产生未处理异常中断应用。 static Future _restoreNetworkServices() async { + final app = Get.find(); + if (!app.authenticatedServicesEnabled) return; + final userSetting = await AppSettingTool.getUserSetting(); + // 登录页只需要网络状态;退出后禁止网络变化重新连接 MQTT 或调用打印待办接口。 + if (ObjectUtil.isEmpty(userSetting.accessToken)) return; try { await AppMqTool.mqRefresh(); + await SyncPrintTool.pullPendingTasks(); + await SyncPrintTool.doCheckSync(); } catch (e, stack) { logger.e('|$_tag|网络恢复后连接消息服务失败error:|$e|stack:|$stack|'); } - try { - await SyncOrderTool.syncOfflineOrder(); - } catch (e, stack) { - logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|'); + final setting = await AppSettingTool.getSysSetting(); + if (setting.terminalRole == TerminalRole.cashier) { + try { + await SyncOrderTool.syncOfflineOrder(); + } catch (e, stack) { + logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|'); + } } } diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 6915bdc..3c05170 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -755,9 +755,14 @@ abstract class AppSettingTool { } final type = data.type.toString(); if (ObjectUtil.isNotEmpty(type)) { - final t = SysType.values.fromCode(type); - if (ObjectUtil.isNotEmpty(t)) { - sysSetting.sysType = t!; + if (type == '12') { + sysSetting.terminalRole = TerminalRole.printer; + } else { + final t = SysType.values.fromCode(type); + if (ObjectUtil.isNotEmpty(t)) { + sysSetting.sysType = t!; + sysSetting.terminalRole = TerminalRole.cashier; + } } } final merchantId = data.merchantId; @@ -816,6 +821,35 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysSetting); } + /// 将服务端校验过的 EY_PRINT 一对一绑定和打印参数持久化到本机。 + static Future updatePrinterBootstrap( + Map bootstrap, + ) async { + final setting = await getSysSetting(); + final sourceType = bootstrap['sourceHardType']?.toString(); + final businessType = SysType.values.fromCode(sourceType ?? ''); + if (businessType == null) { + throw const FormatException('打印终端绑定来源不是超市或食堂设备'); + } + final config = bootstrap['printerConfig'] is Map + ? Map.from(bootstrap['printerConfig'] as Map) + : {}; + setting.terminalRole = TerminalRole.printer; + setting.sysType = businessType; + setting.printerSourceHardId = bootstrap['sourceHardId']?.toString(); + setting.printerSourceHardName = bootstrap['sourceHardName']?.toString(); + setting.printerModel = config['printerModel']?.toString(); + setting.printerUsbVendorId = int.tryParse('${config['usbVendorId'] ?? ''}'); + setting.printerUsbProductId = + int.tryParse('${config['usbProductId'] ?? ''}'); + setting.printerPaperWidth = + int.tryParse('${config['paperWidth'] ?? ''}') ?? 80; + setting.printerAutoCut = '${config['autoCut'] ?? '1'}' != '0'; + setting.printerImageMode = config['imageMode']?.toString() ?? 'GS_V0'; + await _setSetting(sysJson: setting); + return setting; + } + /// 绑定设备信息 static Future bindSys(int serviceId) async { var androidId = await AppSysTool.getAndroidId(); diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index d208755..85233cb 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:common_utils/common_utils.dart'; import 'package:event_bus/event_bus.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -97,6 +98,12 @@ class AppControl extends GetxController { bool _initializing = false; bool _databaseInitialized = false; + /// 当前账号是否允许启动需要鉴权或设备身份的 MQTT、心跳和后台同步任务。 + bool _authenticatedServicesEnabled = false; + + /// 网络监听和消息连接在执行前读取的会话开关;退出动作会先关闭它再释放资源。 + bool get authenticatedServicesEnabled => _authenticatedServicesEnabled; + int get startupCompletedCount => startupSteps .where((item) => item.status == AppStartupStepStatus.success || @@ -129,6 +136,7 @@ class AppControl extends GetxController { @override void onClose() { super.onClose(); + _authenticatedServicesEnabled = false; if (printManageInitialized) printManage.close(); if (serialCardManageInitialized) serialCardManage.close(); if (steelyardManageInitialized) srialSteelyardManage.close(); @@ -184,9 +192,6 @@ class AppControl extends GetxController { detail: '正在读取运行环境与本地终端配置', ); await AppRuntimeTool.initializePlatform(); - // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, - // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 - unawaited(AppSubScreenTool.show()); await AppSettingTool.removeSettingCache(); _setStartupStep( @@ -199,13 +204,22 @@ class AppControl extends GetxController { _databaseInitialized = true; } - var storageGranted = - await Permission.manageExternalStorage.request().isGranted; - if (!storageGranted) { - storageGranted = await Permission.storage.request().isGranted; - } - final setting = await AppSettingTool.getSysSetting(); + final isPrinterTerminal = setting.terminalRole == TerminalRole.printer; + // 打印终端没有顾客副屏,不能在启动时创建第二 Flutter Engine。 + if (!isPrinterTerminal) { + // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, + // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 + unawaited(AppSubScreenTool.show()); + } + var storageGranted = true; + if (!isPrinterTerminal) { + storageGranted = + await Permission.manageExternalStorage.request().isGranted; + if (!storageGranted) { + storageGranted = await Permission.storage.request().isGranted; + } + } AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); final environment = _resolveEnvironment(setting); startupEnvironmentDescription.value = environment.title; @@ -230,28 +244,37 @@ class AppControl extends GetxController { detail: '正在创建打印、读卡与电子秤管理服务', ); if (!printManageInitialized) printManage = PrintManage(); - if (!serialCardManageInitialized) serialCardManage = SerialCardManage(); - if (!steelyardManageInitialized) { - srialSteelyardManage = SerialSteelyardManage(); - } await AppDeviceStatusTool.loadCachedStatus(); - // 硬件检查失败只能影响状态展示,不能阻塞收银终端进入业务页面。 - unawaited( - AppDeviceStatusTool.refreshAll() - .catchError((Object error, StackTrace stack) { - logger.w('|$_tag|首次检查设备状态失败error:|$error|stack:|$stack|'); - return AppDeviceStatusTool.status.value; - }), - ); - await TtsManage.initialize(); + if (isPrinterTerminal) { + // 打印角色只探测 USB 打印机,避免初始化读卡、电子秤、人脸和副屏插件。 + unawaited(printManage.checkAvailable().then( + (available) => AppDeviceStatusTool.updatePrinter(available), + )); + } else { + if (!serialCardManageInitialized) serialCardManage = SerialCardManage(); + if (!steelyardManageInitialized) { + srialSteelyardManage = SerialSteelyardManage(); + } + // 硬件检查失败只能影响状态展示,不能阻塞收银终端进入业务页面。 + unawaited( + AppDeviceStatusTool.refreshAll() + .catchError((Object error, StackTrace stack) { + logger.w('|$_tag|首次检查设备状态失败error:|$error|stack:|$stack|'); + return AppDeviceStatusTool.status.value; + }), + ); + await TtsManage.initialize(); + } _setStartupStep( 2, status: environment.manualHardwareInput ? AppStartupStepStatus.warning : AppStartupStepStatus.success, - detail: environment.manualHardwareInput - ? '当前为手动硬件输入模式,实体设备将在业务使用时按需连接' - : '设备管理服务已就绪,实体设备将在业务使用时按需连接', + detail: isPrinterTerminal + ? 'USB 打印服务已就绪,未启动收银外设' + : environment.manualHardwareInput + ? '当前为手动硬件输入模式,实体设备将在业务使用时按需连接' + : '设备管理服务已就绪,实体设备将在业务使用时按需连接', ); _setStartupStep( @@ -259,19 +282,28 @@ class AppControl extends GetxController { status: AppStartupStepStatus.running, detail: '正在检查激活参数并装载本地人脸库', ); - final facePreparation = await AppFaceTool.prepareForStartup(); - faceStartupPreparationResult.value = facePreparation; - _setStartupStep( - 3, - status: facePreparation.status == FaceStartupPreparationStatus.success - ? AppStartupStepStatus.success - : AppStartupStepStatus.warning, - detail: _facePreparationDetail(facePreparation), - ); + if (isPrinterTerminal) { + _setStartupStep( + 3, + status: AppStartupStepStatus.success, + detail: '打印角色无需加载人脸识别能力', + ); + } else { + final facePreparation = await AppFaceTool.prepareForStartup(); + faceStartupPreparationResult.value = facePreparation; + _setStartupStep( + 3, + status: facePreparation.status == FaceStartupPreparationStatus.success + ? AppStartupStepStatus.success + : AppStartupStepStatus.warning, + detail: _facePreparationDetail(facePreparation), + ); + } - final backupSucceeded = - storageGranted ? await AppFileTool.backupSqlFile() : false; - if (storageGranted && !backupSucceeded) { + final backupSucceeded = !isPrinterTerminal && storageGranted + ? await AppFileTool.backupSqlFile() + : true; + if (!isPrinterTerminal && storageGranted && !backupSucceeded) { _setStartupStep( 1, status: AppStartupStepStatus.warning, @@ -283,9 +315,16 @@ class AppControl extends GetxController { if (AppNetworkTool.subscription == null) { AppNetworkTool.listenNetwork(); } - await SyncCheckTool.instance.startAll(); + final userSetting = await AppSettingTool.getUserSetting(); + _authenticatedServicesEnabled = + ObjectUtil.isNotEmpty(userSetting.accessToken); + if (_authenticatedServicesEnabled) { + await SyncCheckTool.instance.startAll( + terminalRole: setting.terminalRole, + ); + TerminalHeartbeatTool.start(); + } await TerminalAutoUpdateTool.recoverPendingUpdate(); - TerminalHeartbeatTool.start(); startupFinished.value = true; startupStatusLabel.value = startupSteps @@ -321,8 +360,25 @@ class AppControl extends GetxController { await initAsync(destination: destination, force: true); } + /// 登录或设备角色切换后恢复该角色允许的后台任务与在线心跳。 + Future resumeAuthenticatedServices(TerminalRole terminalRole) async { + _authenticatedServicesEnabled = true; + await SyncCheckTool.instance.startAll(terminalRole: terminalRole); + TerminalHeartbeatTool.start(); + } + + /// 退出登录时停止所有需要账号或设备身份的后台任务,并断开消息订阅。 + Future suspendAuthenticatedServices() async { + _authenticatedServicesEnabled = false; + await SyncCheckTool.instance.dispose(); + TerminalHeartbeatTool.stop(); + AppMqTool.mqListenClose(); + await MqManager.closeMqtt(); + } + /// 关闭已创建的设备和后台资源,供核心启动异常后的重试流程重新初始化。 void _closePartiallyInitializedResources() { + _authenticatedServicesEnabled = false; if (printManageInitialized) printManage.close(); if (serialCardManageInitialized) serialCardManage.close(); if (steelyardManageInitialized) srialSteelyardManage.close(); @@ -374,6 +430,14 @@ class AppControl extends GetxController { String stepDetail, bool manualHardwareInput, }) _resolveEnvironment(SysSetting setting) { + if (setting.terminalRole == TerminalRole.printer) { + return ( + title: '打印终端模式', + detail: '仅启用网络、MQTT、本地队列和 USB 打印能力。', + stepDetail: '已读取尔印打印终端配置', + manualHardwareInput: false, + ); + } final manualHardwareInput = AppRuntimeTool.isEmulator || setting.manualHardwareInput; if (AppRuntimeTool.isEmulator) { diff --git a/lib/common/utils/manage/print_manage.dart b/lib/common/utils/manage/print_manage.dart index 4c055e1..72a4a78 100644 --- a/lib/common/utils/manage/print_manage.dart +++ b/lib/common/utils/manage/print_manage.dart @@ -1,38 +1,63 @@ import 'package:common_utils/common_utils.dart'; import 'package:drago_pos_printer/drago_pos_printer.dart'; import 'package:get/get.dart'; +import 'package:image/image.dart' as imglib; import 'package:pj_supermarket_cloud/common/index.dart'; +/// USB ESC/POS 打印机管理器,负责设备发现、连接、图片指令生成和完整字节写入。 class PrintManage extends BaseFacilityManage { + /// 打印机发现、连接和输出日志使用的稳定模块标签。 static const String _tag = "打印机管理"; + /// 当前已选择并连接的 USB 打印管理器;未连接时为空。 USBPrinterManager? manager; + + /// 当前 ESC/POS 能力配置,成功打开打印机后加载。 CapabilityProfile? profile; + + /// ESC/POS 生成器使用的纸宽,首期固定为 80mm。 int paperSizeWidthMM = PaperSizeWidth.mm80; + + /// 80mm 纸宽对应的文本列数,由图片模式之外的兼容命令使用。 int maxPerLine = PaperSizeMaxPerLine.mm80; + /// 最近一次发现、连接、下载或 USB 写入错误,供无人值守状态面板展示。 + String? lastError; + + /// 历史收银设备自动发现使用的已知 Vendor ID;后台未配置 VID/PID 时才允许回退。 static const List _printVendorIds = [ "19267", "4070", "10473", ]; + /// 返回设备状态体系中使用的打印机名称。 @override String setName() => UsbFacilityNames.print.title; /// 只发现 USB 设备,不建立打印连接,避免后台状态任务占用实际打印通道。 Future checkAvailable() async { try { + final setting = await AppSettingTool.getSysSetting(); final printers = await USBPrinterManager.discover(); - return _findTarget(printers) != null; + return _findTarget(printers, setting) != null; } catch (e) { + lastError = e.toString(); logger.e("|$_tag|检查打印机失败error|$e|"); return false; } } - USBPrinter? _findTarget(List printers) { + /// 优先按后台 VID/PID 精确匹配;未配置时才沿用已知厂商或唯一设备回退规则。 + USBPrinter? _findTarget(List printers, SysSetting setting) { + final configuredVendor = setting.printerUsbVendorId; + final configuredProduct = setting.printerUsbProductId; + if (configuredVendor != null && configuredProduct != null) { + return printers.firstWhereOrNull((printer) => + printer.vendorId == configuredVendor && + printer.productId == configuredProduct); + } USBPrinter? target = printers.firstWhereOrNull((printer) { return _printVendorIds.contains(printer.vendorId?.toString() ?? "-1"); }); @@ -40,6 +65,7 @@ class PrintManage extends BaseFacilityManage { return target; } + /// 打开目标 USB 打印机并装载 ESC/POS 能力;失败只更新设备状态,不改变订单结果。 @override Future open() async { final check = await super.open(); @@ -48,8 +74,13 @@ class PrintManage extends BaseFacilityManage { return false; } try { - final target = _findTarget(await USBPrinterManager.discover()); + lastError = null; + final setting = await AppSettingTool.getSysSetting(); + final target = _findTarget(await USBPrinterManager.discover(), setting); if (target == null) { + lastError = setting.printerUsbVendorId == null + ? '未发现可用 USB 打印机' + : '未发现后台配置 VID/PID 对应的 USB 打印机'; AppDeviceStatusTool.updatePrinter(false); return false; } @@ -59,24 +90,36 @@ class PrintManage extends BaseFacilityManage { AppDeviceStatusTool.updatePrinter(true); return true; } catch (e) { + lastError = e.toString(); AppDeviceStatusTool.updatePrinter(false); logger.e("|$_tag|打开打印机失败error|$e|"); return false; } } - /// 下载发票图片并写入已连接的 USB 打印机;仅实际写入周期在顶栏展示“打印中”。 + /// 下载打印凭证图片,按 576px 等比缩放后生成 GS v 0 或 ESC * 字节并完整写入 USB。 + /// + /// 返回 true 只表示图片、走纸和可选切刀命令均被 USB 接口接受,不代表从硬件层确认出纸。 Future printImg(String img) async { if (ObjectUtil.isEmpty(manager)) { AppDeviceStatusTool.updatePrinterWorking(false); return false; } try { - final image = await AppNetworkTool.loadImageFromNetwork(img); - if (ObjectUtil.isEmpty(image)) { - Toast.error(msg: "找不到发票图片"); + final imageBytes = await AppNetworkTool.downloadImage(img); + final image = imageBytes == null ? null : imglib.decodeImage(imageBytes); + if (image == null) { + lastError = '无法下载或解码打印图片'; return false; } + final setting = await AppSettingTool.getSysSetting(); + final normalized = image.width == 576 + ? image + : imglib.copyResize( + image, + width: 576, + interpolation: imglib.Interpolation.linear, + ); final generator = EscGenerator( paperSizeWidthMM, maxPerLine, @@ -84,15 +127,19 @@ class PrintManage extends BaseFacilityManage { ); final bytes = [ ...generator.feed(1), - ...generator.image(image!), + ...(setting.printerImageMode == 'ESC_STAR' + ? generator.image(normalized) + : generator.imageRaster(normalized)), ...generator.feed(1), - ...generator.cut(), + if (setting.printerAutoCut) ...generator.cut(), ]; AppDeviceStatusTool.updatePrinterWorking(true); await manager!.writeBytes(bytes); + lastError = null; AppDeviceStatusTool.updatePrinter(true); return true; } catch (e) { + lastError = e.toString(); AppDeviceStatusTool.updatePrinter(false); logger.e("|$_tag|打印机失败error|$e|"); return false; @@ -101,22 +148,25 @@ class PrintManage extends BaseFacilityManage { } } + /// 关闭当前 USB 连接并清除“打印中”状态;重复调用不报错。 @override Future close() async { AppDeviceStatusTool.updatePrinterWorking(false); if (ObjectUtil.isNotEmpty(manager)) { if (manager != null) { - manager?.disconnect(); + await manager?.disconnect(); } manager = null; } } + /// USB 授权由项目内 Android 插件在连接时申请,此处无需前置 Flutter 权限。 @override Future checkPermission() async { return true; } + /// USB 授权由项目内 Android 插件负责,保留基类接口并返回可继续连接。 @override Future applyPermission() async { return true; diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index ac40cf1..56abc1c 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -19,6 +19,16 @@ class SyncCheckTool { /// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。 static final SyncCheckTool instance = SyncCheckTool._(); + /// 打印角色允许注册的后台任务;其余商品、人脸、订单和取餐任务属于收银角色。 + static const Set _printerTaskTypes = { + ScheduledTaskType.mqReconnect, + ScheduledTaskType.printCheck, + ScheduledTaskType.deviceStatus, + ScheduledTaskType.autoUpdate, + ScheduledTaskType.terminalLogUpload, + ScheduledTaskType.expiredLogCleanup, + }; + /// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。 final configs = {}.obs; @@ -52,6 +62,16 @@ class SyncCheckTool { /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 bool _started = false; + /// 当前调度任务对应的终端角色;设备首次绑定改变角色时据此重建任务集合。 + TerminalRole? _activeRole; + + /// 当前已经注册的终端角色,供状态诊断和测试确认角色切换结果。 + TerminalRole? get activeRole => _activeRole; + + /// 当前注册到 Cron 的任务类型快照。 + Set get scheduledTaskTypes => + Set.unmodifiable(_scheduledTasks.keys); + /// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。 DateTime? _lastDepartmentFaceCatchUpDate; @@ -333,19 +353,33 @@ class SyncCheckTool { ), ]; - /// 初始化结构化配置并注册全部已启用任务;同一生命周期内重复调用直接返回。 - Future startAll() async { - if (_started) return; + /// 初始化结构化配置并注册全部已启用任务;角色改变时先释放旧角色任务再重新注册。 + Future startAll( + {TerminalRole terminalRole = TerminalRole.cashier}) async { await _disposeFuture; + if (_started && _activeRole == terminalRole) return; + if (_started) await dispose(); _started = true; + _activeRole = terminalRole; _cron ??= Cron(); await _loadState(); for (final config in configs.values) { + if (!supportsTask(terminalRole, config.type)) { + continue; + } await _rebuildTask(config); } - queueMissedDepartmentFaceSync(); + if (terminalRole == TerminalRole.cashier) { + queueMissedDepartmentFaceSync(); + } } + /// 判断指定角色是否允许执行某类后台任务,打印角色只保留通信、打印和运维任务。 + static bool supportsTask( + TerminalRole terminalRole, ScheduledTaskType taskType) => + terminalRole == TerminalRole.cashier || + _printerTaskTypes.contains(taskType); + /// 在每天 00:40 之后检查当天是否成功完成部门人脸同步,并在首次启动或恢复时补排一次。 /// /// 补排只加入同一人脸任务队列并要求完整空闲,不会阻塞启动或打断支付、识别和业务弹框。 @@ -787,6 +821,7 @@ class SyncCheckTool { if (!AppNetworkTool.isAutoNetwork()) { return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); } + await SyncPrintTool.pullPendingTasks(); await SyncPrintTool.doCheckPrint(); await SyncPrintTool.doCheckSync(); return const _ScheduledTaskActionResult.success('打印任务检查完成'); @@ -804,7 +839,14 @@ class SyncCheckTool { ); return const _ScheduledTaskActionResult.success('取餐广播检查完成'); case ScheduledTaskType.deviceStatus: - await AppDeviceStatusTool.refreshAll(); + final setting = await AppSettingTool.getSysSetting(); + if (setting.terminalRole == TerminalRole.printer) { + final available = + await Get.find().printManage.checkAvailable(); + AppDeviceStatusTool.updatePrinter(available); + } else { + await AppDeviceStatusTool.refreshAll(); + } return const _ScheduledTaskActionResult.success('设备状态检查完成'); case ScheduledTaskType.autoUpdate: final update = await TerminalAutoUpdateTool.run(); @@ -1044,6 +1086,7 @@ class SyncCheckTool { /// 取消轮询和全部 Cron 句柄、清空待执行状态并关闭 Cron 实例。 Future _dispose() async { _started = false; + _activeRole = null; _idleTimer?.cancel(); _idleTimer = null; for (final task in _scheduledTasks.values) { diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart index e076829..95bdfb6 100644 --- a/lib/common/utils/sync/sync_print_tool.dart +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -1,132 +1,289 @@ -import 'dart:convert'; +import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// EY_PRINT 本地打印队列,负责双通道入队、版本幂等、串行 USB 重试和结果补报。 abstract class SyncPrintTool { - static const _tag = "sync打印工具"; + /// 打印队列诊断日志使用的稳定模块标签。 + static const _tag = 'sync打印工具'; + /// 是否已有串行打印循环正在执行,防止两个任务同时占用 USB 通道。 static bool _isPrint = false; + /// 是否已有打印结果补报正在执行,防止网络恢复和定时任务重复提交。 static bool _isSync = false; + /// 是否已有 HTTP 待办补拉正在执行,防止 MQTT 重连和页面恢复重复拉取。 + static bool _isPulling = false; + + /// 将 MQTT 与 HTTP 的并发入队串行化,避免唯一索引插入竞争。 + static Future _enqueueTail = Future.value(); + + /// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。 + /// + /// 方案同时限定“最多三次 USB 尝试”,因此不会再安排会形成第 4 次出纸的 120 秒重试。 + static const attemptDelays = [ + Duration.zero, + Duration(seconds: 5), + Duration(seconds: 30), + ]; + + /// 将 MQTT 或 HTTP 待办按服务端任务 ID 转换为唯一的本地任务。 + /// + /// [orderId] 是服务端订单 ID,[androidOrderId] 允许线上预约订单为空;[historyId] + /// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列。 static Future addTask({ required String orderId, - required String androidOrderId, + String? androidOrderId, required String historyId, required String pic, + String printType = '1', + int printNo = 1, + int retryVersion = 0, }) async { - final appC = Get.find(); - final printHistoryDao = appC.dataBase.getPrintHistoryDao; - final saveObj = PrintHistoryEntity( - orderId: androidOrderId, - serviceOrderId: orderId, - servicePrintId: historyId, - invoiceUrl: pic, - status: PrintStatus.NONE, - createTime: DateTime.now(), + final operation = _enqueueTail.then((_) => _addTask( + orderId: orderId, + androidOrderId: androidOrderId, + historyId: historyId, + pic: pic, + printType: printType, + printNo: printNo, + retryVersion: retryVersion, + )); + // 队尾吞掉上一项异常以保证后续任务仍可入队,当前调用者仍等待原 operation 获得真实结果。 + _enqueueTail = operation.then( + (_) {}, + onError: (Object _, StackTrace __) {}, ); - await printHistoryDao.save(saveObj); - logger.i("|$_tag|添加到打印记录到本地数据库,json:|${saveObj.toJson()}|"); - await SyncPrintTool.doCheckPrint(); + await operation; } - // 检查打印 - static Future doCheckPrint() async { - if (_isPrint) { - logger.i("|$_tag|检查打印,正在打印中"); + /// 在串行入队区内执行任务新增或失败版本更新。 + static Future _addTask({ + required String orderId, + String? androidOrderId, + required String historyId, + required String pic, + required String printType, + required int printNo, + required int retryVersion, + }) async { + final dao = Get.find().dataBase.getPrintHistoryDao; + final existing = await dao.findByServicePrintId(historyId); + if (existing != null) { + if (!canApplyTaskVersion(existing, retryVersion)) { + if (retryVersion > existing.retryVersion && + existing.status != PrintStatus.ERROR) { + logger.w( + '|$_tag|忽略非失败任务的超前版本|task:$historyId|status:${existing.status.name}|'); + return; + } + logger.i('|$_tag|忽略重复打印任务|task:$historyId|version:$retryVersion|'); + return; + } + existing + ..orderId = androidOrderId?.trim().isNotEmpty == true + ? androidOrderId! + : orderId + ..serviceOrderId = orderId + ..invoiceUrl = pic + ..printType = printType + ..printNo = printNo + ..retryVersion = retryVersion + ..attemptCount = 0 + ..lastError = null + ..resultPending = false + ..status = PrintStatus.NONE + ..printTime = null + ..syncTime = null; + await dao.edit(existing); + } else { + await dao.save(PrintHistoryEntity( + orderId: androidOrderId?.trim().isNotEmpty == true + ? androidOrderId! + : orderId, + serviceOrderId: orderId, + servicePrintId: historyId, + invoiceUrl: pic, + printType: printType, + printNo: printNo, + retryVersion: retryVersion, + status: PrintStatus.NONE, + createTime: DateTime.now(), + )); + } + logger.i('|$_tag|打印任务已入队|task:$historyId|version:$retryVersion|'); + unawaited(doCheckPrint()); + } + + /// 只有本地任务已失败且服务端版本更高时,才允许同一任务重新出纸。 + static bool canApplyTaskVersion( + PrintHistoryEntity existing, int incomingVersion) => + existing.status == PrintStatus.ERROR && + incomingVersion > existing.retryVersion; + + /// 在登录、MQTT 重连、应用恢复和周期检查时补拉本机待办。 + static Future pullPendingTasks() async { + if (_isPulling || !AppNetworkTool.isAutoNetwork()) { return; } - final appC = Get.find(); - final printHistoryDao = appC.dataBase.getPrintHistoryDao; - final printFacility = appC.printManage; + final setting = await AppSettingTool.getSysSetting(); + if (setting.terminalRole != TerminalRole.printer || + setting.serviceId == null) { + return; + } + _isPulling = true; try { - // 检查有没有需要打印的 - final list = await printHistoryDao.getAll(PrintStatus.NONE.name); - if (ObjectUtil.isEmptyList(list)) { - _isPrint = false; - logger.i("|$_tag|检查打印,没有需要打印的发票"); - return; + final tasks = await HardApi.getPrinterTasks(); + for (final task in tasks) { + if (task.historyId == null || + task.orderId == null || + task.pic == null) { + continue; + } + await addTask( + orderId: task.orderId!, + androidOrderId: task.androidOrderCode, + historyId: task.historyId!, + pic: task.pic!, + printType: task.printType ?? '1', + printNo: task.printNo ?? 1, + retryVersion: task.retryVersion ?? 0, + ); } - final isOpen = await printFacility.open(); - if (!isOpen) { - _isPrint = false; - logger.e("|$_tag|检查打印,打开打印机失败"); - return; + } catch (error, stack) { + logger.w('|$_tag|补拉打印待办失败|error:$error|stack:$stack|'); + } finally { + _isPulling = false; + } + } + + /// 串行处理全部待打印任务,同一时间只允许一个 USB 写入流程。 + static Future doCheckPrint() async { + if (_isPrint) { + return; + } + _isPrint = true; + final dao = Get.find().dataBase.getPrintHistoryDao; + try { + // 循环查库可接住打印期间新入队的 MQTT/HTTP 任务,不必等待下一次十分钟补偿扫描。 + while (true) { + final list = await dao.getAll(PrintStatus.NONE.name); + if (list.isEmpty) { + break; + } + for (final task in list) { + await _print(task); + } } - for (final v in list) { - await doPrint(v); - } - } catch (e) { - logger.e("|$_tag|检查打印,打印错误,error:|$e|"); + } catch (error, stack) { + logger.e('|$_tag|打印队列执行异常|error:$error|stack:$stack|'); } finally { _isPrint = false; } } - // 检查同步 + /// 补报本地已经完成的成功或失败结果,断网不会导致再次出纸。 static Future doCheckSync() async { - if (_isSync) { - logger.i("|$_tag|检查打印,正在打印中"); + if (_isSync || !AppNetworkTool.isAutoNetwork()) { return; } - final app = Get.find(); - final printHistoryDao = app.dataBase.getPrintHistoryDao; - final list = await printHistoryDao.getAll(PrintStatus.SUCCESS.name); - if (ObjectUtil.isEmptyList(list)) { - _isSync = false; - return; - } - for (final v in list) { - final printPa = { - "orderId": v.serviceOrderId, - "historyId": v.servicePrintId, - }; - try { - final flag = await HardApi.printInvoice(printPa); - if (!flag) Exception("|$_tag|同步失败api错误params|${v.toJson()}|"); - v.status = PrintStatus.SYNC; - v.syncTime = DateTime.now(); - await printHistoryDao.edit(v); - } catch (e) { - logger.e("|$_tag|检查同步,打印错误,error:|$e|"); - v.status = PrintStatus.ERROR; - await printHistoryDao.edit(v); - } finally { - _isSync = false; + _isSync = true; + final dao = Get.find().dataBase.getPrintHistoryDao; + try { + for (final task in await dao.getPendingResults()) { + await _report(task); } + } finally { + _isSync = false; } } - // 执行打印 - static Future doPrint(PrintHistoryEntity v) async { + /// 串行执行单个任务的最多三次 USB 尝试,并在最终状态落库后补报服务端。 + static Future _print(PrintHistoryEntity task) async { final app = Get.find(); - final printHistoryDao = app.dataBase.getPrintHistoryDao; - final printFacility = app.printManage; - try { - final isPrint = await printFacility.printImg(v.invoiceUrl); - if (!isPrint) { - v.status = PrintStatus.ERROR; - await printHistoryDao.edit(v); - throw Exception("|$_tag|打印失败params|${v.toJson()}|"); + final dao = app.dataBase.getPrintHistoryDao; + for (var index = 0; index < attemptDelays.length; index++) { + final delay = attemptDelays[index]; + if (delay > Duration.zero) { + await Future.delayed(delay); } - // 更新本地数据库 - v.status = PrintStatus.SUCCESS; - v.printTime = DateUtil.formatDate(DateTime.now()); - await printHistoryDao.edit(v); - // 同步到服务端 - final printPa = { - "orderId": v.serviceOrderId, - "historyId": v.servicePrintId, - }; - await HardApi.printInvoice(printPa); - v.status = PrintStatus.SYNC; - v.syncTime = DateTime.now(); - await printHistoryDao.edit(v); - } catch (e) { - logger.i("|$_tag|执行打印,打印错误,error:|$e|"); + task.attemptCount = index + 1; + await dao.edit(task); + var opened = false; + try { + opened = await app.printManage.open(); + if (!opened) { + throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败'); + } + final accepted = await app.printManage.printImg(task.invoiceUrl); + if (!accepted) { + throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据'); + } + task + ..status = PrintStatus.SUCCESS + ..printTime = DateUtil.formatDate(DateTime.now()) + ..lastError = null + ..resultPending = true; + await dao.edit(task); + await _report(task); + return; + } catch (error, stack) { + task.lastError = _errorText(error); + await dao.edit(task); + logger.w( + '|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|'); + } finally { + if (opened) { + await app.printManage.close(); + } + } + } + task + ..status = PrintStatus.ERROR + ..resultPending = true; + await dao.edit(task); + await _report(task); + } + + /// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。 + static Future _report(PrintHistoryEntity task) async { + if (!AppNetworkTool.isAutoNetwork()) { + return; + } + final success = + task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC; + try { + final accepted = await HardApi.reportPrinterResult( + taskId: task.servicePrintId, + orderId: task.serviceOrderId, + retryVersion: task.retryVersion, + success: success, + attemptCount: task.attemptCount, + errorMessage: success ? null : task.lastError, + ); + if (!accepted) { + throw StateError('服务端拒绝打印结果'); + } + task + ..resultPending = false + ..syncTime = DateTime.now(); + if (success) { + task.status = PrintStatus.SYNC; + } + await Get.find().dataBase.getPrintHistoryDao.edit(task); + } catch (error, stack) { + logger.w( + '|$_tag|打印结果等待补报|task:${task.servicePrintId}|error:$error|stack:$stack|'); } } + + /// 将本地异常压缩成最多 500 个字符的单行可上报文本,避免持久化堆栈或敏感上下文。 + static String _errorText(Object error) { + final text = error.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + return text.length <= 500 ? text : text.substring(0, 500); + } } diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 72c24a3..e22da94 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -15,8 +15,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - // static const apiUrl = 'https://www.zianxin.com/api'; + // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/index.dart b/lib/pages/index.dart index 2b98a5c..e37c13b 100644 --- a/lib/pages/index.dart +++ b/lib/pages/index.dart @@ -4,4 +4,6 @@ export './root/load/controller.dart'; export './root/load/view.dart'; export './root/login/controller.dart'; export './root/login/view.dart'; +export './root/device_setup/view.dart'; +export './printer/view.dart'; export './terminal/view.dart'; diff --git a/lib/pages/printer/view.dart b/lib/pages/printer/view.dart new file mode 100644 index 0000000..8b36443 --- /dev/null +++ b/lib/pages/printer/view.dart @@ -0,0 +1,438 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mqtt_client/mqtt_client.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// EY_PRINT 专用状态面板,只承载绑定、连接、队列和故障恢复信息。 +class PrinterStatusPage extends StatefulWidget { + /// 创建打印终端常驻状态页。 + const PrinterStatusPage({super.key}); + + /// 创建负责生命周期恢复、轮询和本地队列统计的页面状态。 + @override + State createState() => _PrinterStatusPageState(); +} + +/// 打印终端状态页的进程内状态,页面销毁时必须取消轮询和生命周期观察。 +class _PrinterStatusPageState extends State + with WidgetsBindingObserver { + /// 每 30 秒刷新一次本地 USB 与队列状态的页面级计时器。 + Timer? _timer; + + /// 最近一次从本地设置读取的绑定和打印机配置快照。 + SysSetting? _setting; + + /// 本机尚未完成 USB 输出的任务数量。 + int _pending = 0; + + /// 本机等待后台人工重试的失败任务数量。 + int _failed = 0; + + /// 最近一次本地打印或连接错误;为空表示暂无已知错误。 + String? _lastError; + + /// 当前配置的 VID/PID 或自动发现的唯一打印机是否在线。 + bool _usbAvailable = false; + + /// 是否正在执行服务端配置刷新、MQTT 重连和待办补拉。 + bool _refreshing = false; + + /// 退出流程开始后为 true,用于阻止尚未结束的恢复任务重新连接消息服务。 + bool _sessionEnding = false; + + /// 注册应用恢复监听,并启动首次恢复和页面状态轮询。 + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + unawaited(_restoreConnections()); + _timer = Timer.periodic( + const Duration(seconds: 30), (_) => unawaited(_refreshStatus())); + } + + /// 取消所有页面级资源,避免退出后继续刷新已销毁的 State。 + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _timer?.cancel(); + super.dispose(); + } + + /// 应用回到前台时恢复 MQTT、HTTP 待办和待补报结果。 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_restoreConnections()); + } + } + + /// 刷新服务端绑定配置、恢复 MQTT,并按顺序补拉、补报和启动本地打印。 + Future _restoreConnections() async { + if (_refreshing || _sessionEnding) { + return; + } + _refreshing = true; + if (mounted) setState(() {}); + try { + if (AppNetworkTool.isAutoNetwork()) { + final bootstrap = await HardApi.getPrinterBootstrap(); + if (_sessionEnding) return; + await AppSettingTool.updatePrinterBootstrap(bootstrap); + if (_sessionEnding) return; + await AppMqTool.mqRefresh(); + if (_sessionEnding) return; + await SyncPrintTool.pullPendingTasks(); + await SyncPrintTool.doCheckSync(); + } + if (_sessionEnding) return; + unawaited(SyncPrintTool.doCheckPrint()); + } catch (error, stack) { + logger.w('|打印终端恢复连接失败|error:$error|stack:$stack|'); + _lastError = error.toString(); + } finally { + _refreshing = false; + if (!_sessionEnding) await _refreshStatus(); + } + } + + /// 汇总本地设置、USB 可用性和队列数量,并以一次 setState 更新面板。 + Future _refreshStatus() async { + if (_sessionEnding) return; + final app = Get.find(); + final dao = app.dataBase.getPrintHistoryDao; + final setting = await AppSettingTool.getSysSetting(); + final latestError = await dao.findLatestError(); + final usb = await app.printManage.checkAvailable(); + final pending = await dao.countPending() ?? 0; + final failed = await dao.countFailed() ?? 0; + if (!mounted) { + return; + } + setState(() { + _setting = setting; + _usbAvailable = usb; + _lastError = + latestError?.lastError ?? app.printManage.lastError ?? _lastError; + _pending = pending; + _failed = failed; + }); + } + + /// 展示由 PC 管理端维护的只读打印机配置摘要。 + Future _showSettings() async { + final setting = _setting ?? await AppSettingTool.getSysSetting(); + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('打印机配置'), + content: Text( + '型号:${setting.printerModel ?? "未填写"}\n' + 'USB VID/PID:${setting.printerUsbVendorId ?? "未填写"} / ${setting.printerUsbProductId ?? "未填写"}\n' + '纸宽:${setting.printerPaperWidth}mm\n' + '图片模式:${setting.printerImageMode}\n' + '自动切刀:${setting.printerAutoCut ? "开启" : "关闭"}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), child: const Text('关闭')) + ], + ), + ); + } + + /// 二次确认后关闭 MQTT、清理登录态并返回登录页。 + Future _logout() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('退出打印终端'), + content: const Text('退出后将停止接收新的打印任务。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('退出')), + ], + ), + ); + if (confirmed != true) return; + _sessionEnding = true; + await AppSettingTool.setUserSetting(UserSetting()); + await Get.find().suspendAuthenticatedServices(); + Get.offAllNamed(RouteNames.login); + } + + /// 构建打印角色专用的绑定、连接、队列和故障恢复面板。 + @override + Widget build(BuildContext context) { + final setting = _setting; + final mqtt = MqManager.state == MqttConnectionState.connected; + return Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.print, + size: 36, color: XfjDesignTokens.primary), + const SizedBox(width: 14), + const Expanded( + child: Text('尔印打印终端', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: XfjDesignTokens.ink))), + Obx(() { + final network = Get.find().isNetwork.value; + return _StatusBadge( + label: network ? '网络正常' : '网络断开', + ok: network, + ); + }), + const SizedBox(width: 8), + _StatusBadge(label: mqtt ? 'MQTT 已连接' : 'MQTT 未连接', ok: mqtt), + const SizedBox(width: 8), + _StatusBadge( + label: _usbAvailable ? 'USB 已连接' : 'USB 未连接', + ok: _usbAvailable), + ], + ), + const SizedBox(height: 24), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: _Panel( + title: '设备绑定', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoRow( + label: '本机设备', + value: setting?.serviceName ?? '--'), + _InfoRow( + label: '设备 ID', + value: setting?.serviceId ?? '--'), + _InfoRow( + label: '来源终端', + value: setting?.printerSourceHardName ?? '--'), + _InfoRow( + label: '业务类型', + value: setting?.sysType.title ?? '--'), + _InfoRow( + label: '打印机型号', + value: setting?.printerModel ?? '未填写'), + ], + ), + ), + ), + const SizedBox(width: 18), + Expanded( + flex: 2, + child: Column( + children: [ + Expanded( + child: _MetricPanel( + label: '待处理任务', + value: _pending, + color: XfjDesignTokens.primary)), + const SizedBox(height: 18), + Expanded( + child: _MetricPanel( + label: '失败任务', + value: _failed, + color: + _failed > 0 ? Colors.red : Colors.green)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 18), + _Panel( + title: '最近错误', + child: Text(_lastError ?? '暂无错误', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _lastError == null + ? XfjDesignTokens.muted + : Colors.red.shade700)), + ), + const SizedBox(height: 18), + Row( + children: [ + FilledButton.icon( + onPressed: _refreshing ? null : _restoreConnections, + icon: const Icon(Icons.refresh), + label: Text(_refreshing ? '连接中' : '重新连接')), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _refreshing ? null : _restoreConnections, + icon: const Icon(Icons.replay), + label: const Text('同步并重试')), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _showSettings, + icon: const Icon(Icons.settings), + label: const Text('设置')), + const Spacer(), + TextButton.icon( + onPressed: _logout, + icon: const Icon(Icons.logout), + label: const Text('退出')), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + /// 创建固定高度的连接状态标签。 + const _StatusBadge({required this.label, required this.ok}); + + /// 状态标签文案。 + final String label; + + /// 是否使用正常状态颜色。 + final bool ok; + + /// 构建不会因文案变化而改变外部工具栏高度的状态标签。 + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: ok ? Colors.green.shade50 : Colors.red.shade50, + borderRadius: BorderRadius.circular(6)), + child: Text(label, + style: TextStyle( + color: ok ? Colors.green.shade800 : Colors.red.shade800, + fontWeight: FontWeight.w700)), + ); +} + +class _Panel extends StatelessWidget { + /// 创建与现有新 UI 主题一致的单层信息面板。 + const _Panel({required this.title, required this.child}); + + /// 面板标题。 + final String title; + + /// 面板正文内容。 + final Widget child; + + /// 构建带边框的单层内容容器。 + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w800)), + const SizedBox(height: 16), + child + ]), + ); +} + +class _MetricPanel extends StatelessWidget { + /// 创建填满父布局高度的队列数量面板。 + const _MetricPanel( + {required this.label, required this.value, required this.color}); + + /// 指标名称。 + final String label; + + /// 当前任务数量。 + final int value; + + /// 数值强调色。 + final Color color; + + /// 构建独立指标卡;不复用 `_Panel`,避免在受限高度内嵌套 Expanded。 + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + ), + Expanded( + child: Align( + alignment: Alignment.bottomLeft, + child: Text( + '$value', + style: TextStyle( + fontSize: 54, + fontWeight: FontWeight.w800, + color: color, + ), + ), + ), + ), + ], + ), + ); +} + +/// 绑定信息面板中的固定标签与可截断值行。 +class _InfoRow extends StatelessWidget { + /// 创建一行绑定信息。 + const _InfoRow({required this.label, required this.value}); + + /// 左侧固定宽度标签。 + final String label; + + /// 右侧允许省略展示的服务端或本地配置值。 + final String value; + + /// 构建适合横屏状态面板密度的信息行。 + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row(children: [ + SizedBox( + width: 110, + child: Text(label, + style: const TextStyle(color: XfjDesignTokens.muted))), + Expanded( + child: Text(value, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700))) + ]), + ); +} diff --git a/lib/pages/root/device_setup/view.dart b/lib/pages/root/device_setup/view.dart new file mode 100644 index 0000000..0fc4288 --- /dev/null +++ b/lib/pages/root/device_setup/view.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 登录后的服务端设备绑定页,同时识别 cashier/printer 终端角色。 +class DeviceSetupPage extends StatefulWidget { + /// 创建登录后首次绑定服务端设备的页面。 + const DeviceSetupPage({super.key}); + + /// 创建设备 ID 输入、提交和错误反馈状态。 + @override + State createState() => _DeviceSetupPageState(); +} + +/// 首次设备绑定页面状态;绑定成功后按服务端类型路由到收银或打印角色。 +class _DeviceSetupPageState extends State { + /// PC 设备管理中生成的服务端设备 ID 输入控制器。 + final _controller = TextEditingController(); + + /// 是否正在提交绑定并加载角色配置,用于禁用重复操作。 + bool _submitting = false; + + /// 最近一次校验或服务端绑定错误;为空时不展示错误文案。 + String? _error; + + /// 释放设备 ID 输入控制器。 + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// 校验设备 ID、绑定设备并根据服务端返回的终端角色完成首次路由。 + Future _bind() async { + final id = int.tryParse(_controller.text.trim()); + if (id == null || id <= 0) { + setState(() => _error = '请输入有效的设备 ID'); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + try { + final bound = await AppSettingTool.bindSys(id); + if (!bound) throw StateError('设备绑定失败,请确认设备 ID、单位以及后台绑定关系'); + var setting = await AppSettingTool.getSysSetting(); + if (setting.terminalRole == TerminalRole.printer) { + setting = await AppSettingTool.updatePrinterBootstrap( + await HardApi.getPrinterBootstrap(), + ); + } + await Get.find().resumeAuthenticatedServices( + setting.terminalRole, + ); + if (setting.terminalRole == TerminalRole.printer) { + await AppMqTool.mqRefresh(); + await SyncPrintTool.pullPendingTasks(); + Get.offAllNamed(RouteNames.printer); + } else { + Get.offAllNamed(RouteNames.terminal); + } + } catch (error) { + if (mounted) { + setState( + () => _error = error.toString().replaceFirst('Bad state: ', ''), + ); + } + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } + + /// 清理账号登录态并返回登录页,不保留未完成的设备绑定输入。 + Future _logout() async { + await AppSettingTool.setUserSetting(UserSetting()); + await Get.find().suspendAuthenticatedServices(); + Get.offAllNamed(RouteNames.login); + } + + /// 构建适合终端横屏触控的设备 ID 绑定表单。 + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Padding( + padding: const EdgeInsets.all(32), + child: Material( + color: XfjDesignTokens.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: XfjDesignTokens.line), + borderRadius: + BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.print_outlined, + size: 42, color: XfjDesignTokens.primary), + const SizedBox(height: 20), + const Text('绑定终端设备', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: XfjDesignTokens.ink)), + const SizedBox(height: 8), + const Text('输入 PC 设备管理中创建的设备 ID', + style: TextStyle( + fontSize: 16, color: XfjDesignTokens.muted)), + const SizedBox(height: 28), + TextField( + controller: _controller, + enabled: !_submitting, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _bind(), + decoration: InputDecoration( + labelText: '设备 ID', + errorText: _error, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + TextButton( + onPressed: _submitting ? null : _logout, + child: const Text('退出登录')), + const Spacer(), + FilledButton.icon( + onPressed: _submitting ? null : _bind, + icon: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2)) + : const Icon(Icons.link), + label: const Text('绑定并继续'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index 74bfde0..1f31b48 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -179,9 +179,9 @@ class LoginController extends GetxController { if (visibleMsg) { Toast.success(msg: "登录成功"); } - // 启动页已经完成数据库与设备初始化,登录成功后直接进入终端,避免重复执行启动链路。 + // 启动页已经完成数据库与设备初始化,登录后按持久化终端角色进入独立工作区。 _clearForm(); - Get.offAllNamed(RouteNames.terminal); + await _openWorkspace(); } catch (error) { logger.e("|$_tag|登录错误error:|$error|"); } finally { @@ -208,7 +208,7 @@ class LoginController extends GetxController { Toast.success(msg: "登录成功"); EasyLoading.dismiss(); _clearForm(); - Get.offAllNamed(RouteNames.terminal); + await _openWorkspace(); return; } if (!isNetwork && serviceId == null) { @@ -218,6 +218,33 @@ class LoginController extends GetxController { await doLogin(true, automatic: true); } + /// 未绑定实例先进入设备 ID 流程;EY_PRINT 永远进入打印面板而非收银终端。 + Future _openWorkspace() async { + var setting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(setting.serviceId)) { + Get.offAllNamed(RouteNames.deviceSetup); + return; + } + if (setting.terminalRole == TerminalRole.printer) { + try { + setting = await AppSettingTool.updatePrinterBootstrap( + await HardApi.getPrinterBootstrap(), + ); + } catch (error, stack) { + logger.w('|$_tag|刷新打印终端绑定失败,使用本地配置进入状态页|error:$error|stack:$stack|'); + } + await Get.find().resumeAuthenticatedServices( + setting.terminalRole, + ); + Get.offAllNamed(RouteNames.printer); + return; + } + await Get.find().resumeAuthenticatedServices( + setting.terminalRole, + ); + Get.offAllNamed(RouteNames.terminal); + } + /// 用户主动编辑账号或密码后,本轮初始化只保留人工登录,避免延迟的自动登录弹层关闭软键盘。 void markManualInputStarted() { _manualInputStarted = true; diff --git a/lib/pages/terminal/order_history/controller.dart b/lib/pages/terminal/order_history/controller.dart index 24af8e9..8e39e7c 100644 --- a/lib/pages/terminal/order_history/controller.dart +++ b/lib/pages/terminal/order_history/controller.dart @@ -394,36 +394,19 @@ class OrderHistoryController extends GetxController { /// 打印订单发票 void doPrint(OrderEntity v) async { - final isOpen = await printManage.open(); - if (!isOpen) { - Toast.error(msg: "打开打印机失败"); - return; - } EasyLoading.show( - status: '打印中...', + status: '正在提交打印任务...', maskType: EasyLoadingMaskType.black, ); try { - final orderId = int.tryParse(v.serviceUniqueCode); - final pa = {"orderId": orderId}; - final data = await HardApi.generateInvoice(pa); - if (data == null) throw Error(); - if (ObjectUtil.isEmpty(data.historyId) || ObjectUtil.isEmpty(data.pic)) { - throw Exception("服务端生成发票失败"); - } - await SyncPrintTool.addTask( - orderId: v.serviceUniqueCode, - androidOrderId: v.id.toString(), - historyId: data.historyId!, - pic: data.pic!, - ); + final accepted = + await HardApi.createPrinterTask(orderId: v.serviceUniqueCode); + if (!accepted) throw StateError('服务端未接受打印任务'); EasyLoading.dismiss(); - Toast.success(msg: "打印成功"); + Toast.success(msg: "打印任务已提交"); } catch (e, stack) { logger.e("打印订单发票失败error:|$e|stack:|$stack|"); EasyLoading.dismiss(); - } finally { - await printManage.close(); } } diff --git a/packages/drago_usb_printer/android/build.gradle b/packages/drago_usb_printer/android/build.gradle new file mode 100644 index 0000000..21dff80 --- /dev/null +++ b/packages/drago_usb_printer/android/build.gradle @@ -0,0 +1,40 @@ +group 'app.sks.client.drago_usb_printer' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.22' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'app.sks.client.drago_usb_printer' + compileSdkVersion 35 + sourceSets { main.java.srcDirs += 'src/main/kotlin' } + defaultConfig { minSdkVersion 24 } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/packages/drago_usb_printer/android/src/main/AndroidManifest.xml b/packages/drago_usb_printer/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1912c97 --- /dev/null +++ b/packages/drago_usb_printer/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt new file mode 100644 index 0000000..ee45435 --- /dev/null +++ b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt @@ -0,0 +1,202 @@ +package app.sks.client.drago_usb_printer + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** 项目内 USB 打印插件;只有 Bulk OUT 接受全部字节后才向 Dart 返回成功。 */ +class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + /** Flutter 插件使用的应用上下文。 */ + private lateinit var context: Context + + /** 与 Dart 侧约定为 drago_usb_printer 的方法通道。 */ + private lateinit var channel: MethodChannel + + /** Android USB Host 系统服务。 */ + private lateinit var usbManager: UsbManager + + /** 以 VID-PID 为键复用已经 claim 的打印机连接。 */ + private val connections = mutableMapOf() + + /** 将后台写入结果切回主线程提交给 Flutter。 */ + private val mainHandler = Handler(Looper.getMainLooper()) + + /** 注册方法通道并取得 USB Host 服务。 */ + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + channel = MethodChannel(binding.binaryMessenger, "drago_usb_printer") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getUSBDeviceList" -> result.success(listPrinterDevices()) + "connect" -> runCatching { connectionFor(call).connect() } + .fold(result::success) { result.error("USB_CONNECT", it.message, null) } + "disconnect" -> { + connections.remove(deviceKey(call))?.close() + result.success(true) + } + "write" -> { + val data = call.argument("data") + if (data == null) { + result.error("USB_DATA", "Missing USB payload", null) + return + } + Thread { + runCatching { connectionFor(call).writeFully(data) } + .fold( + { mainHandler.post { result.success(true) } }, + { error -> mainHandler.post { result.error("USB_WRITE", error.message, null) } }, + ) + }.start() + } + else -> result.notImplemented() + } + } + + /** 返回至少包含一个 Printer Class 接口的 USB 设备摘要。 */ + private fun listPrinterDevices(): List> = usbManager.deviceList.values + .filter(::isPrinterDevice) + .map { device -> + mapOf( + "deviceName" to device.deviceName, + "manufacturer" to safeValue { device.manufacturerName }, + "productName" to safeValue { device.productName }, + "deviceId" to device.deviceId.toString(), + "vendorId" to device.vendorId.toString(), + "productId" to device.productId.toString(), + ) + } + + /** 校验 VID/PID、设备类型和授权后取得可复用连接。 */ + private fun connectionFor(call: MethodCall): UsbPrinterConnection { + val vendorId = call.argument("vendorId") ?: error("Missing USB VID") + val productId = call.argument("productId") ?: error("Missing USB PID") + val device = usbManager.deviceList.values.firstOrNull { + it.vendorId == vendorId && it.productId == productId && isPrinterDevice(it) + } ?: error("Configured USB printer was not found") + if (!usbManager.hasPermission(device)) { + requestPermission(device) + error("USB permission requested; retry after permission is granted") + } + return connections.getOrPut("$vendorId-$productId") { + UsbPrinterConnection(usbManager, device) + } + } + + /** 从方法参数构造连接缓存键。 */ + private fun deviceKey(call: MethodCall): String = + "${call.argument("vendorId")}-${call.argument("productId")}" + + /** 请求 Android USB 授权;授权完成后由上层恢复流程再次连接。 */ + private fun requestPermission(device: UsbDevice) { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + val intent = PendingIntent.getBroadcast( + context, + 0, + Intent("${context.packageName}.USB_PERMISSION"), + flags, + ) + usbManager.requestPermission(device, intent) + } + + /** 判断设备是否公开标准 USB Printer Class 接口。 */ + private fun isPrinterDevice(device: UsbDevice): Boolean = + (0 until device.interfaceCount).any { + device.getInterface(it).interfaceClass == UsbConstants.USB_CLASS_PRINTER + } + + /** 兼容部分设备描述符读取异常,异常字段按空值返回。 */ + private fun safeValue(read: () -> String?): String? = runCatching(read).getOrNull() + + /** 释放全部已 claim 的接口和连接,并注销方法通道。 */ + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + connections.values.forEach(UsbPrinterConnection::close) + connections.clear() + channel.setMethodCallHandler(null) + } +} + +/** 持有一个已 claim 的打印机接口;Bulk OUT 必需,Bulk IN 明确为可选。 */ +private class UsbPrinterConnection( + /** Android USB Host 系统服务。 */ + private val manager: UsbManager, + + /** 当前 VID/PID 对应的 USB 设备。 */ + private val device: UsbDevice, +) { + /** 已打开的设备连接。 */ + private var connection: UsbDeviceConnection? = null + + /** 已 claim 的 Printer Class 接口。 */ + private var printerInterface: UsbInterface? = null + + /** 当前打印接口的 Bulk OUT 端点。 */ + private var bulkOut: UsbEndpoint? = null + + /** 打开设备并 claim Printer Class 接口,缺少 Bulk OUT 时直接失败。 */ + @Synchronized + fun connect(): Boolean { + if (connection != null && bulkOut != null) return true + val targetInterface = (0 until device.interfaceCount) + .map(device::getInterface) + .firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_PRINTER } + ?: error("USB printer interface is missing") + val output = (0 until targetInterface.endpointCount) + .map(targetInterface::getEndpoint) + .firstOrNull { + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK && + it.direction == UsbConstants.USB_DIR_OUT + } ?: error("USB Bulk OUT endpoint is missing") + val opened = manager.openDevice(device) ?: error("Unable to open USB printer") + if (!opened.claimInterface(targetInterface, true)) { + opened.close() + error("Unable to claim USB printer interface") + } + connection = opened + printerInterface = targetInterface + bulkOut = output + return true + } + + @Synchronized + fun writeFully(data: ByteArray): Int { + connect() + val opened = connection ?: error("USB connection is closed") + val output = bulkOut ?: error("USB Bulk OUT endpoint is missing") + var offset = 0 + while (offset < data.size) { + val written = opened.bulkTransfer(output, data, offset, data.size - offset, 5000) + if (written <= 0) error("USB write stopped at $offset/${data.size} bytes (result $written)") + offset += written + } + if (offset != data.size) error("USB write incomplete: $offset/${data.size} bytes") + return offset + } + + @Synchronized + fun close() { + printerInterface?.let { connection?.releaseInterface(it) } + connection?.close() + connection = null + printerInterface = null + bulkOut = null + } +} + /** 分发设备发现、连接、关闭和完整写入四类原生调用。 */ + /** 循环处理 bulkTransfer 部分写入,并核对最终累计长度。 */ + /** 释放接口并关闭设备连接,重复调用保持幂等。 */ diff --git a/packages/drago_usb_printer/lib/drago_usb_printer.dart b/packages/drago_usb_printer/lib/drago_usb_printer.dart new file mode 100644 index 0000000..151c9ef --- /dev/null +++ b/packages/drago_usb_printer/lib/drago_usb_printer.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +/// `drago_pos_printer` 在 Android 上使用的项目内 USB Bulk 传输实现。 +class DragoUsbPrinter { + /// 与原生插件约定的稳定 MethodChannel 名称。 + static const MethodChannel _channel = MethodChannel('drago_usb_printer'); + + /// 当前连接目标的 USB Vendor ID,连接前为 0。 + int vendorId = 0; + + /// 当前连接目标的 USB Product ID,连接前为 0。 + int productId = 0; + + /// 列出 Android 上具有 Printer Class 接口的 USB 设备,授权仍由 [connect] 触发。 + static Future>> getUSBDeviceList() async { + if (!Platform.isAndroid) return >[]; + final devices = + await _channel.invokeListMethod('getUSBDeviceList'); + return (devices ?? const []) + .cast>() + .map((item) => Map.from(item)) + .toList(growable: false); + } + + /// 按准确 VID/PID 打开设备,并要求目标接口至少存在一个 Bulk OUT 端点。 + Future connect(int vendorId, int productId) async { + this.vendorId = vendorId; + this.productId = productId; + return await _channel.invokeMethod('connect', _params) == true; + } + + /// 释放当前原生 USB Interface 与连接;重复关闭按成功处理。 + Future close() async { + return await _channel.invokeMethod('disconnect', _params) == true; + } + + /// 等待原生层完整接收全部字节后返回;部分写、零字节或负数结果均抛出异常。 + Future write(Uint8List data) async { + if (data.isEmpty) return true; + final completed = await _channel.invokeMethod('write', { + ..._params, + 'data': data, + }); + if (completed != true) { + throw StateError('USB did not accept the complete payload'); + } + return true; + } + + /// 保留上游可能使用的文本兼容入口;生产图片打印直接调用 [write]。 + Future printText(String text) => + write(Uint8List.fromList(text.codeUnits)); + + /// 构造所有原生连接与关闭调用共用的目标设备参数。 + Map get _params => { + 'vendorId': vendorId, + 'productId': productId, + }; +} diff --git a/packages/drago_usb_printer/pubspec.yaml b/packages/drago_usb_printer/pubspec.yaml new file mode 100644 index 0000000..d90216c --- /dev/null +++ b/packages/drago_usb_printer/pubspec.yaml @@ -0,0 +1,19 @@ +name: drago_usb_printer +description: Project-owned Android USB transport with complete-write verification. +version: 0.1.2+zax.1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: app.sks.client.drago_usb_printer + pluginClass: DragoUsbPrinterPlugin diff --git a/pubspec.lock b/pubspec.lock index 4299a67..0d9cebb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -343,13 +343,12 @@ packages: source: hosted version: "1.0.5" drago_usb_printer: - dependency: transitive + dependency: "direct overridden" description: - name: drago_usb_printer - sha256: "08272fe48797fe513cbdd57747dfe2edb2814cc438f9623311ec5e2601fcd1ab" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.1.2" + path: "packages/drago_usb_printer" + relative: true + source: path + version: "0.1.2+zax.1" ducafe_ui_core: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 890c786..f3482ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.4.6+146 +version: 1.4.7+147 environment: sdk: ^3.6.0 @@ -93,6 +93,11 @@ dev_dependencies: # json json_serializable: 6.8.0 +# drago_pos_printer 通过该传递依赖写入 Android USB。修复版随项目发布,禁止修改全局 pub-cache。 +dependency_overrides: + drago_usb_printer: + path: packages/drago_usb_printer + flutter: uses-material-design: true diff --git a/test/common/printer_task_test.dart b/test/common/printer_task_test.dart new file mode 100644 index 0000000..f96e308 --- /dev/null +++ b/test/common/printer_task_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +// ignore_for_file: depend_on_referenced_packages + +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/services/database/database_migration.dart'; + +void main() { + sqfliteFfiInit(); + + setUpAll(() { + databaseFactory = databaseFactoryFfi; + }); + + test('MQTT 打印消息允许线上预约缺少 androidOrderCode', () { + final message = MqttInvoiceMsg.fromJson(const { + 'action': 'PRINT', + 'historyId': 9001, + 'orderId': 8001, + 'androidOrderCode': null, + 'sourceHardId': 101, + 'targetHardId': 202, + 'printType': '2', + 'printNo': 3, + 'retryVersion': 4, + 'pic': 'https://example.test/voucher.png', + }); + + expect(message.androidOrderCode, isNull); + expect(message.historyId, '9001'); + expect(message.orderId, '8001'); + expect(message.sourceHardId, '101'); + expect(message.targetHardId, '202'); + expect(message.printType, '2'); + expect(message.printNo, 3); + expect(message.retryVersion, 4); + expect(message.toJson(), containsPair('androidOrderCode', null)); + }); + + test('打印任务最多尝试三次且不安排第四次出纸', () { + expect( + SyncPrintTool.attemptDelays, + const [ + Duration.zero, + Duration(seconds: 5), + Duration(seconds: 30), + ], + ); + }); + + test('同版本重复任务被拒绝,只有失败任务的更高版本可重试', () { + final task = PrintHistoryEntity( + orderId: 'local-order', + serviceOrderId: '8001', + servicePrintId: '9001', + invoiceUrl: 'https://example.test/voucher.png', + retryVersion: 2, + status: PrintStatus.ERROR, + createTime: DateTime(2026, 9, 4), + ); + + expect(SyncPrintTool.canApplyTaskVersion(task, 2), isFalse); + expect(SyncPrintTool.canApplyTaskVersion(task, 1), isFalse); + expect(SyncPrintTool.canApplyTaskVersion(task, 3), isTrue); + + task.status = PrintStatus.SUCCESS; + expect(SyncPrintTool.canApplyTaskVersion(task, 3), isFalse); + task.status = PrintStatus.SYNC; + expect(SyncPrintTool.canApplyTaskVersion(task, 3), isFalse); + }); + + test('12到13迁移保留每个服务端任务最新记录并建立唯一索引', () async { + final database = + await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(database.close); + await database.execute(''' + CREATE TABLE print_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + orderId TEXT NOT NULL, + serviceOrderId TEXT NOT NULL, + servicePrintId TEXT NOT NULL, + invoiceUrl TEXT NOT NULL, + status TEXT NOT NULL, + printTime TEXT, + syncTime TEXT, + createTime TEXT NOT NULL + ) + '''); + await database.insert('print_history', { + 'orderId': 'old-1', + 'serviceOrderId': '8001', + 'servicePrintId': '9001', + 'invoiceUrl': 'old.png', + 'status': 'ERROR', + 'createTime': '2026-09-04T08:00:00.000', + }); + await database.insert('print_history', { + 'orderId': 'old-2', + 'serviceOrderId': '8001', + 'servicePrintId': '9001', + 'invoiceUrl': 'new.png', + 'status': 'SUCCESS', + 'createTime': '2026-09-04T08:01:00.000', + }); + + await DatabaseMigration.migration12To13.migrate(database); + + final rows = await database.query('print_history'); + final columns = await database.rawQuery('PRAGMA table_info(print_history)'); + final indexes = await database.rawQuery('PRAGMA index_list(print_history)'); + expect(rows, hasLength(1)); + expect(rows.single['orderId'], 'old-2'); + expect(rows.single['printType'], '1'); + expect(rows.single['printNo'], 1); + expect(rows.single['retryVersion'], 0); + expect(rows.single['attemptCount'], 0); + expect(rows.single['resultPending'], 0); + expect( + columns.map((column) => column['name']), + containsAll(const { + 'printType', + 'printNo', + 'retryVersion', + 'attemptCount', + 'lastError', + 'resultPending', + }), + ); + expect( + indexes.map((index) => index['name']), + contains('index_print_history_servicePrintId'), + ); + await expectLater( + database.insert('print_history', { + 'orderId': 'duplicate', + 'serviceOrderId': '8001', + 'servicePrintId': '9001', + 'invoiceUrl': 'duplicate.png', + 'status': 'NONE', + 'createTime': '2026-09-04T08:02:00.000', + }), + throwsA(isA()), + ); + }); +} diff --git a/test/common/terminal_device_status_test.dart b/test/common/terminal_device_status_test.dart index bc59e7e..2f6a0a6 100644 --- a/test/common/terminal_device_status_test.dart +++ b/test/common/terminal_device_status_test.dart @@ -222,27 +222,27 @@ void main() { expect(AppDeviceStatusTool.progress.value.running, isFalse); }); - test('自动检查任务避免重复启动并在释放时取消全部定时器', () { - final tool = SyncCheckTool.instance; - tool.dispose(); - addTearDown(tool.dispose); + test('打印角色只保留通信、打印和运维 Cron 任务', () { + const expected = { + ScheduledTaskType.mqReconnect, + ScheduledTaskType.printCheck, + ScheduledTaskType.deviceStatus, + ScheduledTaskType.autoUpdate, + ScheduledTaskType.terminalLogUpload, + ScheduledTaskType.expiredLogCleanup, + }; - tool.startAll(); - final mqTimer = tool.mqTimer; - final printTimer = tool.printTimer; - final broadcastTimer = tool.broadcastTimer; - final deviceStatusTimer = tool.deviceStatusTimer; - - tool.startAll(); - expect(identical(tool.mqTimer, mqTimer), isTrue); - expect(identical(tool.printTimer, printTimer), isTrue); - expect(identical(tool.broadcastTimer, broadcastTimer), isTrue); - expect(identical(tool.deviceStatusTimer, deviceStatusTimer), isTrue); - - tool.dispose(); - expect(tool.mqTimer, isNull); - expect(tool.printTimer, isNull); - expect(tool.broadcastTimer, isNull); - expect(tool.deviceStatusTimer, isNull); + for (final type in ScheduledTaskType.values) { + expect( + SyncCheckTool.supportsTask(TerminalRole.printer, type), + expected.contains(type), + reason: '打印角色任务过滤错误: $type', + ); + expect( + SyncCheckTool.supportsTask(TerminalRole.cashier, type), + isTrue, + reason: '收银角色应保留完整任务: $type', + ); + } }); } diff --git a/tmp/打印机文档.doc b/tmp/打印机文档.doc new file mode 100644 index 0000000..0510a53 Binary files /dev/null and b/tmp/打印机文档.doc differ