[FIX] 優化 MQTT 連線生命週期與重啟邏輯

1. 新增 gracefulDisconnect 模式:重啟前主動發布 'restarting' 狀態並正常斷線,抑制 LWT 離線警告。
2. 消除冗餘重啟循環:修改 HomeActivity 與 RestartActivity 協作邏輯,進入購物頁面僅釋放硬體而不殺掉進程,避免多次連線恢復。
3. 遠端指令校準:確保遠端重啟指令後 App 回到 HomeActivity 初始畫面。
4. 解決硬體資源衝突:保留 RestartActivity 的串口釋放流程,修復 App 跳轉卡死問題。
This commit is contained in:
sky121113 2026-05-11 14:33:22 +08:00
parent 7a990ffc12
commit 5579d937e3
23 changed files with 934 additions and 252 deletions

View File

@ -0,0 +1,44 @@
---
description: 自動編譯 Android APK、輸出至專屬目錄並安裝至實體手機
---
# 編譯與安裝 APK 工作流 (compiler-apk)
這個 Workflow 主要是協助您一鍵完成 Android 專案的編譯、匯出與安裝。當您呼叫 `/compiler-apk`AI 會自動為您執行以下步驟:
## 步驟一:設定環境並編譯 Debug APK
使用 Java 17 與 Gradle 編譯專屬 Flavor (`S_12_XY_U_Standard_Debug`) 的程式碼。
```bash
// turbo
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
./gradlew :app:assembleS_12_XY_U_Standard_Debug
```
## 步驟二:導出 APK 到 outputs 資料夾
將編譯出來的深層 APK 複製到專案最外層的 `outputs` 資料夾,並統一命名為 `TaiwanStar_debug.apk`
```bash
// turbo
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
mkdir -p outputs
cp app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk outputs/TaiwanStar_debug.apk
echo "✅ APK 已經複製到專案根目錄的 outputs 資料夾中"
```
## 步驟三:透過 Windows ADB 安裝到手機 / 機台
利用 WSL 串接 Windows 端的 ADB將熱騰騰的 APK 覆蓋安裝到目前連接的設備上。
```bash
// turbo
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe install -r -d -t /home/mama/projects/TaiwanStar_general_size_s_Chengwai/outputs/TaiwanStar_debug.apk
```
## 步驟四:自動啟動 App
安裝完成後,直接在手機上喚醒並啟動應用程式 (`HomeActivity`)。
```bash
// turbo
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe shell am start -n com.unibuy.smartdevice/com.unibuy.smartdevice.HomeActivity
```

1
.gitignore vendored
View File

@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
/outputs

View File

@ -215,6 +215,8 @@ public class HomeActivity extends AppCompatActivityAbstract {
httpAPI = new HttpAPI(getHandlerMain());
binding.startButton.setOnClickListener(v -> {
// 經過 RestartActivity 釋放硬體資源RS232 再跳購物頁面
// RestartActivity 已修改為不再 gracefulDisconnect + killProcessMQTT 連線不中斷
getTools().gotoActivity(RestartActivity.class);
finish();
});

View File

@ -638,6 +638,7 @@ public class MainActivity extends AppCompatActivityAbstract {
finishAffinity();
// 建議先 log kill
getLogs().info("🔄MainActivity - App restarting...");
android.os.Process.killProcess(android.os.Process.myPid());
// 完全不需要 killProcess()
}

View File

@ -417,7 +417,14 @@ public class MyApp extends Application {
serviceIntent.putExtra("apiToken", apiKey);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
try {
startForegroundService(serviceIntent);
} catch (Exception e) {
// Android 12+ AlarmManager 喚醒時不允許直接啟動 ForegroundService
// 改用普通 startService Service 先啟動 Activity 進前台後再提升
logs.info("ForegroundService start blocked (background state), falling back to startService");
startService(serviceIntent);
}
} else {
startService(serviceIntent);
}

View File

@ -439,6 +439,111 @@ public class StarCloudAPI {
/**
* 將下載回來的設定儲存到 MyApp
*/
/**
* B680: 員工卡身分驗證
* 驗證成功後會回傳 code_id (staff_cards 資料表的 ID)
*/
public void verifyStaffCard(final String cardNo, final VerificationCallback callback) {
final String machineId = MyApp.getInstance().getMachine().getMachineID();
final String url = BASE_URL + "machine/staff/verify/B680";
Log.i(TAG, "Starting B680 request for card: " + cardNo);
new Thread(new Runnable() {
@Override
public void run() {
int responseCode = -1;
String receive = "";
try {
JSONObject json = new JSONObject();
// 根據 API 規格參數名應為 "code"
json.put("code", cardNo);
java.net.URL reqUrl = new java.net.URL(url);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
// 加入機台 Token 驗證
String machineToken = MyApp.getInstance().getMachine().getApiKey();
if (machineToken != null && !machineToken.isEmpty()) {
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
}
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
conn.setDoOutput(true);
byte[] bodyBytes = json.toString().getBytes("UTF-8");
conn.getOutputStream().write(bodyBytes);
conn.getOutputStream().flush();
responseCode = conn.getResponseCode();
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
? conn.getInputStream() : conn.getErrorStream();
if (is != null) {
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
receive = sb.toString();
}
conn.disconnect();
Log.d(TAG, "B680 Response (" + responseCode + "): " + receive);
} catch (final Exception e) {
Log.e(TAG, "B680 Network Error", e);
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> callback.onFailure("連線失敗: " + e.getMessage()));
return;
}
final int finalCode = responseCode;
final String finalReceive = receive;
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
if (finalCode >= 200 && finalCode < 300) {
try {
JSONObject res = new JSONObject(finalReceive);
// 根據規格後台回傳 success: true 且包含 code_id
if (res.optBoolean("success", false)) {
// 規格顯示 code_id 可能在 data 層級或直接在根層級這裡做相容處理
String codeId = "";
if (res.has("data")) {
codeId = res.optJSONObject("data").optString("code_id", "");
} else {
codeId = res.optString("code_id", "");
}
callback.onSuccess(codeId);
} else {
callback.onFailure(res.optString("message", "驗證失敗"));
}
} catch (Exception e) {
callback.onFailure("解析錯誤: " + finalCode);
}
} else if (finalCode == 404) {
callback.onFailure("查無此員工卡,請確認卡片是否已註冊");
} else if (finalCode == 403) {
callback.onFailure("此卡片目前無權限使用");
} else if (finalCode == 401) {
callback.onFailure("系統認證失效,請聯繫管理員");
} else {
// 嘗試從錯誤訊息中提取 message
try {
JSONObject res = new JSONObject(finalReceive);
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
} catch (Exception e) {
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
}
}
});
}
}).start();
}
public interface VerificationCallback {
void onSuccess(String codeId);
void onFailure(String message);
}
private void saveSettings(JSONObject config) {
// 使用 optString 避免欄位缺失導致 JSONException

View File

@ -13,7 +13,9 @@ import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.controller.DevXinYuanController;
import com.unibuy.smartdevice.structure.mqtt.CommandAckPayload;
import com.unibuy.smartdevice.structure.mqtt.CommandPayload;
import com.unibuy.smartdevice.structure.mqtt.ErrorPayload;
import com.unibuy.smartdevice.structure.mqtt.HeartbeatPayload;
import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
@ -136,6 +138,9 @@ public class MqttManager {
}, 0, 60, TimeUnit.SECONDS);
}
/**
* 一般斷線內部或非計畫性使用
*/
public void disconnect() {
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
heartbeatExecutor.shutdownNow();
@ -145,6 +150,64 @@ public class MqttManager {
}
}
/**
* 優雅重啟斷線Graceful Disconnect
*
* 依照 MQTT 3.1.1 規範Client 主動發送 DISCONNECT 封包後正常離線
* Broker 不會觸發 LWT 遺囑訊息因此可避免管理後台誤報離線
*
* 呼叫流程
* 1. publishStatus("restarting") 告知後台為計畫性重啟
* 2. 等發布完成後呼叫 client.disconnect() 正常斷線LWT 不觸發
* 3. 執行 onComplete callback 呼叫方在此執行 killProcess()
*
* @param onComplete 斷線完成後的回呼用來執行 killProcess/System.exit 等後續動作
*/
public void gracefulDisconnect(Runnable onComplete) {
// 先停止心跳避免斷線期間還在發送
if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
heartbeatExecutor.shutdownNow();
}
if (client == null || !client.getState().isConnected()) {
// 如果本來就沒有連線直接執行後續動作
Log.i(TAG, "gracefulDisconnect: MQTT not connected, skip directly.");
if (onComplete != null) onComplete.run();
return;
}
// 發布 restarting 狀態retain=true後台可感知這是計畫性重啟
String payload = "{\"status\":\"restarting\"}";
String topic = "machine/" + serialNo + "/status";
Log.i(TAG, "gracefulDisconnect: publishing restarting status...");
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.retain(true)
.send()
.whenComplete((publishResult, pubThrowable) -> {
if (pubThrowable != null) {
Log.w(TAG, "gracefulDisconnect: failed to publish restarting status, disconnecting anyway.");
} else {
Log.i(TAG, "gracefulDisconnect: restarting status published.");
}
// 無論發布成否都執行正常 DISCONNECT避免 LWT 觸發
client.disconnect()
.whenComplete((v, disconnThrowable) -> {
if (disconnThrowable != null) {
Log.w(TAG, "gracefulDisconnect: disconnect error: " + disconnThrowable.getMessage());
} else {
Log.i(TAG, "gracefulDisconnect: disconnected cleanly, LWT suppressed.");
}
// 回呼呼叫方可以安全地執行 killProcess
if (onComplete != null) onComplete.run();
});
});
}
private void subscribeToCommandTopic() {
if (client == null || serialNo == null) return;
@ -240,6 +303,58 @@ public class MqttManager {
});
}
public void publishError(int tid, String errorCode) {
if (client == null || !client.getState().isConnected()) return;
ErrorPayload payloadObj = new ErrorPayload(tid, errorCode);
String payload = gson.toJson(payloadObj);
String topic = "machine/" + serialNo + "/error";
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish error to MQTT", throwable);
} else {
Log.i(TAG, "Hardware error published to " + topic + ": " + payload);
}
});
}
/**
* 發送交易結案訊息 (MQTT Finalize)
* App 端完成所有本地動作支付 + 出貨後呼叫
* Topic: machine/[MachineID]/transaction
* 取代舊的 B600/B601/B602 分段 HTTP 上報機制
*
* @param payload TransactionFinalizeBuilder.build() 產生的完整 payload
*/
public void publishTransactionFinalize(TransactionFinalizePayload payload) {
if (client == null || !client.getState().isConnected()) {
Log.e(TAG, "Cannot publish finalize: MQTT not connected");
return;
}
String payloadJson = gson.toJson(payload);
String topic = "machine/" + serialNo + "/transaction";
client.publishWith()
.topic(topic)
.payload(payloadJson.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish transaction finalize", throwable);
} else {
Log.i(TAG, "Transaction finalize published to " + topic + ": " + payloadJson);
}
});
}
public interface MqttCommandListener {
void onCommandReceived(CommandPayload command);
}

View File

@ -78,15 +78,19 @@ public class MqttService extends Service {
if ("reboot".equals(cmd)) {
Log.i(TAG, "Executing remote reboot command...");
mqttManager.publishCommandAck(cmdId, "success", "Rebooting soon...");
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
// 徹底殺掉當前進程讓系統乾淨重啟
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
}, 500); // 縮短為 0.5 確保 ACK 已發出即可
// 🚩 優雅重啟ACK 發出後先 gracefulDisconnect不觸發 LWT killProcess
// 重啟到初始畫面 (HomeActivity)購物去再經 RestartActivity 釋放硬體不会再次 killProcess
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() ->
mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
})
, 500); // 0.5 秒確保 ACK 先發出
} else if ("lock".equals(cmd)) {
Log.i(TAG, "Executing remote lock command...");
com.unibuy.smartdevice.database.SettingsDao settingsDao = new com.unibuy.smartdevice.database.SettingsDao(getApplicationContext());
@ -108,12 +112,19 @@ public class MqttService extends Service {
mqttManager.publishCommandAck(cmdId, "success", "Machine unlocked, restarting...");
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
}, 500);
// 🚩 優雅重啟 gracefulDisconnect不觸發 LWT killProcess
// 解鎖後重啟到初始畫面 (HomeActivity)
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() ->
mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
})
, 500);
} else if ("nfc_reset".equals(cmd)) {
Log.i(TAG, "Executing remote NFC reset command...");
try {

View File

@ -0,0 +1,27 @@
package com.unibuy.smartdevice.structure.mqtt;
public class ErrorPayload {
private int tid;
private String error_code;
public ErrorPayload(int tid, String error_code) {
this.tid = tid;
this.error_code = error_code;
}
public int getTid() {
return tid;
}
public void setTid(int tid) {
this.tid = tid;
}
public String getError_code() {
return error_code;
}
public void setError_code(String error_code) {
this.error_code = error_code;
}
}

View File

@ -0,0 +1,219 @@
package com.unibuy.smartdevice.structure.mqtt;
import java.util.List;
/**
* MQTT Finalize 交易結案訊息的資料結構
* 對應後台 machine/[ID]/transaction Topic payload 格式
*
* 此訊息在 App 端完成所有本地動作支付 + 出貨統一發送一次
* 取代原本的分段式 HTTP 上報 (B600/B601/B602)
*/
public class TransactionFinalizePayload {
/** 固定為 "finalize" */
private final String action = "finalize";
/** 機台流水號(由 reportFlowId 填入,若無則以 order_no 代替) */
private String flow_id;
/**
* 特定交易的資料庫原始 ID
* B660(取貨碼) / B670(通行碼) / B680(員工卡) 驗證成功後後台返回的 ID
* 一般現金/電子支付交易此欄位填 0
*/
private int code_id;
/** 訂單資訊 */
private OrderPayload order;
/** 每個貨道的出貨結果列表 */
private List<DispenseRecord> dispense;
// =========== Getters & Setters ===========
public String getAction() { return action; }
public String getFlow_id() { return flow_id; }
public void setFlow_id(String flow_id) { this.flow_id = flow_id; }
public int getCode_id() { return code_id; }
public void setCode_id(int code_id) { this.code_id = code_id; }
public OrderPayload getOrder() { return order; }
public void setOrder(OrderPayload order) { this.order = order; }
public List<DispenseRecord> getDispense() { return dispense; }
public void setDispense(List<DispenseRecord> dispense) { this.dispense = dispense; }
// =========== 內部類別訂單資訊 ===========
public static class OrderPayload {
/** 訂單編號(本地時間戳 UUID */
private String order_no;
/** 商品原始總金額 */
private int original_amount;
/** 折扣金額 */
private int discount_amount;
/** 實際應付金額 */
private int total_amount;
/** 實際支付金額 */
private int pay_amount;
/** 找零金額 */
private int change_amount;
/** 使用點數 */
private int points_used;
/**
* 支付類型代碼
* 1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼,
* 7:來店禮, 8:問卷, 9:零錢, 30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi,
* 34:全盈+, 40:會員驗證取貨, 41:員工卡, 60:點數全額折抵
*/
private int payment_type;
/**
* 支付狀態1 = 成功
*/
private int payment_status;
/** 支付 Request 原始內容 (JSON String) */
private String payment_request;
/** 支付 Response 原始內容 (JSON String) */
private String payment_response;
/** 會員條碼 */
private String member_barcode;
/** 發票資訊 */
private String invoice_info;
/** 機台本地時間 (yyyy-MM-dd HH:mm:ss) */
private String machine_time;
/** 購買商品列表 */
private List<ItemRecord> items;
// Getters & Setters
public String getOrder_no() { return order_no; }
public void setOrder_no(String order_no) { this.order_no = order_no; }
public int getOriginal_amount() { return original_amount; }
public void setOriginal_amount(int original_amount) { this.original_amount = original_amount; }
public int getDiscount_amount() { return discount_amount; }
public void setDiscount_amount(int discount_amount) { this.discount_amount = discount_amount; }
public int getTotal_amount() { return total_amount; }
public void setTotal_amount(int total_amount) { this.total_amount = total_amount; }
public int getPay_amount() { return pay_amount; }
public void setPay_amount(int pay_amount) { this.pay_amount = pay_amount; }
public int getChange_amount() { return change_amount; }
public void setChange_amount(int change_amount) { this.change_amount = change_amount; }
public int getPoints_used() { return points_used; }
public void setPoints_used(int points_used) { this.points_used = points_used; }
public int getPayment_type() { return payment_type; }
public void setPayment_type(int payment_type) { this.payment_type = payment_type; }
public int getPayment_status() { return payment_status; }
public void setPayment_status(int payment_status) { this.payment_status = payment_status; }
public String getPayment_request() { return payment_request; }
public void setPayment_request(String payment_request) { this.payment_request = payment_request; }
public String getPayment_response() { return payment_response; }
public void setPayment_response(String payment_response) { this.payment_response = payment_response; }
public String getMember_barcode() { return member_barcode; }
public void setMember_barcode(String member_barcode) { this.member_barcode = member_barcode; }
public String getInvoice_info() { return invoice_info; }
public void setInvoice_info(String invoice_info) { this.invoice_info = invoice_info; }
public String getMachine_time() { return machine_time; }
public void setMachine_time(String machine_time) { this.machine_time = machine_time; }
public List<ItemRecord> getItems() { return items; }
public void setItems(List<ItemRecord> items) { this.items = items; }
}
// =========== 內部類別購買商品記錄 ===========
public static class ItemRecord {
/** 商品 ID後台數字 ID由 productID 字串轉換;若無法解析則填 0 */
private int product_id;
/** 單價 */
private int price;
/** 數量 */
private int quantity;
public ItemRecord(int product_id, int price, int quantity) {
this.product_id = product_id;
this.price = price;
this.quantity = quantity;
}
public int getProduct_id() { return product_id; }
public int getPrice() { return price; }
public int getQuantity() { return quantity; }
}
// =========== 內部類別出貨記錄 ===========
public static class DispenseRecord {
/** 貨道號碼(字串型態) */
private String slot_no;
/** 商品 ID */
private int product_id;
/** 商品金額 */
private int amount;
/** 使用點數 */
private int points_used;
/**
* 出貨狀態1 = 成功, 0 = 失敗
*/
private int dispense_status;
/** 出貨後剩餘庫存 */
private int remaining_stock;
/** 出貨完成時的機台本地時間 (yyyy-MM-dd HH:mm:ss) */
private String machine_time;
public DispenseRecord(String slot_no, int product_id, int amount, int points_used,
int dispense_status, int remaining_stock, String machine_time) {
this.slot_no = slot_no;
this.product_id = product_id;
this.amount = amount;
this.points_used = points_used;
this.dispense_status = dispense_status;
this.remaining_stock = remaining_stock;
this.machine_time = machine_time;
}
public String getSlot_no() { return slot_no; }
public int getProduct_id() { return product_id; }
public int getAmount() { return amount; }
public int getPoints_used() { return points_used; }
public int getDispense_status() { return dispense_status; }
public int getRemaining_stock() { return remaining_stock; }
public String getMachine_time() { return machine_time; }
}
}

View File

@ -5,6 +5,7 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
import com.unibuy.smartdevice.ui.FontendActivity;
public class AppUtils {
@ -18,6 +19,8 @@ public class AppUtils {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmManager.setExact(AlarmManager.RTC, System.currentTimeMillis() + 500, pendingIntent);
android.os.Process.killProcess(android.os.Process.myPid());
}
}

View File

@ -0,0 +1,60 @@
package com.unibuy.smartdevice.tools;
import android.content.Context;
import android.content.SharedPreferences;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* 機台本地交易流水號生成器
*
* 流水號格式YYYYMMDDXXXX
* - YYYYMMDD當日日期
* - XXXX當日從 0001 起算的 4 位序號最多 9999 /超過後循環
*
* 範例202605110001202605110002...
*
* 每天重置當偵測到日期改變時序號自動從 0001 重新開始
* 持久化流水號存放於 SharedPreferencesApp 重啟後不會遺失
*/
public class FlowIdGenerator {
private static final String PREFS_NAME = "FlowIdPrefs";
private static final String KEY_LAST_DATE = "last_date";
private static final String KEY_COUNTER = "counter";
private static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyyMMdd", Locale.getDefault());
/**
* 取得下一個流水號每次呼叫自動遞增
*
* @param context Android Context
* @return 格式為 "YYYYMMDDXXXX" 的流水號字串例如 "202605110001"
*/
public static synchronized String next(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String today = DATE_FORMAT.format(new Date());
String lastDate = prefs.getString(KEY_LAST_DATE, "");
int counter;
if (!today.equals(lastDate)) {
// 新的一天重置計數器
counter = 1;
} else {
// 同一天遞增計數器超過 9999 後循環回 1
counter = prefs.getInt(KEY_COUNTER, 0) + 1;
if (counter > 9999) counter = 1;
}
// 持久化
prefs.edit()
.putString(KEY_LAST_DATE, today)
.putInt(KEY_COUNTER, counter)
.apply();
// 格式化日期 + 4 位補零序號
return today + String.format(Locale.getDefault(), "%04d", counter);
}
}

View File

@ -9,6 +9,7 @@ import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.controller.DevController;
import com.unibuy.smartdevice.controller.DevXinYuanController;
import com.unibuy.smartdevice.devices.PortTools;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
/**
* 遠端出貨管理器
@ -57,6 +58,7 @@ public class ProductDispenseManager {
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
boolean isCMD = false;
boolean isStatusOk = false;
String errorDetail = "";
for (byte[] buffer : devController.getReadBufferByMax()) {
if (buffer[0] == 0x02) { // VMC_02_CHECK_SLOT_STATUS
@ -65,6 +67,8 @@ public class ProductDispenseManager {
// 01: OK, 02: PDT_EMPTY (有些機型空也會允許出貨指令), 04: TMP_USE
if (buffer[1] == 0x01 || buffer[1] == 0x02 || buffer[1] == 0x04) {
isStatusOk = true;
} else {
errorDetail = String.format("%02X%02X", buffer[0], buffer[1]);
}
break;
}
@ -74,6 +78,10 @@ public class ProductDispenseManager {
if (isStatusOk) {
mainHandler.post(() -> executeActualDispense(slot));
} else {
// Error during CMD 01
if (!errorDetail.isEmpty()) {
MqttManager.getInstance().publishError(slot, errorDetail);
}
mainHandler.post(() -> listener.onFailure("貨道狀態異常,無法出貨"));
}
} else {
@ -139,6 +147,10 @@ public class ProductDispenseManager {
listener.onSuccess();
});
} else if (isFailed) {
// Error during CMD 04: construct error code e.g. "0403"
String errCode = String.format("04%s", errorDetail);
MqttManager.getInstance().publishError(slot, errCode);
String finalErrorDetail = errorDetail;
mainHandler.post(() -> listener.onFailure("出貨失敗,錯誤代碼: " + finalErrorDetail));
} else {

View File

@ -0,0 +1,172 @@
package com.unibuy.smartdevice.tools;
import android.content.Context;
import android.util.Log;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.structure.BuyStructure;
import com.unibuy.smartdevice.structure.ReportFlowInfoStructure;
import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* 交易結案 Payload 的組裝工具
*
* 使用方式
* 1. 在支付成功進入出貨流程前建立此 Builder 實例
* 2. 每次一個貨道出貨完成呼叫 {@link #addDispenseRecord} 加入結果
* 3. 全部出貨完成後呼叫 {@link #build()} 取得完整的 Payload
*
* Builder 取代舊的 B600 / B601 / B602 分段 HTTP 上報機制
*/
public class TransactionFinalizeBuilder {
private static final String TAG = "TransactionFinalizeBuilder";
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
/** 訂單建立時間(支付完成時的時間點) */
private final String orderMachineTime;
/** 機台本地流水號格式YYYYMMDDXXXX例如 202605110001 */
private final String flowId;
/** 累積的出貨結果清單 */
private final List<TransactionFinalizePayload.DispenseRecord> dispenseRecords = new ArrayList<>();
public TransactionFinalizeBuilder() {
this.orderMachineTime = SDF.format(new Date());
// 在進入出貨流程時立即生成本次交易流水號一次交易只生成一次
this.flowId = FlowIdGenerator.next(MyApp.getInstance());
Log.i(TAG, "TransactionFinalizeBuilder created, flow_id=" + this.flowId);
}
/**
* 加入一筆出貨結果記錄每個貨道出貨後呼叫一次
*
* @param slotNo 貨道號碼整數
* @param productId 商品 ID整數後台 ID
* @param amount 此貨道的商品金額
* @param pointsUsed 使用點數
* @param isSuccess 是否出貨成功
* @param remainingStock 出貨後剩餘庫存
*/
public void addDispenseRecord(int slotNo, int productId, int amount,
int pointsUsed, boolean isSuccess, int remainingStock) {
TransactionFinalizePayload.DispenseRecord record = new TransactionFinalizePayload.DispenseRecord(
String.valueOf(slotNo),
productId,
amount,
pointsUsed,
isSuccess ? 1 : 0,
remainingStock,
SDF.format(new Date())
);
dispenseRecords.add(record);
Log.d(TAG, "Added dispense record: slot=" + slotNo + " status=" + (isSuccess ? "success" : "failed"));
}
/**
* 組裝完整的 TransactionFinalizePayload
* 應在所有出貨流程結束後呼叫
*
* @return 完整的 MQTT finalize payload
*/
public TransactionFinalizePayload build() {
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
TransactionFinalizePayload payload = new TransactionFinalizePayload();
// --- flow_id機台本地流水號 (YYYYMMDDXXXX) ---
// FlowIdGenerator 在建立 Builder 時生成不依賴後端 API 回傳
payload.setFlow_id(flowId);
// --- code_id特殊驗證碼 ID (B660/B670/B680 取得) ---
// 一般現金/電子支付交易為 0
String accessCodeId = MyApp.getInstance().getReportAccessCodeStructure().getAccessCodeId();
try {
payload.setCode_id(accessCodeId != null && !accessCodeId.isEmpty()
? Integer.parseInt(accessCodeId) : 0);
} catch (NumberFormatException e) {
Log.w(TAG, "Cannot parse code_id: " + accessCodeId);
payload.setCode_id(0);
}
// --- 組裝 order ---
TransactionFinalizePayload.OrderPayload order = buildOrderPayload(flowInfo);
payload.setOrder(order);
// --- 出貨記錄 ---
payload.setDispense(new ArrayList<>(dispenseRecords));
Log.i(TAG, "TransactionFinalizePayload built: flow_id=" + flowId
+ ", items=" + order.getItems().size()
+ ", dispense=" + dispenseRecords.size());
return payload;
}
/**
* MyApp 的全域狀態組裝 OrderPayload
*/
private TransactionFinalizePayload.OrderPayload buildOrderPayload(ReportFlowInfoStructure flowInfo) {
TransactionFinalizePayload.OrderPayload order = new TransactionFinalizePayload.OrderPayload();
order.setOrder_no(flowInfo.getOrderId());
// 金額資訊
int totalAmount = flowInfo.getTotalPrice();
order.setOriginal_amount(totalAmount);
order.setDiscount_amount(0); // TODO: 如有折扣活動需進一步填入
order.setTotal_amount(totalAmount);
order.setPay_amount(totalAmount);
order.setChange_amount(flowInfo.getGiveChange());
order.setPoints_used(flowInfo.getUsePoints());
// 支付類型flowType 直接對應 payment_type
order.setPayment_type(flowInfo.getFlowType());
order.setPayment_status(1); // 走到出貨流程代表支付已成功
// 支付流程原始資料
order.setPayment_request(
flowInfo.getFlowSendInfo() != null ? flowInfo.getFlowSendInfo() : "");
order.setPayment_response(
flowInfo.getFlowRequestInfo() != null ? flowInfo.getFlowRequestInfo() : "");
// 會員資訊
order.setMember_barcode(
flowInfo.getFlowBarCode() != null ? flowInfo.getFlowBarCode() : "");
// 發票資訊
order.setInvoice_info(
flowInfo.getInvoiceInfo() != null ? flowInfo.getInvoiceInfo() : "");
// 機台時間
order.setMachine_time(orderMachineTime);
// --- 組裝購買商品清單 (items) ---
List<TransactionFinalizePayload.ItemRecord> items = new ArrayList<>();
List<BuyStructure> buyList = MyApp.getInstance().getBuyList();
if (buyList != null) {
for (BuyStructure buy : buyList) {
int productId = 0;
try {
productId = Integer.parseInt(buy.getProduct().getProductID());
} catch (NumberFormatException e) {
Log.w(TAG, "Cannot parse productID to int: " + buy.getProduct().getProductID());
}
int price = buy.getProduct().getRealPrice();
int quantity = buy.getCount() > 0 ? buy.getCount() : 1;
items.add(new TransactionFinalizePayload.ItemRecord(productId, price, quantity));
}
}
order.setItems(items);
return order;
}
}

View File

@ -387,6 +387,7 @@ public class FontendActivity extends AppCompatActivityAbstract {
finishAffinity();
// 建議先 log kill
getLogs().info("🔄MainActivity - App restarting...");
Process.killProcess(Process.myPid());
}
@ -755,6 +756,7 @@ public class FontendActivity extends AppCompatActivityAbstract {
Intent intent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});

View File

@ -1,15 +1,11 @@
package com.unibuy.smartdevice.ui;
import android.annotation.SuppressLint;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import com.unibuy.smartdevice.AppCompatActivityAbstract;
import com.unibuy.smartdevice.MyApp;
@ -17,6 +13,7 @@ import com.unibuy.smartdevice.databinding.ActivityRestartBinding;
import com.unibuy.smartdevice.devices.Rs232Dev;
import com.unibuy.smartdevice.exception.LogsIOException;
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.tools.ToastHandlerMain;
@ -95,32 +92,15 @@ public class RestartActivity extends AppCompatActivityAbstract {
restartApp();
}
@SuppressLint("ScheduleExactAlarm")
private void restartApp() {
// Intent intent = new Intent(getApplicationContext(), FontendActivity.class);
// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
//
// PendingIntent pendingIntent = PendingIntent.getActivity(
// getApplicationContext(), 0, intent, PendingIntent.FLAG_IMMUTABLE);
//
// AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
// alarmManager.setExact(AlarmManager.RTC, System.currentTimeMillis() + 1000, pendingIntent);
//
// android.os.Process.killProcess(android.os.Process.myPid());
// 🚩 硬體資源已在 onCreate() 釋放完畢直接跳購物頁面
// 不需要 gracefulDisconnect + killProcessMQTT 已在線保持連線不中斷
Intent intent = new Intent(getApplicationContext(), FontendActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
new Handler(Looper.getMainLooper()).postDelayed(() -> {
getApplicationContext().startActivity(intent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
}, 200);
//finishAffinity();
startActivity(intent);
}
public static class DeviceResourceManager {
private static DeviceResourceManager instance;

View File

@ -56,28 +56,7 @@ public class SystemSetActivity extends AppCompatActivityAbstract {
finish();
});
// IC 卡讀取功能實作
binding.editTextCardNo.requestFocus();
binding.buttonClearCardNo.setOnClickListener(v -> {
binding.editTextCardNo.setText("");
binding.editTextCardNo.requestFocus();
});
binding.editTextCardNo.setOnKeyListener((v, keyCode, event) -> {
if (keyCode == android.view.KeyEvent.KEYCODE_ENTER && event.getAction() == android.view.KeyEvent.ACTION_UP) {
// 當讀卡機輸入完成並送出 Enter 時的處理 (如果需要)
getLogs().info("IC 卡號輸入完成: " + binding.editTextCardNo.getText().toString());
return true;
}
return false;
});
// 避免點擊其他地方導致失去焦點無法讀卡可在必要時重新獲取焦點
binding.editTextCardNo.setOnFocusChangeListener((v, hasFocus) -> {
if (!hasFocus) {
// 如果需要強制聚焦可以在這裡處理但通常讓使用者手動點選回欄位即可
}
});
// IC 卡讀取功能已移除
}
private void updateCheckBoxes() {

View File

@ -86,6 +86,37 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
holder.binding.editCount.setOnEditorActionListener(new editCountOnEditorActionListener(holder.binding, position));
holder.binding.editCount.setOnClickListener(new editCountOnClickListener(holder.binding, slot.getCount()));
// 修正移除不穩定的失去焦點邏輯改用即時監聽 (TextWatcher)
// 先移除舊的監聽器如果有避免 RecyclerView 重用 View 時產生衝突
if (holder.binding.editCount.getTag() instanceof android.text.TextWatcher) {
holder.binding.editCount.removeTextChangedListener((android.text.TextWatcher) holder.binding.editCount.getTag());
}
android.text.TextWatcher textWatcher = new android.text.TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(android.text.Editable s) {
// 只有在輸入框有焦點使用者正在打字時才更新資料避免 setText 觸發循環
if (holder.binding.editCount.hasFocus()) {
int newCount = 0;
if (s.length() > 0) {
try {
newCount = Integer.parseInt(s.toString());
} catch (NumberFormatException ignored) {}
}
slotList.get(position).setCount(newCount);
holder.binding.textCount.setText(String.valueOf(newCount));
}
}
};
holder.binding.editCount.addTextChangedListener(textWatcher);
holder.binding.editCount.setTag(textWatcher); // 將監聽器存入 Tag 以便下次重用時移除
tools.setImmHideByFocusChange(holder.binding.editCount);
}

View File

@ -112,6 +112,12 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
binding.buttonSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 強制清除目前焦點確保輸入框 (editCount) 觸發失去焦點儲存事件
View currentFocus = getCurrentFocus();
if (currentFocus != null) {
currentFocus.clearFocus();
}
// 1. 本地儲存
saveData();

View File

@ -230,109 +230,36 @@ public class CashPayDialog extends DialogAbstract {
}
/**
* 顯示出貨對話框設置金流類型並調用 B600 接口
* B600 調用後延遲 2 秒再顯示 DispatchDialog以確保 B600 完成後再調用 B602
* [MQTT Finalize 重構]
* 顯示出貨對話框
* 原本會先呼叫 B600 再延遲 2 秒跳轉此設計因 Race Condition 風險已廢棄
* 現在改為直接將支付資訊寫入 MyApp 全域狀態再跳轉至 DispatchDialog
* 完整的交易資料將在所有出貨動作完成後 DispatchDialog 統一透過 MQTT Finalize 送出
*/
private void showDispatchDialog() {
if (getCtx() != null) {
// 設置現金支付的 flowType 9
MyApp.getInstance().getReportFlowInfoData().setFlowType(9);
if (getCtx() == null) return;
// 調用 B600 接口
callB600AndProceed();
// 填入現金支付資訊至全域狀態 TransactionFinalizeBuilder 使用
com.unibuy.smartdevice.structure.ReportFlowInfoStructure flowInfo =
MyApp.getInstance().getReportFlowInfoData();
// 延遲 2 秒後再顯示 DispatchDialog確保 B600 完成後再調用 B602
new Handler(Looper.getMainLooper()).postDelayed(() -> {
flowInfo.setFlowType(9); // 現金支付
flowInfo.setFlowSendInfo("收現金:" + inCashSAVE + "元/找零:" + redundantCash + "");
flowInfo.setFlowRequestInfo(""); // 現金無原始請求字串
flowInfo.setOrderId(com.unibuy.smartdevice.tools.Tools.generateTimeBasedUUID());
flowInfo.setTotalPrice(getTotalPrice());
flowInfo.setFlowBarCode(""); // 現金交易無條碼
flowInfo.setFlowStatusType(1); // 1 = 成功
flowInfo.setGiveChange(redundantCash);
flowInfo.setUsePoints(0);
if (!isDispatchDialogShown) {
isDispatchDialogShown = true;
changeDialog(DispatchDialog.class);
}
}, 2000);
// 現金支付無需等待後端確認直接跳轉出貨
if (!isDispatchDialogShown) {
isDispatchDialogShown = true;
changeDialog(DispatchDialog.class);
}
}
/**
* 調用 B600 接口上傳交易資訊
*/
private void callB600AndProceed() {
try {
// 1. 準備 B600 請求參數
ReportFlowInfoStructure reportFlowInfoData = MyApp.getInstance().getReportFlowInfoData();
// 設置 B600 參數
reportFlowInfoData.setFlowType(9); // 現金支付
reportFlowInfoData.setFlowSendInfo("收現金:" + inCashSAVE + "元/找零:" + redundantCash + ""); // req4 空字串
reportFlowInfoData.setFlowRequestInfo(""); // req5 空字串
MyApp.getInstance().getReportFlowInfoData().setOrderId(Tools.generateTimeBasedUUID());
MyApp.getInstance().getPostInvoiceByGreenData()
.setOrderId(MyApp.getInstance().getReportFlowInfoData().getOrderId());
reportFlowInfoData.setProductId(MyApp.getInstance().getBuyList().get(0).getProduct().getProductID()); // req6
// 商品ID
reportFlowInfoData.setTotalPrice(getTotalPrice()); // req7 商品價格
// 保留已有的發票信息不強制清空
// reportFlowInfoData.setInvoiceInfo(""); // 移除這行保留使用者在發票對話框輸入的資料
reportFlowInfoData.setOrderId(Tools.generateTimeBasedUUID()); // req9 訂單ID
reportFlowInfoData.setFlowBarCode(""); // req10 空字串
reportFlowInfoData.setFlowStatusType(1); // req12 設為1表示成功
reportFlowInfoData.setGiveChange(0); // req13 找零金額
reportFlowInfoData.setUsePoints(0); // req14 設為0
// 設置購物車信息
JSONArray shoppingCartArray = new JSONArray();
JSONObject cartItem = new JSONObject();
cartItem.put("pid", "0");
cartItem.put("amount", "0");
cartItem.put("num", "0");
shoppingCartArray.put(cartItem);
reportFlowInfoData.setShoppingCartInfoByJsonArray(shoppingCartArray); // req16
// 2. 調用 B600
final HttpAPI[] httpAPIRef = new HttpAPI[1];
httpAPIRef[0] = new HttpAPI(new HandlerMain(getCtx(), getLogs()) {
@Override
protected void execute(Context context, int commandCode, String message) {
if (commandCode == HttpAPI.Option.REPORT_FLOW_RESPONSE.getOption()) {
// 保存金流ID到 MyApp
String flowId = message;
MyApp.getInstance().getReportFlowInfoData().setReportFlowId(flowId);
// 同時保存金流ID到 ReportFlowInfoData
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
if (flowInfo != null) {
flowInfo.setReportFlowId(flowId);
}
// 檢查是否有發票信息有的話調用綠界科技開立發票
if (!MyApp.getInstance().getReportFlowInfoData().getInvoiceInfo().isEmpty()) {
try {
httpAPIRef[0].postInvoiceByGreen(MyApp.getInstance().getPostInvoiceByGreenData());
} catch (Exception e) {
getLogs().warning(e);
}
}
} else if (commandCode == HttpAPI.Option.GREEN_INVOICE_RESPONSE.getOption()) {
// 綠界發票開立成功
getLogs().info("綠界發票開立成功(現金支付): " + message);
}
}
});
// 調用 B600
httpAPIRef[0].reportFlowInfo(reportFlowInfoData);
} catch (Exception e) {
getLogs().error(ErrorCode.UNKNOWN_ERROR, "調用 B600 失敗: " + e.getMessage());
// 如果調用失敗仍然進入出貨流程但可能會有問題
if (!isDispatchDialogShown) {
isDispatchDialogShown = true;
changeDialog(DispatchDialog.class);
}
}
}
/// ////
// 接收設備的回應
/**

View File

@ -23,6 +23,7 @@ import com.unibuy.smartdevice.controller.DevController;
import com.unibuy.smartdevice.controller.DevDigitalDisplayController;
import com.unibuy.smartdevice.controller.DevElectricController;
import com.unibuy.smartdevice.database.SlotsDao;
import com.unibuy.smartdevice.databinding.DialogDispatchBinding;
import com.unibuy.smartdevice.devices.ComPort;
import com.unibuy.smartdevice.devices.PortTools;
@ -35,6 +36,7 @@ import com.unibuy.smartdevice.exception.LogsSecurityException;
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
import com.unibuy.smartdevice.external.HttpAPI;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
import com.unibuy.smartdevice.structure.BuyStructure;
import com.unibuy.smartdevice.structure.DispatchStructure;
import com.unibuy.smartdevice.structure.ProductStructure;
@ -44,6 +46,7 @@ import com.unibuy.smartdevice.structure.SlotStructure;
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.tools.ToastHandlerMain;
import com.unibuy.smartdevice.tools.TransactionFinalizeBuilder;
import com.unibuy.smartdevice.ui.FontendActivity;
import com.unibuy.smartdevice.ui.recycler.RecyclerDialogDispatchListAdpter;
import com.unibuy.smartdevice.ui.tools.ImageGlide;
@ -144,14 +147,9 @@ public class DispatchDialog extends DialogAbstract {
closeButtonOk();
break;
case REPORT_DISPATCH:
if (isReportDispatch) {
try {
isReportDispatch = false;
new HttpAPI(getHandlerMain()).reportDispatchInfo(MyApp.getInstance().getReportDispatchInfoData());
} catch (LogsSettingEmptyException | LogsParseException e) {
getLogs().warning(e);
}
}
// [已停用] 舊的 B602 HTTP 分段上報改由 MQTT Finalize 統一結案
// 此事件仍保留以維持向下相容但不執行任何 HTTP 呼叫
getLogs().debug("REPORT_DISPATCH event received but skipped (MQTT Finalize mode)");
break;
case SET_MESSAGE_TEXT:
setTextViewSmall(message);
@ -223,6 +221,8 @@ public class DispatchDialog extends DialogAbstract {
private HandlerMainCountdown buttonControlCountdown;
private HttpAPI httpAPI;
private final List<String> deliveryLogs = new ArrayList<>();
/** MQTT Finalize 模式:累積本次交易的所有出貨記錄,最後統一送出 */
private TransactionFinalizeBuilder finalizeBuilder;
public DispatchDialog(Context context, HandlerMain srcHandlerMain) {
super(context, srcHandlerMain);
@ -239,6 +239,8 @@ public class DispatchDialog extends DialogAbstract {
isReportDispatch = true;
tvCountdown = binding.tvCountdown;
httpAPI = new HttpAPI(getHandlerMain());
// 初始化 Finalize Builder在支付完成進入出貨流程時建立
finalizeBuilder = new TransactionFinalizeBuilder();
MyApp.getInstance().setReportFreeGiftCodeStructure(new ReportFreeGiftCodeStructure("", 0, "", 0, 0, ""));
@ -418,6 +420,7 @@ public class DispatchDialog extends DialogAbstract {
boolean isCMD = false;
boolean isStatus = false;
String errorDetail = "";
for (byte[] buffer: devController.getReadBufferByMax()) {
if (buffer[0] == 0x02) {
@ -427,6 +430,8 @@ public class DispatchDialog extends DialogAbstract {
if (buffer[1] == 0x01 || buffer[1] == 0x02 || buffer[1] == 0x04) {
isStatus = true;
break;
} else {
errorDetail = String.format("%02X%02X", buffer[0], buffer[1]);
}
}
}
@ -440,6 +445,9 @@ public class DispatchDialog extends DialogAbstract {
MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(new DevGetProductByVMC(handlerMain, slot), 5000);
MyApp.getInstance().getDevXinYuanController().addSendBuffer(MyApp.getInstance().getDevXinYuanController().getDevXinYuan().getProduct(slot));
} else {
if (!errorDetail.isEmpty()) {
MqttManager.getInstance().publishError(slot, errorDetail);
}
buttonControlCountdown.shutdown();
getSrcHandlerMain().start(getClass().getSimpleName(), Option.ERROR_PROCESS_GOODS.getOption(), getCtx().getString(R.string.check_whether_goods_have_been_taken_out_before_confirming_collection));//請檢查商品有無取出再確認取貨
}
@ -487,6 +495,7 @@ public class DispatchDialog extends DialogAbstract {
boolean isCMD = false;
boolean inTheMiddle = false;
boolean isSuccess = false;
String errorDetail = "";
for (byte[] buffer: devController.getReadBufferByMax()) {
if (buffer[0] == 0x04) {
@ -510,6 +519,9 @@ public class DispatchDialog extends DialogAbstract {
isSuccess = true;
break;
}
// If it's a known error code
errorDetail = String.format("04%02X", buffer[1]);
}
}
@ -550,6 +562,9 @@ public class DispatchDialog extends DialogAbstract {
MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(new DevGetProductByVMC(handlerMain, slot),5000L);
}
} else {
if (!errorDetail.isEmpty()) {
MqttManager.getInstance().publishError(slot, errorDetail);
}
buttonControlCountdown.shutdown();
getSrcHandlerMain().send(getClass().getSimpleName(), Option.ERROR_PROCESS_GOODS.getOption(), getCtx().getString(R.string.product_delivery_issues_contact_the_counter_window));
}
@ -794,38 +809,20 @@ public class DispatchDialog extends DialogAbstract {
* 3)dispatchStatusType欄位寫入0代表成功
* */
int price = product.getRealPrice() * count;
MyApp.getInstance().setReportDispatchInfoData(new ReportDispatchInfoStructure(
MyApp.getInstance().getReportFlowInfoData().getReportFlowId(),
MyApp.getInstance().getReportFlowInfoData().getFlowType(),
product.getProductID(),
slot,
price*increase,
MyApp.getInstance().getReportFlowInfoData().getOrderId(),
remaining,
MyApp.getInstance().getReportFlowInfoData().getFlowBarCode(),
"",
0,//dispatchStatusType欄位寫入0代表成功
price,
0
));
isReportDispatch = true;
if (isReportDispatch) {
try {
isReportDispatch = false;
httpAPI.reportDispatchInfo(MyApp.getInstance().getReportDispatchInfoData());
} catch (LogsSettingEmptyException | LogsParseException e) {
getLogs().warning(e);
}
}
// [MQTT Finalize 模式] 累積出貨成功記錄最後統一送出
int productIdInt = 0;
try { productIdInt = Integer.parseInt(product.getProductID()); } catch (Exception ignored) {}
finalizeBuilder.addDispenseRecord(slot, productIdInt, price * increase, 0, true, remaining);
getLogs().info("[Finalize] 已累積出貨記錄: slot=" + slot + ", product=" + productIdInt + ", remaining=" + remaining);
getLogs().info("count:" + count +" quantity:" + quantity);
if (count == quantity) { //貨物是否已經出貨完畢
//偵測是否還有下個出貨商品
if (this.dispatchIndex >= this.dispatchList.size()-1) { //沒有貨
// 所有商品出貨完畢結束流程
getLogs().info("所有商品出貨完畢,結束出貨流程。");
// 所有商品出貨完畢發送 MQTT Finalize 結案訊息
getLogs().info("所有商品出貨完畢,發送 MQTT Finalize 結案訊息。");
MqttManager.getInstance().publishTransactionFinalize(finalizeBuilder.build());
getSrcHandlerMain().send(getClass().getSimpleName(), FontendActivity.Option.RESET_DATA.getOption(), "update data");
saveData();

View File

@ -18,6 +18,9 @@ import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
import com.unibuy.smartdevice.tools.ToastHandlerMain;
import com.unibuy.smartdevice.ui.FontendActivity;
import com.unibuy.smartdevice.external.StarCloudAPI;
import com.unibuy.smartdevice.exception.ErrorCode;
import com.unibuy.smartdevice.tools.Tools;
import java.util.HashMap;
import java.util.Map;
@ -176,36 +179,48 @@ public class MemberCardPickupDialog extends DialogAbstract {
private void handleCardDetected(String cardNo) {
if (!cardNo.isEmpty()) {
getLogs().info("MemberCardPickupDialog - Card Detected: " + cardNo);
// 檢查是否為指定的授權卡號
if (cardNo.equals("0640741922")) {
getLogs().info("MemberCardPickupDialog - Authorized Card Confirmed: 0640741922");
MyApp.getInstance().getReportFlowInfoData().setReportFlowId(cardNo);
// 跳轉到出貨
new Handler(Looper.getMainLooper()).postDelayed(() -> {
getHandlerMain().send(getClass().getSimpleName(), Option.CHANGE_DISPATCH.getOption(), "change dispatch");
}, 300);
} else {
getLogs().info("MemberCardPickupDialog - Unauthorized Card: " + cardNo);
// 顯示錯誤訊息在畫面上
binding.editCardNo.setText("");
binding.editCardNo.setHint("無效卡號將於5秒後關閉");
cardBuffer.setLength(0); // 清空緩衝區
// 使用 Toast 提示
getHandlerMain().start(getClass().getSimpleName(), Option.TOAST.getOption(), "此卡號無領取權限,視窗即將關閉");
binding.editCardNo.setHint("正在驗證卡片...");
binding.editCardNo.setText("");
// 5 秒後自動關閉對話框
new Handler(Looper.getMainLooper()).postDelayed(() -> {
if (isShowing()) {
dialogCancel();
}
}, 5000);
}
// 呼叫 B680 遠端驗證
new StarCloudAPI(getHandlerMain(), getHandlerMain()).verifyStaffCard(cardNo, new StarCloudAPI.VerificationCallback() {
@Override
public void onSuccess(String codeId) {
getLogs().info("MemberCardPickupDialog - B680 Success, code_id: " + codeId);
// 設定支付資訊
MyApp.getInstance().getReportFlowInfoData().setFlowType(41); // 員工卡
MyApp.getInstance().getReportFlowInfoData().setReportFlowId(cardNo);
MyApp.getInstance().getReportFlowInfoData().setFlowBarCode(cardNo);
// 生成訂單編號 (避免後台找不到 order_no)
MyApp.getInstance().getReportFlowInfoData().setOrderId(Tools.generateTimeBasedUUID());
// 設定交易總金額 (員工卡設為 0 )
MyApp.getInstance().getReportFlowInfoData().setTotalPrice(0);
// 儲存 code_id (用於 MQTT finalize)
MyApp.getInstance().getReportAccessCodeStructure().setAccessCodeId(codeId);
// 跳轉到出貨
new Handler(Looper.getMainLooper()).postDelayed(() -> {
getHandlerMain().send(getClass().getSimpleName(), Option.CHANGE_DISPATCH.getOption(), "change dispatch");
}, 300);
}
@Override
public void onFailure(String message) {
getLogs().error(ErrorCode.NETWORK_UNKNOWN_ERROR, "MemberCardPickupDialog - B680 Failed: " + message);
binding.editCardNo.setText("");
binding.editCardNo.setHint(message);
cardBuffer.setLength(0);
getHandlerMain().start(getClass().getSimpleName(), Option.TOAST.getOption(), message);
}
});
}
}
public void changeDispatch() {
if (isChangeDispatch) {
isChangeDispatch = false;

View File

@ -291,41 +291,7 @@
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:orientation="horizontal"
android:gravity="center_vertical" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="26sp"
android:text="IC卡讀取" />
<EditText
android:id="@+id/edit_text_card_no"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="26sp"
android:hint="請感應卡片"
android:focusable="true"
android:focusableInTouchMode="true"
android:background="@null"
android:inputType="none"
android:singleLine="true" />
<Button
android:id="@+id/button_clear_card_no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清除"
android:textSize="20sp"
android:layout_marginStart="10dp"
android:backgroundTint="@color/black" />
</LinearLayout>
</LinearLayout>
<View