feat:消费机打印机
This commit is contained in:
1 parent
4d7bc5ed33
commit
4a18fc6eb1
39 files changed
+2061
-219
No files matched your search
@@ -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为版本号(内部比较)
|
||||
```
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -284,6 +284,25 @@ abstract class HardApi {
|
||||
return ServiceGenerateInvoice.fromJson(res.data);
|
||||
}
|
||||
|
||||
/// 业务终端按 DEVICE-ID 创建打印任务,目标打印机只由服务端绑定关系决定。
|
||||
static Future<bool> 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<bool> 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<Map<String, dynamic>> 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<String, dynamic>.from(res.data as Map);
|
||||
}
|
||||
|
||||
/// 拉取当前打印终端未完成任务,补偿 MQTT 离线窗口。
|
||||
static Future<List<MqttInvoiceMsg>> getPrinterTasks() async {
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final hardId = setting.serviceId;
|
||||
if (hardId == null || hardId.isEmpty) return const <MqttInvoiceMsg>[];
|
||||
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 <MqttInvoiceMsg>[];
|
||||
return (res.data as List).whereType<Map>().map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
json['historyId'] ??= json['id'];
|
||||
return MqttInvoiceMsg.fromJson(json);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 上报完整 USB 写入结果;请求失败由本地队列保留并在网络恢复后补报。
|
||||
static Future<bool> 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<List<String>?> syncOfflineOrder(dynamic formData) async {
|
||||
final res = await HttpManage().dio.post(
|
||||
|
||||
@@ -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", "线下购买"),
|
||||
|
||||
@@ -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<Object?> get props => [
|
||||
sysType,
|
||||
terminalRole,
|
||||
androidId,
|
||||
serviceId,
|
||||
serviceName,
|
||||
serviceMerchantId,
|
||||
printerSourceHardId,
|
||||
printerSourceHardName,
|
||||
printerModel,
|
||||
printerUsbVendorId,
|
||||
printerUsbProductId,
|
||||
printerPaperWidth,
|
||||
printerAutoCut,
|
||||
printerImageMode,
|
||||
merchantDeptId,
|
||||
hardGroupId,
|
||||
mqSocketAddress,
|
||||
|
||||
@@ -8,6 +8,9 @@ part of 'database_models.dart';
|
||||
|
||||
SysSetting _$SysSettingFromJson(Map<String, dynamic> 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<String, dynamic> 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<dynamic>?)
|
||||
?.map((e) => ServiceTenant.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
@@ -38,6 +44,11 @@ SysSetting _$SysSettingFromJson(Map<String, dynamic> 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<String, dynamic> json) => SysSetting(
|
||||
Map<String, dynamic> _$SysSettingToJson(SysSetting instance) =>
|
||||
<String, dynamic>{
|
||||
'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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,11 @@ MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map<String, dynamic> 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<String, dynamic> _$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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -31,4 +31,24 @@ abstract class PrintHistoryDao {
|
||||
(:status = '-1' OR status = :status)
|
||||
''')
|
||||
Future<List<PrintHistoryEntity>> getAll(String status);
|
||||
|
||||
/// 以服务端任务 ID 唯一定位本地记录,作为 MQTT/HTTP 双通道去重依据。
|
||||
@Query(
|
||||
'SELECT * FROM print_history WHERE servicePrintId = :servicePrintId LIMIT 1')
|
||||
Future<PrintHistoryEntity?> findByServicePrintId(String servicePrintId);
|
||||
|
||||
/// 查询等待补报成功或失败结果的任务。
|
||||
@Query(
|
||||
'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC')
|
||||
Future<List<PrintHistoryEntity>> getPendingResults();
|
||||
|
||||
@Query("SELECT COUNT(*) FROM print_history WHERE status = 'NONE'")
|
||||
Future<int?> countPending();
|
||||
|
||||
@Query("SELECT COUNT(*) FROM print_history WHERE status = 'ERROR'")
|
||||
Future<int?> countFailed();
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != '' ORDER BY id DESC LIMIT 1")
|
||||
Future<PrintHistoryEntity?> findLatestError();
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const typeConverters = <Type>[
|
||||
];
|
||||
|
||||
/// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。
|
||||
@Database(version: 12, entities: entities)
|
||||
@Database(version: 13, entities: entities)
|
||||
@TypeConverters(typeConverters)
|
||||
abstract class AppDatabase extends FloorDatabase {
|
||||
/// 取餐广播队列 DAO。
|
||||
|
||||
@@ -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<List<PrintHistoryEntity>> getAll(String status) async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT * FROM print_history WHERE (?1 = \'-1\' OR status = ?1)',
|
||||
mapper: (Map<String, Object?> 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<String, Object?> 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<PrintHistoryEntity?> findByServicePrintId(
|
||||
String servicePrintId) async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT * FROM print_history WHERE servicePrintId = ?1 LIMIT 1',
|
||||
mapper: (Map<String, Object?> 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<List<PrintHistoryEntity>> getPendingResults() async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC',
|
||||
mapper: (Map<String, Object?> 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<int?> countPending() async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT COUNT(*) FROM print_history WHERE status = \'NONE\'',
|
||||
mapper: (Map<String, Object?> row) => row.values.first as int);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> countFailed() async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT COUNT(*) FROM print_history WHERE status = \'ERROR\'',
|
||||
mapper: (Map<String, Object?> row) => row.values.first as int);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PrintHistoryEntity?> findLatestError() async {
|
||||
return _queryAdapter.query(
|
||||
'SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != \'\' ORDER BY id DESC LIMIT 1',
|
||||
mapper: (Map<String, Object?> 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<void> save(PrintHistoryEntity entity) async {
|
||||
await _printHistoryEntityInsertionAdapter.insert(
|
||||
|
||||
@@ -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<Migration> getDatabaseMigrations() {
|
||||
return [
|
||||
@@ -400,6 +424,7 @@ abstract class DatabaseMigration {
|
||||
migration9To10,
|
||||
migration10To11,
|
||||
migration11To12,
|
||||
migration12To13,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,12 @@ PrintHistoryEntity _$PrintHistoryEntityFromJson(Map<String, dynamic> 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<String, dynamic> _$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(),
|
||||
|
||||
@@ -14,13 +14,16 @@ class AppMqTool {
|
||||
|
||||
/// 校验网络和设备绑定后重建 MQTT 连接,并注册当前设备的消息监听。
|
||||
static Future<void> mqRefresh() async {
|
||||
final app = Get.find<AppControl>();
|
||||
if (!app.authenticatedServicesEnabled) {
|
||||
logger.w('|$_tag|当前没有有效登录会话,暂不连接消息服务');
|
||||
return;
|
||||
}
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
if (!isNetwork) {
|
||||
logger.e("|$_tag|mqtt监听消息错误没有网络");
|
||||
return;
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
|
||||
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<AppControl>();
|
||||
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:
|
||||
|
||||
@@ -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<void> _restoreNetworkServices() async {
|
||||
final app = Get.find<AppControl>();
|
||||
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|');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SysSetting> updatePrinterBootstrap(
|
||||
Map<String, dynamic> 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<String, dynamic>.from(bootstrap['printerConfig'] as Map)
|
||||
: <String, dynamic>{};
|
||||
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<bool> bindSys(int serviceId) async {
|
||||
var androidId = await AppSysTool.getAndroidId();
|
||||
|
||||
@@ -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<void> resumeAuthenticatedServices(TerminalRole terminalRole) async {
|
||||
_authenticatedServicesEnabled = true;
|
||||
await SyncCheckTool.instance.startAll(terminalRole: terminalRole);
|
||||
TerminalHeartbeatTool.start();
|
||||
}
|
||||
|
||||
/// 退出登录时停止所有需要账号或设备身份的后台任务,并断开消息订阅。
|
||||
Future<void> 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) {
|
||||
|
||||
@@ -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<String> _printVendorIds = [
|
||||
"19267",
|
||||
"4070",
|
||||
"10473",
|
||||
];
|
||||
|
||||
/// 返回设备状态体系中使用的打印机名称。
|
||||
@override
|
||||
String setName() => UsbFacilityNames.print.title;
|
||||
|
||||
/// 只发现 USB 设备,不建立打印连接,避免后台状态任务占用实际打印通道。
|
||||
Future<bool> 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<USBPrinter> printers) {
|
||||
/// 优先按后台 VID/PID 精确匹配;未配置时才沿用已知厂商或唯一设备回退规则。
|
||||
USBPrinter? _findTarget(List<USBPrinter> 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<bool> 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<bool> 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 = <int>[
|
||||
...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<void> close() async {
|
||||
AppDeviceStatusTool.updatePrinterWorking(false);
|
||||
if (ObjectUtil.isNotEmpty(manager)) {
|
||||
if (manager != null) {
|
||||
manager?.disconnect();
|
||||
await manager?.disconnect();
|
||||
}
|
||||
manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// USB 授权由项目内 Android 插件在连接时申请,此处无需前置 Flutter 权限。
|
||||
@override
|
||||
Future<bool> checkPermission() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// USB 授权由项目内 Android 插件负责,保留基类接口并返回可继续连接。
|
||||
@override
|
||||
Future<bool> applyPermission() async {
|
||||
return true;
|
||||
|
||||
@@ -19,6 +19,16 @@ class SyncCheckTool {
|
||||
/// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。
|
||||
static final SyncCheckTool instance = SyncCheckTool._();
|
||||
|
||||
/// 打印角色允许注册的后台任务;其余商品、人脸、订单和取餐任务属于收银角色。
|
||||
static const Set<ScheduledTaskType> _printerTaskTypes = {
|
||||
ScheduledTaskType.mqReconnect,
|
||||
ScheduledTaskType.printCheck,
|
||||
ScheduledTaskType.deviceStatus,
|
||||
ScheduledTaskType.autoUpdate,
|
||||
ScheduledTaskType.terminalLogUpload,
|
||||
ScheduledTaskType.expiredLogCleanup,
|
||||
};
|
||||
|
||||
/// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。
|
||||
final configs = <ScheduledTaskType, ScheduledTaskConfig>{}.obs;
|
||||
|
||||
@@ -52,6 +62,16 @@ class SyncCheckTool {
|
||||
/// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。
|
||||
bool _started = false;
|
||||
|
||||
/// 当前调度任务对应的终端角色;设备首次绑定改变角色时据此重建任务集合。
|
||||
TerminalRole? _activeRole;
|
||||
|
||||
/// 当前已经注册的终端角色,供状态诊断和测试确认角色切换结果。
|
||||
TerminalRole? get activeRole => _activeRole;
|
||||
|
||||
/// 当前注册到 Cron 的任务类型快照。
|
||||
Set<ScheduledTaskType> get scheduledTaskTypes =>
|
||||
Set.unmodifiable(_scheduledTasks.keys);
|
||||
|
||||
/// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。
|
||||
DateTime? _lastDepartmentFaceCatchUpDate;
|
||||
|
||||
@@ -333,19 +353,33 @@ class SyncCheckTool {
|
||||
),
|
||||
];
|
||||
|
||||
/// 初始化结构化配置并注册全部已启用任务;同一生命周期内重复调用直接返回。
|
||||
Future<void> startAll() async {
|
||||
if (_started) return;
|
||||
/// 初始化结构化配置并注册全部已启用任务;角色改变时先释放旧角色任务再重新注册。
|
||||
Future<void> 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<AppControl>().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<void> _dispose() async {
|
||||
_started = false;
|
||||
_activeRole = null;
|
||||
_idleTimer?.cancel();
|
||||
_idleTimer = null;
|
||||
for (final task in _scheduledTasks.values) {
|
||||
|
||||
@@ -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<void> _enqueueTail = Future<void>.value();
|
||||
|
||||
/// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。
|
||||
///
|
||||
/// 方案同时限定“最多三次 USB 尝试”,因此不会再安排会形成第 4 次出纸的 120 秒重试。
|
||||
static const attemptDelays = <Duration>[
|
||||
Duration.zero,
|
||||
Duration(seconds: 5),
|
||||
Duration(seconds: 30),
|
||||
];
|
||||
|
||||
/// 将 MQTT 或 HTTP 待办按服务端任务 ID 转换为唯一的本地任务。
|
||||
///
|
||||
/// [orderId] 是服务端订单 ID,[androidOrderId] 允许线上预约订单为空;[historyId]
|
||||
/// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列。
|
||||
static Future<void> 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<AppControl>();
|
||||
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<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace __) {},
|
||||
);
|
||||
await printHistoryDao.save(saveObj);
|
||||
logger.i("|$_tag|添加到打印记录到本地数据库,json:|${saveObj.toJson()}|");
|
||||
await SyncPrintTool.doCheckPrint();
|
||||
await operation;
|
||||
}
|
||||
|
||||
// 检查打印
|
||||
static Future<void> doCheckPrint() async {
|
||||
if (_isPrint) {
|
||||
logger.i("|$_tag|检查打印,正在打印中");
|
||||
/// 在串行入队区内执行任务新增或失败版本更新。
|
||||
static Future<void> _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<AppControl>().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<void> pullPendingTasks() async {
|
||||
if (_isPulling || !AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
final appC = Get.find<AppControl>();
|
||||
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<void> doCheckPrint() async {
|
||||
if (_isPrint) {
|
||||
return;
|
||||
}
|
||||
_isPrint = true;
|
||||
final dao = Get.find<AppControl>().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<void> doCheckSync() async {
|
||||
if (_isSync) {
|
||||
logger.i("|$_tag|检查打印,正在打印中");
|
||||
if (_isSync || !AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
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<AppControl>().dataBase.getPrintHistoryDao;
|
||||
try {
|
||||
for (final task in await dao.getPendingResults()) {
|
||||
await _report(task);
|
||||
}
|
||||
} finally {
|
||||
_isSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行打印
|
||||
static Future<void> doPrint(PrintHistoryEntity v) async {
|
||||
/// 串行执行单个任务的最多三次 USB 尝试,并在最终状态落库后补报服务端。
|
||||
static Future<void> _print(PrintHistoryEntity task) async {
|
||||
final app = Get.find<AppControl>();
|
||||
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<void>.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<void> _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<AppControl>().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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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<PrinterStatusPage> createState() => _PrinterStatusPageState();
|
||||
}
|
||||
|
||||
/// 打印终端状态页的进程内状态,页面销毁时必须取消轮询和生命周期观察。
|
||||
class _PrinterStatusPageState extends State<PrinterStatusPage>
|
||||
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<void> _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<void> _refreshStatus() async {
|
||||
if (_sessionEnding) return;
|
||||
final app = Get.find<AppControl>();
|
||||
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<void> _showSettings() async {
|
||||
final setting = _setting ?? await AppSettingTool.getSysSetting();
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
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<void> _logout() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AppControl>().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<AppControl>().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)))
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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<DeviceSetupPage> createState() => _DeviceSetupPageState();
|
||||
}
|
||||
|
||||
/// 首次设备绑定页面状态;绑定成功后按服务端类型路由到收银或打印角色。
|
||||
class _DeviceSetupPageState extends State<DeviceSetupPage> {
|
||||
/// PC 设备管理中生成的服务端设备 ID 输入控制器。
|
||||
final _controller = TextEditingController();
|
||||
|
||||
/// 是否正在提交绑定并加载角色配置,用于禁用重复操作。
|
||||
bool _submitting = false;
|
||||
|
||||
/// 最近一次校验或服务端绑定错误;为空时不展示错误文案。
|
||||
String? _error;
|
||||
|
||||
/// 释放设备 ID 输入控制器。
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 校验设备 ID、绑定设备并根据服务端返回的终端角色完成首次路由。
|
||||
Future<void> _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<AppControl>().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<void> _logout() async {
|
||||
await AppSettingTool.setUserSetting(UserSetting());
|
||||
await Get.find<AppControl>().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('绑定并继续'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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<AppControl>().resumeAuthenticatedServices(
|
||||
setting.terminalRole,
|
||||
);
|
||||
Get.offAllNamed(RouteNames.printer);
|
||||
return;
|
||||
}
|
||||
await Get.find<AppControl>().resumeAuthenticatedServices(
|
||||
setting.terminalRole,
|
||||
);
|
||||
Get.offAllNamed(RouteNames.terminal);
|
||||
}
|
||||
|
||||
/// 用户主动编辑账号或密码后,本轮初始化只保留人工登录,避免延迟的自动登录弹层关闭软键盘。
|
||||
void markManualInputStarted() {
|
||||
_manualInputStarted = true;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
</manifest>
|
||||
+202
@@ -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<String, UsbPrinterConnection>()
|
||||
|
||||
/** 将后台写入结果切回主线程提交给 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<ByteArray>("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<Map<String, String?>> = 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<Int>("vendorId") ?: error("Missing USB VID")
|
||||
val productId = call.argument<Int>("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<Int>("vendorId")}-${call.argument<Int>("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 部分写入,并核对最终累计长度。 */
|
||||
/** 释放接口并关闭设备连接,重复调用保持幂等。 */
|
||||
@@ -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<List<Map<String, dynamic>>> getUSBDeviceList() async {
|
||||
if (!Platform.isAndroid) return <Map<String, dynamic>>[];
|
||||
final devices =
|
||||
await _channel.invokeListMethod<dynamic>('getUSBDeviceList');
|
||||
return (devices ?? const <dynamic>[])
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 按准确 VID/PID 打开设备,并要求目标接口至少存在一个 Bulk OUT 端点。
|
||||
Future<bool> connect(int vendorId, int productId) async {
|
||||
this.vendorId = vendorId;
|
||||
this.productId = productId;
|
||||
return await _channel.invokeMethod<bool>('connect', _params) == true;
|
||||
}
|
||||
|
||||
/// 释放当前原生 USB Interface 与连接;重复关闭按成功处理。
|
||||
Future<bool> close() async {
|
||||
return await _channel.invokeMethod<bool>('disconnect', _params) == true;
|
||||
}
|
||||
|
||||
/// 等待原生层完整接收全部字节后返回;部分写、零字节或负数结果均抛出异常。
|
||||
Future<bool> write(Uint8List data) async {
|
||||
if (data.isEmpty) return true;
|
||||
final completed = await _channel.invokeMethod<bool>('write', {
|
||||
..._params,
|
||||
'data': data,
|
||||
});
|
||||
if (completed != true) {
|
||||
throw StateError('USB did not accept the complete payload');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 保留上游可能使用的文本兼容入口;生产图片打印直接调用 [write]。
|
||||
Future<bool> printText(String text) =>
|
||||
write(Uint8List.fromList(text.codeUnits));
|
||||
|
||||
/// 构造所有原生连接与关闭调用共用的目标设备参数。
|
||||
Map<String, dynamic> get _params => {
|
||||
'vendorId': vendorId,
|
||||
'productId': productId,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
+5
-6
@@ -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:
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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<DatabaseException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user