diff --git a/.agents/workflows/compiler-apk.md b/.agents/workflows/compiler-apk.md new file mode 100644 index 0000000..7e6abfd --- /dev/null +++ b/.agents/workflows/compiler-apk.md @@ -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 +``` diff --git a/.gitignore b/.gitignore index aa724b7..d91df7a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +/outputs diff --git a/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java b/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java index be7a986..d8a0388 100644 --- a/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/HomeActivity.java @@ -215,6 +215,8 @@ public class HomeActivity extends AppCompatActivityAbstract { httpAPI = new HttpAPI(getHandlerMain()); binding.startButton.setOnClickListener(v -> { + // 經過 RestartActivity 釋放硬體資源(RS232 等),再跳購物頁面 + // RestartActivity 已修改為不再 gracefulDisconnect + killProcess,MQTT 連線不中斷 getTools().gotoActivity(RestartActivity.class); finish(); }); diff --git a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java index 512456e..4b29299 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MainActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/MainActivity.java @@ -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() } diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index 87933ae..a0c5509 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -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); } diff --git a/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java index 050c11f..978fa5a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/StarCloudAPI.java @@ -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 diff --git a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java index 9902a41..fc597e5 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java @@ -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); } diff --git a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java index 0fe7b03..1a2fcf3 100644 --- a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java +++ b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java @@ -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 { diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/ErrorPayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/ErrorPayload.java new file mode 100644 index 0000000..3ba53ef --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/ErrorPayload.java @@ -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; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java new file mode 100644 index 0000000..71cdd04 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java @@ -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 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 getDispense() { return dispense; } + public void setDispense(List 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 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 getItems() { return items; } + public void setItems(List 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; } + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/AppUtils.java b/app/src/main/java/com/unibuy/smartdevice/tools/AppUtils.java index fb099fa..52d8ee6 100644 --- a/app/src/main/java/com/unibuy/smartdevice/tools/AppUtils.java +++ b/app/src/main/java/com/unibuy/smartdevice/tools/AppUtils.java @@ -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()); } } diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/FlowIdGenerator.java b/app/src/main/java/com/unibuy/smartdevice/tools/FlowIdGenerator.java new file mode 100644 index 0000000..0ea8383 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/FlowIdGenerator.java @@ -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 筆/日,超過後循環) + * + * 範例:202605110001、202605110002、... + * + * 每天重置:當偵測到日期改變時,序號自動從 0001 重新開始。 + * 持久化:流水號存放於 SharedPreferences,App 重啟後不會遺失。 + */ +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); + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/ProductDispenseManager.java b/app/src/main/java/com/unibuy/smartdevice/tools/ProductDispenseManager.java index 4f6fbca..f2df20e 100644 --- a/app/src/main/java/com/unibuy/smartdevice/tools/ProductDispenseManager.java +++ b/app/src/main/java/com/unibuy/smartdevice/tools/ProductDispenseManager.java @@ -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 { diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/TransactionFinalizeBuilder.java b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionFinalizeBuilder.java new file mode 100644 index 0000000..b118069 --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionFinalizeBuilder.java @@ -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 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 items = new ArrayList<>(); + List 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; + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java index 883f9e5..7c3715a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java @@ -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); }); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/RestartActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/RestartActivity.java index f86201b..cf2eaa3 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/RestartActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/RestartActivity.java @@ -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 + killProcess:MQTT 已在線,保持連線不中斷 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; diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/SystemSetActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/SystemSetActivity.java index ebf6fa9..80040bb 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/SystemSetActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/SystemSetActivity.java @@ -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() { diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcSlotListAdpter.java b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcSlotListAdpter.java index 0de10f3..eb6cd29 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcSlotListAdpter.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcSlotListAdpter.java @@ -86,6 +86,37 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter 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); } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java index af59e47..3d86a90 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcSlotListActivity.java @@ -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(); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CashPayDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CashPayDialog.java index d716ec6..2316ae3 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CashPayDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CashPayDialog.java @@ -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); - } - } - } - /// //// // 接收設備的回應 /** diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java index 9564050..90ebea7 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DispatchDialog.java @@ -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 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(); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/MemberCardPickupDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/MemberCardPickupDialog.java index becafce..f184e07 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/MemberCardPickupDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/MemberCardPickupDialog.java @@ -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; diff --git a/app/src/main/res/layout/activity_system_set.xml b/app/src/main/res/layout/activity_system_set.xml index 7a5feb5..a5cd223 100644 --- a/app/src/main/res/layout/activity_system_set.xml +++ b/app/src/main/res/layout/activity_system_set.xml @@ -291,41 +291,7 @@ - - - - - -