feat:消费机打印机

This commit is contained in:
dzc committed 2026-09-04 13:15:55 +08:00
1 parent 4d7bc5ed33
commit 4a18fc6eb1
39 files changed
+2061 -219

No files matched your search

+1 -1
View File
@@ -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为版本号(内部比较)
```
@@ -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);
+83
View File
@@ -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',
+17
View File
@@ -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,
});
+10
View File
@@ -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
View File
@@ -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';
+8
View File
@@ -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();
}
+1 -1
View File
@@ -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。
+107 -3
View File
@@ -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(),
+29 -6
View File
@@ -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:
+15 -4
View File
@@ -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|');
}
}
}
+37 -3
View File
@@ -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();
+103 -39
View File
@@ -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) {
+60 -10
View File
@@ -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;
+48 -5
View File
@@ -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) {
+247 -90
View File
@@ -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);
}
}
+2 -2
View File
@@ -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';
+2
View File
@@ -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';
+438
View File
@@ -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)))
]),
);
}
+163
View File
@@ -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('绑定并继续'),
),
],
),
],
),
),
),
),
),
),
),
);
}
}
+30 -3
View File
@@ -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>
@@ -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,
};
}
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
+146
View File
@@ -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>()),
);
});
}
+21 -21
View File
@@ -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.