From ebfa25da72d15eada663b398cb269d56ab676327 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 23 Jun 2026 09:09:10 +0800 Subject: [PATCH 1/4] =?UTF-8?q?[FEAT]=20=E5=BC=B7=E5=8C=96=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E4=B8=8A=E5=A0=B1=E6=AD=A3=E7=A2=BA=E6=80=A7=EF=BC=9A?= =?UTF-8?q?=E6=89=8B=E6=A9=9F=E6=94=AF=E4=BB=98=E8=B7=AF=E7=94=B1=E9=8D=B5?= =?UTF-8?q?=E5=85=A8=E7=A8=8B=E4=B8=80=E8=87=B4=20+=20=E7=B5=90=E6=A1=88?= =?UTF-8?q?=E7=99=BC=E4=BB=B6=E7=AE=B1=E9=98=B2=E6=BC=8F=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 手機支付 flowType 自按鈕起即為 10(原 4):BuyDialog 手機感應鈕、BuyDialog/ BuyPickupCodeDialog/InvoiceBarcodeDialog/InvoiceCustomerIdentifierDialog/ InvoiceDonationDialog 的支付路由 case 4→10,realPayment 判斷一併更新; DevSetStructure.canMobilePay 與 TransactionFinalizePayload 註解同步標為 10。 2. CheckoutNfcPhoneDialog 成功時不再覆寫 flowType(原 4→10),改為成功/失敗/取消 各出口全程一致上報 10,修正失敗手機感應誤標後台 4(紙鈔機/Bill Acceptor)的問題。 3. 新增 TransactionOutbox:結案 payload 先持久化(SharedPreferences per-key)再送出, broker PUBACK 才移除;連線恢復與心跳每 60s 自動補送,解決斷線/斷電/App 被殺時 finalize 漏送。 4. MqttManager.publishTransactionFinalize 改走 Outbox,斷線不再直接丟棄;新增 rawPublishTransaction(成功/失敗雙回呼)供 Outbox 實際送出。 5. Outbox 全程單一背景執行緒處理不卡 UI;in-flight 標記避免同筆重複送;連續失敗達 上限移入死信(保留資料、出聲告警);addConnectedListener 連線恢復清 in-flight 並補送。 Co-Authored-By: Claude Opus 4.8 --- .../external/mqtt/MqttManager.java | 44 +++- .../structure/DevSetStructure.java | 2 +- .../mqtt/TransactionFinalizePayload.java | 2 +- .../smartdevice/tools/TransactionOutbox.java | 217 ++++++++++++++++++ .../smartdevice/ui/dialog/BuyDialog.java | 8 +- .../ui/dialog/BuyPickupCodeDialog.java | 2 +- .../ui/dialog/CheckoutNfcPhoneDialog.java | 6 +- .../ui/dialog/InvoiceBarcodeDialog.java | 2 +- .../InvoiceCustomerIdentifierDialog.java | 2 +- .../ui/dialog/InvoiceDonationDialog.java | 2 +- 10 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java 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 5965b65..6d2bdc7 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 @@ -110,6 +110,13 @@ public class MqttManager { } catch (Exception e) { logs.info("OTA resume on connect failed: " + e.getMessage()); } + // 連線就緒後補送未送出的交易結案(先清 in-flight,舊連線在途的送出已失效)。 + try { + com.unibuy.smartdevice.tools.TransactionOutbox.onConnected( + com.unibuy.smartdevice.MyApp.getInstance().getApplicationContext()); + } catch (Exception e) { + logs.info("Transaction outbox resume on connect failed: " + e.getMessage()); + } }) .buildAsync(); @@ -149,6 +156,10 @@ public class MqttManager { } int temperature = DevXinYuanController.getTemperature(); publishHeartbeat(ver, temperature); + // 心跳順帶 drain 發件箱:即使連線健康時單筆 publish 曾失敗,也能在此定時補送, + // 不必等到下次斷線重連(修正 outbox「只在重連才補送」的缺陷)。 + com.unibuy.smartdevice.tools.TransactionOutbox.resumeIfPending( + MyApp.getInstance().getApplicationContext()); } catch (Exception e) { logs.info("Error in heartbeat scheduler: " + e.getMessage()); } @@ -361,14 +372,26 @@ public class MqttManager { * @param payload 由 TransactionFinalizeBuilder.build() 產生的完整 payload */ public void publishTransactionFinalize(TransactionFinalizePayload payload) { + // 改走本地發件箱(Outbox):先持久化、再嘗試送出;斷線 / App 被殺不再直接遺失, + // 連線恢復時由 TransactionOutbox.resumeIfPending 補送。後台對同一 flow_id 完全冪等,重送安全。 + com.unibuy.smartdevice.tools.TransactionOutbox.submit( + MyApp.getInstance().getApplicationContext(), payload); + } + + /** + * 實際把交易結案 JSON 送到 MQTT(由 {@link com.unibuy.smartdevice.tools.TransactionOutbox} 呼叫)。 + * + * @param payloadJson 已序列化的 payload + * @param onSuccess 送出成功(broker PUBACK)後的回呼,用來從發件箱移除該筆 + * @param onFailure 送出失敗(throwable)後的回呼,用來清 in-flight / 累計失敗次數 + * @return true=已交給 MQTT 送出(成敗待 callback);false=未連線(保留於發件箱待下次補送) + */ + public boolean rawPublishTransaction(String payloadJson, Runnable onSuccess, Runnable onFailure) { if (client == null || !client.getState().isConnected()) { - logs.info("Cannot publish finalize: MQTT not connected"); - return; + logs.info("rawPublishTransaction: MQTT not connected, kept in outbox for resend"); + return false; } - - String payloadJson = gson.toJson(payload); String topic = "machine/" + serialNo + "/transaction"; - client.publishWith() .topic(topic) .payload(payloadJson.getBytes(StandardCharsets.UTF_8)) @@ -376,11 +399,18 @@ public class MqttManager { .send() .whenComplete((publishResult, throwable) -> { if (throwable != null) { - logs.info("Failed to publish transaction finalize: " + throwable.getMessage()); + logs.info("rawPublishTransaction failed (will retry): " + throwable.getMessage()); + if (onFailure != null) { + onFailure.run(); + } } else { - logs.info("Transaction finalize published to " + topic + ": " + payloadJson); + logs.info("Transaction published to " + topic); + if (onSuccess != null) { + onSuccess.run(); + } } }); + return true; } public interface MqttCommandListener { diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java b/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java index a9e9436..70151fa 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java @@ -194,7 +194,7 @@ public class DevSetStructure { /** flowType 2 電子票證(卡片支付):刷卡機開啟且卡片支付子項開啟。 */ public boolean canCardPay() { return isDevNFCPay && isDevCardPay; } - /** flowType 4 手機支付:刷卡機開啟且手機支付子項開啟。 */ + /** flowType 10 手機支付:刷卡機開啟且手機支付子項開啟。 */ public boolean canMobilePay() { return isDevNFCPay && isDevMobilePay; } /** flowType 3 玉山 EsunPay:掃碼開啟且玉山子項開啟。 */ 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 index 763ab4a..9ac991a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java @@ -184,7 +184,7 @@ public class TransactionFinalizePayload { /** * 支付類型代碼: - * 1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, + * 1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 10:手機支付, 5:通行碼, 6:取貨碼, * 7:來店禮, 8:問卷, 9:零錢, 30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi, * 34:全盈+, 40:會員驗證取貨, 41:員工卡, 60:點數全額折抵。 */ diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java new file mode 100644 index 0000000..27cc08f --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java @@ -0,0 +1,217 @@ +package com.unibuy.smartdevice.tools; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.google.gson.Gson; +import com.unibuy.smartdevice.exception.Logs; +import com.unibuy.smartdevice.external.mqtt.MqttManager; +import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 交易結案上報的本地發件箱(Outbox)。 + * + * 目的:解決「出貨完成但 MQTT 斷線 / App 被殺 → finalize 直接遺失」的漏送。 + * + * 設計重點: + * - 單一背景執行緒(outboxExecutor)處理所有落地 / 送出 / 移除:不卡 UI 主執行緒、 + * 天然序列化(不需 synchronized、無鎖競爭)。 + * - 每筆交易一個 SharedPreferences key("tx:" + entryId):O(1) 落地 / 移除,無整包重寫。 + * - 落地用 commit()(在背景緒,安全)→ 送出前確保已在磁碟,crash / 斷電不遺失。 + * - in-flight 防重送:正在送、等 PUBACK 的單會被標記,drain 不重複送同一筆(避免流量放大)。 + * - 送出成功(broker PUBACK)才移除;失敗則清 in-flight、累計失敗次數,留待下次 drain。 + * - 重試上限 / 死信:連續送出失敗達上限的「毒丸」單移入 dead: key(保留資料、不丟), + * 停止反覆重送並出聲,交維運處理。 + * - 補送觸發:連線恢復(onConnected,順帶清 in-flight 防殘留)+ 心跳定時(每 60s)。 + * + * 可靠性:QoS1 + broker PUBACK 後負責後續投遞;後台對同一 flow_id 完全冪等 + * (serial 前綴防撞號 + 終態短路 + lockForUpdate),故重送 / 亂序皆安全、不會重複扣庫存。 + * + * entryId = flow_id + ":" + status,讓 pending / failed / completed 各自獨立不互蓋。 + */ +public class TransactionOutbox { + + private static final String PREFS = "TransactionOutboxPrefs"; + private static final String KEY_PREFIX = "tx:"; // 待送出 + private static final String ATT_PREFIX = "att:"; // 累計送出失敗次數 + private static final String DEAD_PREFIX = "dead:"; // 死信(達重試上限,保留待維運) + /** 連續送出失敗(broker 層)達此上限即移入死信,停止反覆重送。 */ + private static final int MAX_ATTEMPTS = 100; + /** 堆積警戒線:超過即出聲警告(不丟資料),代表網路 / broker 長時間異常。 */ + private static final int WARN_THRESHOLD = 200; + + private static final Logs logs = new Logs(TransactionOutbox.class); + private static final Gson gson = new Gson(); + + /** 單一背景執行緒:序列化所有 outbox 操作,不卡 UI、不需鎖。 */ + private static final ExecutorService outboxExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "tx-outbox"); + t.setDaemon(true); + return t; + }); + + /** 正在送出、等待 PUBACK 的 entryId。只在 outboxExecutor 單一緒存取 → 免鎖。 */ + private static final Set inflight = new HashSet<>(); + + /** + * 結案上報:背景緒落地後嘗試送出(成功才移除)。 + * 呼叫端(出貨 / 付款結案的 UI 主執行緒)只做一次輕量序列化後立即返回,不阻塞、不做磁碟 I/O。 + */ + public static void submit(Context context, TransactionFinalizePayload payload) { + if (context == null || payload == null) { + return; + } + final Context app = context.getApplicationContext(); + final String json; + final String entryId; + try { + json = gson.toJson(payload); + String flowId = payload.getFlow_id(); + String status = (payload.getOrder() != null && payload.getOrder().getStatus() != null) + ? payload.getOrder().getStatus() : "completed"; + entryId = (flowId != null ? flowId : "unknown") + ":" + status; + } catch (Exception e) { + logs.info("TransactionOutbox.submit serialize failed: " + e.getMessage()); + return; + } + outboxExecutor.execute(() -> { + persist(app, entryId, json); + drain(app); + }); + } + + /** 心跳定時呼叫:背景緒補送所有未送出的交易(保留 in-flight 狀態)。 */ + public static void resumeIfPending(Context context) { + if (context == null) { + return; + } + final Context app = context.getApplicationContext(); + outboxExecutor.execute(() -> drain(app)); + } + + /** 連線恢復呼叫:先清 in-flight(舊連線在途的送出已失效,避免殘留卡住)再補送。 */ + public static void onConnected(Context context) { + if (context == null) { + return; + } + final Context app = context.getApplicationContext(); + outboxExecutor.execute(() -> { + inflight.clear(); + drain(app); + }); + } + + // ==== 以下全部只在 outboxExecutor 單一背景緒執行,故無併發、不需鎖 ==== + + /** 落地單筆(commit 同步寫磁碟,確保 publish 前已在磁碟)。 */ + private static void persist(Context app, String entryId, String json) { + try { + prefs(app).edit().putString(KEY_PREFIX + entryId, json).commit(); + logs.info("TransactionOutbox persisted " + entryId); + int count = countPending(prefs(app).getAll()); + if (count > WARN_THRESHOLD) { + logs.info("TransactionOutbox WARNING: " + count + + " entries pending — MQTT/network likely down for a long time"); + } + } catch (Exception e) { + logs.info("TransactionOutbox.persist failed: " + e.getMessage()); + } + } + + /** 嘗試送出所有未送出且未在途的交易;未連線即停,留待下次 drain。 */ + private static void drain(Context app) { + try { + Map all = prefs(app).getAll(); + int pending = countPending(all); + if (pending == 0) { + return; + } + logs.info("TransactionOutbox.drain: " + pending + " pending, " + inflight.size() + " in-flight"); + for (Map.Entry e : all.entrySet()) { + String key = e.getKey(); + if (key == null || !key.startsWith(KEY_PREFIX)) { + continue; + } + final String entryId = key.substring(KEY_PREFIX.length()); + if (inflight.contains(entryId)) { + continue; // A:已在送、等 PUBACK,不重送 + } + Object v = e.getValue(); + if (!(v instanceof String)) { + continue; + } + boolean sent = MqttManager.getInstance().rawPublishTransaction( + (String) v, + () -> outboxExecutor.execute(() -> onSendSuccess(app, entryId)), + () -> outboxExecutor.execute(() -> onSendFailure(app, entryId))); + if (sent) { + inflight.add(entryId); + } else { + // 未連線:其餘也會失敗,留待下次 drain(連線恢復或下次心跳)。 + logs.info("TransactionOutbox.drain: not connected, keep pending for next drain"); + break; + } + } + } catch (Exception e) { + logs.info("TransactionOutbox.drain failed: " + e.getMessage()); + } + } + + /** 送出成功(PUBACK):清 in-flight、移除該筆與其失敗計數。 */ + private static void onSendSuccess(Context app, String entryId) { + try { + inflight.remove(entryId); + prefs(app).edit().remove(KEY_PREFIX + entryId).remove(ATT_PREFIX + entryId).apply(); + logs.info("TransactionOutbox removed " + entryId + " (published)"); + } catch (Exception e) { + logs.info("TransactionOutbox.onSendSuccess failed: " + e.getMessage()); + } + } + + /** 送出失敗:清 in-flight、累計失敗;達上限移入死信(保留資料、不丟、出聲)。 */ + private static void onSendFailure(Context app, String entryId) { + try { + inflight.remove(entryId); + SharedPreferences p = prefs(app); + int att = p.getInt(ATT_PREFIX + entryId, 0) + 1; + if (att >= MAX_ATTEMPTS) { + String json = p.getString(KEY_PREFIX + entryId, null); + SharedPreferences.Editor ed = p.edit() + .remove(KEY_PREFIX + entryId) + .remove(ATT_PREFIX + entryId); + if (json != null) { + ed.putString(DEAD_PREFIX + entryId, json); // 保留資料供維運處理,不丟棄 + } + ed.apply(); + logs.info("TransactionOutbox DEAD-LETTER " + entryId + " after " + att + + " failed attempts — moved to dead:, NEEDS OPS ATTENTION"); + } else { + p.edit().putInt(ATT_PREFIX + entryId, att).apply(); + logs.info("TransactionOutbox send failed " + entryId + " attempt " + att + "/" + MAX_ATTEMPTS); + } + } catch (Exception e) { + logs.info("TransactionOutbox.onSendFailure failed: " + e.getMessage()); + } + } + + /** 計算待送出(tx:)筆數;傳入已取得的快照避免重複 getAll。 */ + private static int countPending(Map all) { + int n = 0; + for (String key : all.keySet()) { + if (key != null && key.startsWith(KEY_PREFIX)) { + n++; + } + } + return n; + } + + private static SharedPreferences prefs(Context app) { + return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java index 7004621..ab19e3a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java @@ -150,7 +150,7 @@ public class BuyDialog extends DialogAbstract { }); this.binding.buttonNfcPhone.setOnClickListener(v -> { - MyApp.getInstance().getReportFlowInfoData().setFlowType(4); + MyApp.getInstance().getReportFlowInfoData().setFlowType(10); // 手機感應=payment_type 10,全程一致(避免失敗上報撞後台 4=紙鈔機) gotoDialog(); }); @@ -492,7 +492,7 @@ public class BuyDialog extends DialogAbstract { // basic 購物模式(本專案 general/demo) 且為實際付款方式(1/2/3/4/9/30/70) 時: // 為本筆生成新流水號,並上報 pending(「進付款一律留底」)。pending → completed/failed 全程共用此 flow_id。 // 通行碼(5)/來店禮(7) 非付款,不在此處理;employee_card(晟崴)/pickup_sheet(中國醫) 無付款不送,後台資料不受影響。 - boolean realPayment = (flowType == 1 || flowType == 2 || flowType == 3 || flowType == 4 + boolean realPayment = (flowType == 1 || flowType == 2 || flowType == 3 || flowType == 10 || flowType == 9 || flowType == 30 || flowType == 70); if ("basic".equals(MyApp.getInstance().getShoppingMode()) && realPayment) { flowInfo.setMqttFlowId(com.unibuy.smartdevice.tools.FlowIdGenerator.next(MyApp.getInstance())); @@ -509,7 +509,7 @@ public class BuyDialog extends DialogAbstract { case 3: changeDialog(CheckoutEsunPayDialog.class); break; - case 4: + case 10: changeDialog(CheckoutNfcPhoneDialog.class); break; case 5: @@ -539,7 +539,7 @@ public class BuyDialog extends DialogAbstract { switch (flowType) { case 1: return d.canCreditCard(); // 信用卡(刷卡機) case 2: return d.canCardPay(); // 電子票證/卡片支付(刷卡機) - case 4: return d.canMobilePay(); // 手機支付(刷卡機) + case 10: return d.canMobilePay(); // 手機支付(刷卡機) case 3: return d.canEsunPay(); // 玉山(掃碼) case 30: return d.canTapPay(); // TapPay(掃碼) case 70: return d.canLinePay(); // Line 官方支付(獨立) diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java index ad3024d..c7992a8 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java @@ -218,7 +218,7 @@ public class BuyPickupCodeDialog extends DialogAbstract { case 3: changeDialog(CheckoutEsunPayDialog.class); break; - case 4: + case 10: changeDialog(CheckoutNfcPhoneDialog.class); break; case 5: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java index 2be368f..2e160ba 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java @@ -108,10 +108,8 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract { if (message.equals("0000")) { MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1); binding.textStatus.setText(DevNFCPay.recordMessage(message)); - // NfcPhone(手機支付):刷卡機 NCCC 電文無法區分手機錢包/實體卡, - // 依使用者所選付款鈕歸類為 payment_type=10(手機支付)。 - // 路由鍵 flowType=4 僅用於 BuyDialog 開啟本 Dialog,這裡覆寫為實際上報值。 - MyApp.getInstance().getReportFlowInfoData().setFlowType(10); + // NfcPhone(手機支付):flowType 自 BuyDialog 按鈕起即為 10(手機支付), + // 成功/失敗/取消各出口全程一致上報,不再於此覆寫(4 已不再代表手機感應)。 // [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo); // 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。 changeDispatch(); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java index ecfbc19..98d8c7e 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java @@ -375,7 +375,7 @@ public class InvoiceBarcodeDialog extends DialogAbstract { case 3: changeDialog(CheckoutEsunPayDialog.class); break; - case 4: + case 10: changeDialog(CheckoutNfcPhoneDialog.class); break; case 9: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java index e713aea..b67f1d0 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java @@ -401,7 +401,7 @@ public class InvoiceCustomerIdentifierDialog extends DialogAbstract { case 3: changeDialog(CheckoutEsunPayDialog.class); break; - case 4: + case 10: changeDialog(CheckoutNfcPhoneDialog.class); break; case 9: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java index 938a702..bc23f96 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java @@ -274,7 +274,7 @@ public class InvoiceDonationDialog extends DialogAbstract { case 3: changeDialog(CheckoutEsunPayDialog.class); break; - case 4: + case 10: changeDialog(CheckoutNfcPhoneDialog.class); break; case 9: From ed9b05636caa117dc4a30e52f522485dc8f4cf19 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 23 Jun 2026 13:20:25 +0800 Subject: [PATCH 2/4] =?UTF-8?q?[FIX]=20=E4=BF=AE=E5=BE=A9=E6=8E=83?= =?UTF-8?q?=E7=A2=BC=E6=A7=8DHID=E5=B9=BD=E9=9D=88=E8=BC=B8=E5=85=A5?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB=E5=95=86=E5=93=81=E8=A6=96=E7=AA=97=E3=80=81?= =?UTF-8?q?=E9=81=A0=E7=AB=AF=E9=87=8D=E5=95=9F=E8=A2=AB=E8=AA=A4=E5=88=A4?= =?UTF-8?q?=E5=BF=99=E7=A2=8C=E6=8B=92=E7=B5=95=E8=88=87=E5=9F=B7=E8=A1=8C?= =?UTF-8?q?=E7=B7=92=E6=B4=A9=E6=BC=8F=E5=8D=A1=E9=A0=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現場 M000008 經 log 確認三症狀同一根因:掃碼槍(HID鍵盤)按鍵穿透 FLAG_NOT_FOCUSABLE 對話框打進 FontendActivity,觸發 onUserInteraction 不斷把閒置時間歸零,導致待機看門狗 與重啟條件永遠達不到,且 [Dialog Open] 讓遠端 reboot 被當忙碌拒絕(指令484實證)。 1. FontendActivity.onUserInteraction 不再更新閒置時間/不呼叫 super,改在 dispatchTouchEvent 更新;閒置一律以「實體觸控」為準,HID 按鍵不再算互動。 2. DialogAbstract 新增 isBusyDialog/isInputNeededDialog 分類、hasBusyDialogShowing/ hasInputNeededDialogShowing 查詢、dismissAll 統一關窗,並加 dispatchTouchEvent 讓對話框上的真人觸控也算互動(避免操作中被誤關)。 3. DialogProductDetail 標記 isBusyDialog=false:商品詳情不再卡成 [Dialog Open] 忙碌, 閒置可自動返回首頁、亦不再擋掉遠端重啟。 4. FontendPickupCodeDialog / MemberCardPickupDialog 標記 isInputNeededDialog=true, 保留取貨碼/會員卡的掃碼槍/讀卡機輸入。 5. MyApp.getBusyReason 改用 hasBusyDialogShowing(),只計入付款/出貨等真正忙碌對話框。 6. FontendActivity.dispatchKeyEvent 同時攔截 ENTER 與 NUMPAD_ENTER,僅放行正在顯示 需要 HID 輸入的對話框,阻止幽靈輸入反覆開關商品視窗。 7. FontendActivity.idleRunnable 返回首頁前呼叫 dismissAll 清除殘窗。 8. FontendActivity.onDestroy 各釋放步驟全包 try-catch 並把 super.onDestroy 移到最後, 確保 stopHeaderReportScheduler 一定執行,修正 ClockTickScheduler 執行緒洩漏卡頓。 9. MqttService 遠端 reboot 新增 force(command:"reboot_force" 或 payload {"force":true}), 現場卡死時可無視忙碌強制重啟,作為最後逃生口。 10. 升版號 verPatch 至 16(正式版 21_02_16_R / versionCode 210216)以觸發 OTA。 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle | 2 +- .../unibuy/smartdevice/DialogAbstract.java | 71 +++++++++++++++++ .../java/com/unibuy/smartdevice/MyApp.java | 9 ++- .../smartdevice/service/MqttService.java | 23 +++++- .../smartdevice/ui/FontendActivity.java | 77 +++++++++++++------ .../ui/dialog/DialogProductDetail.java | 9 +++ .../ui/dialog/FontendPickupCodeDialog.java | 6 ++ .../ui/dialog/MemberCardPickupDialog.java | 6 ++ 8 files changed, 174 insertions(+), 29 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 102f5c7..89c8067 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { // 避免不同尺寸機台間版本號產生衝突。 // ========================================================================= def verMinor = 2 -def verPatch = 13 +def verPatch = 16 diff --git a/app/src/main/java/com/unibuy/smartdevice/DialogAbstract.java b/app/src/main/java/com/unibuy/smartdevice/DialogAbstract.java index e270acb..771c569 100644 --- a/app/src/main/java/com/unibuy/smartdevice/DialogAbstract.java +++ b/app/src/main/java/com/unibuy/smartdevice/DialogAbstract.java @@ -222,4 +222,75 @@ public abstract class DialogAbstract extends AlertDialog { // MyApp.getInstance().setShowDialog(false); // 移至 dismiss 統一處理 getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.SHOW_SALE_OVERLAY.getOption(), "show sale overlay"); } + + @Override + public boolean dispatchTouchEvent(android.view.MotionEvent ev) { + // 對話框有自己的 Window,使用者觸控對話框時不會經過宿主 Activity 的 dispatchTouchEvent。 + // 這裡補上「真人觸控即更新全域閒置時間」,避免使用者正在操作對話框時被待機看門狗誤關。 + // 只認觸控、不認 HID 按鍵(掃碼槍/讀卡機),故幽靈按鍵仍無法把對話框卡死。 + MyApp.getInstance().updateLastInteraction(); + return super.dispatchTouchEvent(ev); + } + + /** + * 此對話框是否需要接收實體鍵盤/掃碼槍/讀卡機等 HID 輸入。 + * 預設 false:購物畫面的 Enter/Numpad_Enter 會被攔截以防 HID 誤觸。 + * 取貨碼、會員卡等真正需要 HID 輸入的對話框應覆寫為 true。 + */ + public boolean isInputNeededDialog() { + return false; + } + + /** + * 此對話框是否屬於「忙碌」狀態(交易/金流/出貨進行中),不可被閒置返回首頁或遠端重啟打斷。 + * 預設 true(付款、出貨等沿用既有保護);純瀏覽性質的對話框(如商品詳情)應覆寫為 false, + * 以免把機台一直卡在 [Dialog Open] 忙碌而擋掉待機與遠端重啟。 + */ + public boolean isBusyDialog() { + return true; + } + + /** 目前是否有任何「需要 HID 輸入」的對話框正在顯示 */ + public static boolean hasInputNeededDialogShowing() { + synchronized (dialogInstances) { + for (DialogAbstract dialog : dialogInstances.values()) { + if (dialog != null && dialog.isShowing() && dialog.isInputNeededDialog()) { + return true; + } + } + } + return false; + } + + /** 目前是否有任何「忙碌(交易/金流/出貨)」對話框正在顯示 */ + public static boolean hasBusyDialogShowing() { + synchronized (dialogInstances) { + for (DialogAbstract dialog : dialogInstances.values()) { + if (dialog != null && dialog.isShowing() && dialog.isBusyDialog()) { + return true; + } + } + } + return false; + } + + /** + * 閒置返回首頁時統一安全關閉所有顯示中的對話框,避免殘窗(WindowLeaked)並重置全域旗標。 + * 以 dialogInstances 的複本迭代,避免 dismiss() 內移除元素造成 ConcurrentModification。 + */ + public static void dismissAll() { + synchronized (dialogInstances) { + for (DialogAbstract dialog : new java.util.ArrayList<>(dialogInstances.values())) { + if (dialog != null && dialog.isShowing()) { + try { + dialog.dismiss(); + } catch (Exception e) { + // 忽略單一對話框關閉異常,確保其餘對話框仍會被關閉 + } + } + } + dialogInstances.clear(); + } + MyApp.getInstance().setShowDialog(false); + } } diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index 219392b..9e0b16b 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -1202,13 +1202,16 @@ public class MyApp extends Application { */ public String getBusyReason() { StringBuilder reason = new StringBuilder(); - if (isShowDialog()) { - reason.append("[Dialog Open] "); + // 只計入「忙碌型」對話框(付款/出貨等 isBusyDialog()==true)。 + // 純瀏覽的商品詳情(isBusyDialog()==false)不算忙碌,否則它一旦被 HID 誤觸卡住, + // 就會讓機台長期 [Dialog Open] → 待機看門狗略過、遠端重啟被拒(Machine busy)。 + if (DialogAbstract.hasBusyDialogShowing()) { + reason.append("[Busy Dialog Open] "); } // 注意:購物車非空「不可」計入忙碌。否則待機/重啟會與清車死結—— // resetIdleTimer 忙碌即不待機並重新計時,但清空購物車的程式就寫在待機那一支, // 購物車有殘留 → 永遠忙碌 → 永遠不待機 → 永遠清不掉。真正「進行中」狀態 - // 已由 isShowDialog(付款/出貨對話框)與 isNfcPaymentActive(感應支付)涵蓋。 + // 已由忙碌型對話框(付款/出貨)與 isNfcPaymentActive(感應支付)涵蓋。 if (isNfcPaymentActive()) { reason.append("[NFC Payment Active] "); } 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 717258e..3240014 100644 --- a/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java +++ b/app/src/main/java/com/unibuy/smartdevice/service/MqttService.java @@ -94,18 +94,35 @@ public class MqttService extends Service { logs.info("Received MQTT command: " + cmd + " (ID: " + cmdId + ")"); try { - if ("reboot".equals(cmd)) { + if ("reboot".equals(cmd) || "reboot_force".equals(cmd)) { logs.info("Executing remote reboot command..."); long idle = MyApp.getInstance().getIdleSeconds(); String busyReason = MyApp.getInstance().getBusyReason(); - // 🚩 若機台忙碌(對話框開著、購物車有東西、NFC 交易中)則拒絕 - if (!busyReason.isEmpty()) { + // 🚩 強制重啟旗標:指令為 reboot_force,或 reboot 的 payload 帶 {"force":true}。 + // 用途:現場卡死(例如對話框被 HID 誤觸卡住 → [Busy Dialog Open])時, + // 操作員仍能「無視忙碌」遠端強制重啟,作為最後逃生口。 + boolean force = "reboot_force".equals(cmd); + try { + com.google.gson.JsonElement p = command.getPayload(); + if (!force && p != null && p.isJsonObject()) { + com.google.gson.JsonObject o = p.getAsJsonObject(); + force = o.has("force") && o.get("force").getAsBoolean(); + } + } catch (Exception ignore) { + // payload 非預期格式時,視為非強制 + } + + // 🚩 若機台忙碌(付款/出貨對話框、NFC 交易中)則拒絕;force 時略過此保護。 + if (!busyReason.isEmpty() && !force) { logs.info("Remote reboot rejected: " + busyReason); mqttManager.publishCommandAck(cmdId, "failed", "Machine busy: " + busyReason); return; } + if (!busyReason.isEmpty()) { + logs.info("Remote reboot FORCED despite busy: " + busyReason); + } // 🚩 若不忙碌但閒置時間不足,僅記錄警告但允許重啟(遠端控制優先) if (idle < 60) { 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 25154ec..4bec341 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java @@ -471,11 +471,13 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl */ @Override public void onUserInteraction() { - super.onUserInteraction(); - MyApp.getInstance().updateLastInteraction(); - // onUserInteraction 連對話框/彈窗的觸控都會觸發(dispatchTouchEvent 不會), - // 故一併重設 5 分鐘待機計時器,避免操作對話框時被誤判閒置踢回待機。 - resetIdleTimer(); + // 🚩 刻意「不」在此更新閒置時間/重設待機計時器。 + // Android 的 Activity.dispatchKeyEvent() 內部會呼叫 onUserInteraction(),因此 HID 掃碼槍/ + // 讀卡機的週期性按鍵也會跑到這裡。先前這會把 5 分鐘待機看門狗無限往後頂,造成: + // (1) 商品視窗被誤觸開啟後永遠不會自動關閉; + // (2) RestartAPP2「閒置>=90秒」與遠端重啟的條件永遠達不到。 + // 因此閒置一律改以「實體觸控」為準(見本類別 dispatchTouchEvent 與 DialogAbstract.dispatchTouchEvent)。 + // 故意不呼叫 super.onUserInteraction(),以阻斷基底類別 AppCompatActivityAbstract 用按鍵更新 lastInteractionTime。 } public void ReSetAPP() { @@ -585,6 +587,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl } getLogs().info("⚠️ 無操作 5 分鐘,自動返回初始畫面(清空購物車)"); + com.unibuy.smartdevice.DialogAbstract.dismissAll(); // 🚩 一併關閉所有殘留對話框(如被 HID 誤觸卡住的商品詳情),避免殘窗與 [Dialog Open] 卡死 MyApp.getInstance().getBuyList().clear(); // 🚩 逾時返回首頁時,務必清空購物車,避免機台進入永久忙碌狀態 com.unibuy.v2.UnibuyHelper.INSTANCE.getCartViewModel().clearCart(); // 一併清 v2 多品項購物車,避免取消結帳後走人留下殘單 Intent intent = new Intent(FontendActivity.this, AppEntry.initialActivity()); @@ -598,6 +601,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl @Override public boolean dispatchTouchEvent(MotionEvent ev) { + MyApp.getInstance().updateLastInteraction(); // 🚩 只有實體觸控才算「真人互動」,HID 按鍵不算 resetIdleTimer(); // 每次觸控都重設 idle 計時器 return super.dispatchTouchEvent(ev); } @@ -1436,11 +1440,16 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl @Override public boolean dispatchKeyEvent(KeyEvent event) { - // [修正] 防止讀卡機 (HID) 在購物畫面送出 Enter 導致誤點擊商品 - if (!MyApp.getInstance().isShowDialog()) { - if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { + // [修正] 防止讀卡機/掃碼槍 (HID) 在購物畫面送出 Enter/Numpad_Enter 導致誤點擊商品或誤關對話框。 + // 規則:只有「正在顯示需要 HID 輸入的對話框」(取貨碼/會員卡) 時才放行 Enter;其餘情況一律攔截, + // 包含「商品詳情等非輸入型對話框開著」時,避免 HID 幽靈輸入反覆開關商品視窗。 + boolean allowHidEnter = MyApp.getInstance().isShowDialog() + && com.unibuy.smartdevice.DialogAbstract.hasInputNeededDialogShowing(); + if (!allowHidEnter) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) { if (event.getAction() == KeyEvent.ACTION_UP) { - getLogs().info("攔截到購物畫面非預期的 Enter 鍵,已忽略防止誤觸"); + getLogs().info("攔截到購物畫面非預期的 Enter 鍵(" + keyCode + "),已忽略防止誤觸"); } return true; // 消耗事件 } @@ -1521,23 +1530,47 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl @Override protected void onDestroy() { - super.onDestroy(); - stopOnSwitchFragment(); - - if (networkCallback != null) { - connectivityManager.unregisterNetworkCallback(networkCallback); + // 🚩 每個釋放步驟都獨立 try-catch,並把 super.onDestroy() 移到最後: + // 先前 super 在第一行且各步驟無保護,只要任一步拋例外,後面的 stopHeaderReportScheduler() 就會被跳過, + // 導致 ClockTickScheduler/ReportShotTimeByScheduler 執行緒沒被關閉而持續殘留,App 越用越慢、需硬重啟。 + try { + stopOnSwitchFragment(); + } catch (Exception e) { + getLogs().warning(e); } - if (vmcFragment != null) { - ((FragmentAbstract) vmcFragment).stopOnSwitchFragment(); - } - - if (MyApp.getInstance().getDevSet().isElectic()) { - if (electricFragment != null) { - ((FragmentAbstract) electricFragment).stopOnSwitchFragment(); + try { + if (networkCallback != null) { + connectivityManager.unregisterNetworkCallback(networkCallback); } + } catch (Exception e) { + getLogs().warning(e); } - stopHeaderReportScheduler(); + try { + if (vmcFragment != null) { + ((FragmentAbstract) vmcFragment).stopOnSwitchFragment(); + } + } catch (Exception e) { + getLogs().warning(e); + } + + try { + if (MyApp.getInstance().getDevSet().isElectic()) { + if (electricFragment != null) { + ((FragmentAbstract) electricFragment).stopOnSwitchFragment(); + } + } + } catch (Exception e) { + getLogs().warning(e); + } + + try { + stopHeaderReportScheduler(); + } catch (Exception e) { + getLogs().warning(e); + } + + super.onDestroy(); } } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java index 93f690e..318b9d0 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java @@ -217,4 +217,13 @@ public class DialogProductDetail extends DialogAbstract { } new BuyDialog(hostContext, getSrcHandlerMain()).show(); } + + /** + * 商品詳情僅為瀏覽性質,非交易/出貨忙碌狀態。 + * 標記為非忙碌,閒置逾時時可自動關閉返回首頁,且不會擋掉遠端重啟([Dialog Open])。 + */ + @Override + public boolean isBusyDialog() { + return false; + } } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/FontendPickupCodeDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/FontendPickupCodeDialog.java index c26631e..3db4304 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/FontendPickupCodeDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/FontendPickupCodeDialog.java @@ -77,6 +77,12 @@ public class FontendPickupCodeDialog extends DialogAbstract { return getClass(); } + /** 取貨碼需以掃碼槍/鍵盤輸入,購物畫面的 Enter 攔截需放行給本對話框。 */ + @Override + public boolean isInputNeededDialog() { + return true; + } + @Override protected HandlerMain setHandlerMain() { return new ToastHandlerMain(getCtx(), getLogs()) { 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 51cf466..a12f849 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 @@ -61,6 +61,12 @@ public class MemberCardPickupDialog extends DialogAbstract { return getClass(); } + /** 會員卡需以讀卡機(HID)刷卡輸入,購物畫面的 Enter 攔截需放行給本對話框。 */ + @Override + public boolean isInputNeededDialog() { + return true; + } + @Override protected HandlerMain setHandlerMain() { return new ToastHandlerMain(getContext(), getLogs()) { From 8db3fab1c078abc026f6f49de24f2fcfcfa06950 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 23 Jun 2026 17:08:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?[FEAT]=20=E6=95=88=E6=9C=9F=E9=81=8E?= =?UTF-8?q?=E6=9C=9F=E5=95=86=E5=93=81=E8=87=AA=E5=8B=95=E4=B8=8B=E6=9E=B6?= =?UTF-8?q?=EF=BC=88=E6=9A=AB=E4=B8=8D=E8=B2=A9=E5=94=AE=EF=BC=89+=20?= =?UTF-8?q?=E8=A8=8A=E8=99=9F=E6=AC=8A=E9=99=90=20root=20=E8=87=AA?= =?UTF-8?q?=E6=8E=88=E6=AC=8A=E8=A6=8F=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SlotStructure 新增 isExpired()/getExpiryDate()/clearExpiry():以 ISO 日期字串字典序比對效期(避開時區/解析誤差),當天可賣、隔天起才鎖;解析失敗 fail-open 不擋售,避免時鐘或資料異常誤鎖全機。 2. VMC 銷售清單與貨道清單:過期商品顯示「暫不販售」遮罩並禁止加入購物車/購買(recycler_vmc_slot_list.xml + RecyclerVmcBuyListAdpter/RecyclerVmcSlotListAdpter)。 3. 出貨前即時效期複查:DialogProductDetail/DispatchDialog/MemberCardPickupDialog/CartViewModel 於出貨當下再驗一次,過期則取消出貨並返回銷售平台。 4. 貨道庫存歸 0(賣完)時自動清除效期與批號,避免舊批效期殘留導致補新貨被誤鎖。 5. 新增多語系字串 product_expired「暫不販售」(11 語系)。 6. docs:network-signal-reporting-spec 補上「機台 rooted → App 啟動時以 su 自我授權 ACCESS_FINE_LOCATION」的定案做法(失敗靜默、不影響其他功能)。 7. 版號遞增 verPatch 17→18(versionName 21_02_18_D / versionCode 210218)供 OTA 辨識。 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle | 2 +- .../smartdevice/structure/SlotStructure.java | 50 +++++++++++++++++++ .../ui/devs/vmc/RecyclerVmcBuyListAdpter.java | 20 +++++++- .../devs/vmc/RecyclerVmcSlotListAdpter.java | 15 ++++++ .../ui/dialog/DialogProductDetail.java | 6 +++ .../smartdevice/ui/dialog/DispatchDialog.java | 5 ++ .../ui/dialog/MemberCardPickupDialog.java | 6 +++ .../java/com/unibuy/v2/vm/CartViewModel.kt | 5 ++ .../res/layout/recycler_vmc_slot_list.xml | 17 ++++++- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-th/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + docs/network-signal-reporting-spec.md | 14 ++++-- 21 files changed, 143 insertions(+), 8 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 89c8067..524a51c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { // 避免不同尺寸機台間版本號產生衝突。 // ========================================================================= def verMinor = 2 -def verPatch = 16 +def verPatch = 18 diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/SlotStructure.java b/app/src/main/java/com/unibuy/smartdevice/structure/SlotStructure.java index 7e42b13..fd342d5 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/SlotStructure.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/SlotStructure.java @@ -100,7 +100,57 @@ public class SlotStructure { return otherData; } + /** + * 效期是否已過期(後台庫存同步透過 otherData.expiry_date 下發,格式 "yyyy-MM-dd")。 + * 規則:當天仍可販售,今天 > 效期日(即隔天起)才視為過期。 + * 採 ISO 日期字串字典序比對(等同時間序),避開時區/解析誤差。 + * 失敗時 fail-open 回傳 false(不擋售),避免時鐘或資料異常誤鎖全機。 + */ + public boolean isExpired() { + if (otherData == null || !otherData.has("expiry_date")) + return false; + try { + String expiry = otherData.getString("expiry_date"); + if (expiry == null || expiry.isEmpty() || "null".equalsIgnoreCase(expiry)) + return false; + // 僅取日期部分 yyyy-MM-dd(即使後台帶了時間也安全) + String expiryDate = expiry.length() >= 10 ? expiry.substring(0, 10) : expiry; + String today = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US) + .format(new java.util.Date()); + return today.compareTo(expiryDate) > 0; + } catch (Exception e) { + return false; + } + } + public void setOtherData(JSONObject otherData) { this.otherData = otherData; } + + /** + * 取得效期字串(yyyy-MM-dd),無設定時回傳空字串。供貨道管理 UI 顯示用。 + */ + public String getExpiryDate() { + if (otherData == null || !otherData.has("expiry_date")) + return ""; + try { + String expiry = otherData.getString("expiry_date"); + if (expiry == null || "null".equalsIgnoreCase(expiry)) + return ""; + return expiry.length() >= 10 ? expiry.substring(0, 10) : expiry; + } catch (Exception e) { + return ""; + } + } + + /** + * 清除效期與批號。通常在貨道庫存歸 0(賣完)時呼叫,避免舊批貨效期殘留, + * 導致下次本機補新貨時被舊效期誤鎖成「暫不販售」。 + */ + public void clearExpiry() { + if (otherData == null) + return; + otherData.remove("expiry_date"); + otherData.remove("batch_no"); + } } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcBuyListAdpter.java b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcBuyListAdpter.java index ff04fdd..caf3059 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcBuyListAdpter.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/RecyclerVmcBuyListAdpter.java @@ -89,6 +89,7 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter 0 ? machinePrice : sellingPrice; holder.binding.textPrice.setVisibility(View.VISIBLE); @@ -105,12 +106,18 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter 0 && !isLock) { + if (count > 0 && !isLock && !isExpired) { holder.binding.imageProductPicture.setFocusable(false); holder.binding.imageProductPicture.setFocusableInTouchMode(false); holder.binding.imageProductPicture.setOnClickListener(new imageProductPictureOnClickListener(position)); @@ -123,7 +130,7 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter 0 && !isLock) { + if (cartOn && count > 0 && !isLock && !isExpired) { holder.binding.buttonQuickAdd.setVisibility(View.VISIBLE); holder.binding.buttonQuickAdd.setOnClickListener(v -> { if (MyApp.getInstance().isMachineBusy()) return; @@ -177,6 +184,15 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 311c1d5..6d3deaa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -288,6 +288,7 @@ Restzeit 00H:00M Ablauf 0000/00/00 00H:00M Produkt ausverkauft + Nicht verfügbar Schachtnummer Menge diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 71d3b3e..c315f9e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -288,6 +288,7 @@ Time left 00H:00M Expires at 0000/00/00 00H:00M Product sold out + Unavailable Lane Number Amount diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a1662be..a55f5bd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -288,6 +288,7 @@ Tiempo restante 00H:00M Vence el 0000/00/00 00H:00M Producto agotado + No disponible Número de canal Cantidad diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 78f7156..fc7b90c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -288,6 +288,7 @@ Temps restant 00H:00M Expire le 0000/00/00 00H:00M Produit épuisé + Indisponible Numéro de couloir Quantité diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index bfcbb6b..27fd28a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -288,6 +288,7 @@ Sisa waktu 00J:00M Berakhir pada 0000/00/00 00J:00M Produk habis terjual + Tidak dijual Nomor Jalur Jumlah diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0c8ab67..78ef9bb 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -288,6 +288,7 @@ 残り時間 00時:00分 0000/00/00 00時:00分 期限 商品は売り切れました + 販売停止 車線番号 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5abe360..a1aec7a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -288,6 +288,7 @@ 남은 시간 00H:00M 만료 0000/00/00 00H:00M 상품 매진 + 판매 중지 레인 번호 수량 diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 8d179bd..dfeaa8d 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -288,6 +288,7 @@ เหลือเวลา 00H:00M หมดอายุ 0000/00/00 00H:00M สินค้าจำหน่ายหมดแล้ว + งดจำหน่ายชั่วคราว หมายเลขช่องสินค้า จำนวน diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ade3da6..7494685 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -288,6 +288,7 @@ Còn lại 00H:00M Hết hạn lúc 0000/00/00 00H:00M Sản phẩm đã bán hết + Tạm ngừng bán Số ngăn hàng Số lượng diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2601363..f5790eb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -288,6 +288,7 @@ 剩余 00时:00分 0000/00/00 00时:00分 到期 商品销售完毕 + 暂不贩售 货道编号 数量 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cb561d..d7617ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,7 @@ 剩餘 00時:00分 0000/00/00 00時:00分 到期 商品銷售完畢 + 暫不販售 貨道編號 數量 diff --git a/docs/network-signal-reporting-spec.md b/docs/network-signal-reporting-spec.md index 26129fd..6759eb9 100644 --- a/docs/network-signal-reporting-spec.md +++ b/docs/network-signal-reporting-spec.md @@ -72,9 +72,15 @@ int rsrq = (SDK_INT >= 29) ? lte.getRsrq() : Integer.MAX_VALUE; - 本專案 minSdk 21 = ART,採惰性驗證;對「**既有類別的新方法**」(如 `CellSignalStrengthLte.getRsrp()`,類別自 API 17 即存在)只要包 `Build.VERSION.SDK_INT` inline guard 就安全,**不會** class-load `VerifyError`/啟動崩潰。這也是本檔既有慣例([FontendActivity.java:709](file:///home/mama/projects/TaiwanStar_general_size_s_Chengwai/app/src/main/java/com/unibuy/smartdevice/ui/FontendActivity.java#L709) 的 `getSignalStrength()` API29、:1474 的 TIRAMISU API33 皆 inline 出貨)。Wrapper/Helper class 只是可選風格,非正確性必需。 - **唯一例外**:引用 API 29+/30+ 才有的**新類別「型別」**(`CellInfoNr`/`CellSignalStrengthNr`)時,少數客製 ROM 仍可能 `NoClassDefFoundError`。故 **5G 分支整段務必在 `SDK_INT` guard 內**(見下點)。 3. **5G NSA fallback(agy 查核)**:現場 5G 多為 **NSA(非獨立組網)**,控制面錨定在 LTE,`getAllCellInfo()` 主小區仍是 `CellInfoLte`。所以**不能只靠 `CellInfoNr`**;邏輯應為「先取 `CellInfoNr`(SA 5G,整段包在 API 31+ guard 內),取不到則 fallback `CellInfoLte`」,否則 NSA 機台拿不到訊號。 -4. **權限:真機需驗證 + 穩健性缺口(agy 詰問後降級,非確證致命 bug)**:`checkPermissionByAccessFineLocation()` 在 Android M+ 直接 `return true`,**沒做執行期動態申請**,且全專案無 `requestPermissions`、非平台簽章。但 `getMobileSignalStrength()` 有 `try-catch(SecurityException)` → 即使未授權也只是回 -1,**不會崩**。 - - 這些是**專用 kiosk 機**,`ACCESS_FINE_LOCATION` 多半在佈署時用 `adb shell pm grant` 或 provisioning 預授;且 cellular 分支只在 active transport==CELLULAR 時才跑(現場多走 WiFi/有線),故是否真壞**未經現場驗證**。 - - **行動**:實作訊號上報前,先在目標機台確認權限已授(`adb shell dumpsys package | grep ACCESS_FINE_LOCATION`);若未授,選擇「佈署腳本 `pm grant`」或「App 內補執行期申請」其一即可。不列為阻斷性 bug,但**不確認就上線,行動網路訊號會全空**。 +4. **權限:App 用 root 自我授權(定案,適配「打包 APK 直裝機台、無 adb、無人值守」佈署模式)**: + - 現況:`checkPermissionByAccessFineLocation()` 在 Android M+ 直接 `return true`,**沒做執行期動態申請**;App 非平台簽章/系統 App(debug key、無 `sharedUserId`)。但 `getMobileSignalStrength()` 有 `try-catch(SecurityException)` → 未授權也只回 -1,**不會崩**。 + - **關鍵:機台是 rooted**,App 已用 `su` 做乙太網路/OTA/RS232(見 [EthernetTetherHelper.java:10](file:///home/mama/projects/TaiwanStar_general_size_s_Chengwai/app/src/main/java/com/unibuy/smartdevice/worker/EthernetTetherHelper.java#L10)、[OtaManager.java:529](file:///home/mama/projects/TaiwanStar_general_size_s_Chengwai/app/src/main/java/com/unibuy/smartdevice/tools/OtaManager.java#L529))。因此**不需 adb、不需人工、不需跳視窗**。 + - **做法**:App 啟動時(讀訊號前)以 root 自我授權,照抄 `EthernetTetherHelper` 的 `exec("su")` 模式: + ``` + su -c "pm grant android.permission.ACCESS_FINE_LOCATION" + ``` + 建議封裝成 `LocationPermissionHelper.ensureGrantedViaRoot()`,失敗靜默(訊號降為 -1,不影響其他功能)。授權具持久性,授一次即可,每次開機保險再跑無妨。 + - 備註:cellular 分支只在 active transport==CELLULAR 才跑(現場多走 WiFi/有線),故行動訊號上報主要在用 SIM 的機台才有資料。 ### 1.3 commit 注意 本分支 `feat/general-cart-and-payment` 正在改 `MqttManager`;`HeartbeatPayload` 擴充與訊號讀取請**獨立小 commit**,避免與購物車/支付改動纏在一起。 @@ -162,7 +168,7 @@ $table->timestamp('last_network_at')->nullable(); ## 5. 實作清單(給後台同事 / 後續排程) **App 端** -- [ ] **先驗權限**:在目標機台確認 `ACCESS_FINE_LOCATION` 已授(`adb shell dumpsys package | grep FINE_LOCATION`);未授則 `pm grant` 或補執行期申請(§1.2-4,非阻斷但不確認會全空) +- [ ] **權限自我授權**:新增 `LocationPermissionHelper.ensureGrantedViaRoot()`,啟動時以 `su -c "pm grant ACCESS_FINE_LOCATION"` 靜默授權(照抄 `EthernetTetherHelper`,失敗不崩)(§1.2-4) - [ ] 訊號讀取:補 RSSI/SINR/RSRQ + inline `Build.VERSION.SDK_INT` 防護(符合本庫慣例,毋須 Wrapper class)+ `CellInfoNr`→`CellInfoLte` fallback、5G 分支整段在 API31+ guard 內(§1.2-2/3) - [ ] `HeartbeatPayload` 加巢狀 `network`(含對應 class,測 Gson JSON 結構;null 不序列化已驗證安全) - [ ] 獨立 commit(勿混入購物車/支付) From 90f86cf3c9128dcc26ee61df97ea0b095e6bb0fb Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 24 Jun 2026 10:22:03 +0800 Subject: [PATCH 4/4] =?UTF-8?q?[FIX]=20=E9=8E=96=E8=B2=A8=E9=81=93/?= =?UTF-8?q?=E6=95=88=E6=9C=9F=E5=8F=AA=E9=8E=96=E8=A9=B2=E8=B2=A8=E9=81=93?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=8E=96=E6=95=B4=E5=95=86=E5=93=81=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E5=95=86=E5=93=81=E5=A4=9A=E8=B2=A8=E9=81=93=E6=94=B9?= =?UTF-8?q?=E9=80=90=E9=81=93=E5=87=BA=E8=B2=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. VmcFragment.updateFlowerBuyList():銷售卡合併同商品多貨道改為「任一可賣貨道(count>0且未鎖未過期)即可售」,庫存只計可賣貨道加總;全部不可賣才依 鎖定>過期>售完 顯示對應遮罩。修正鎖一個貨道或單一貨道過期就把整個商品擋掉的問題。 2. MyApp 新增 allocateBuyStructures():把購買數量依可賣貨道 greedy 拆成多筆 count=1 的 BuyStructure,規避 VMC 出貨狀態機不支援單筆 quantity>1(出完第一個會卡 40 秒看門狗);庫存不足才溢位回代表道,交既有「出貨失敗→後台依 dispense 退款」保護網。 3. CartViewModel.convertToBuyStructure():多品項購物車改用 allocateBuyStructures 拆道,避免同商品買多個時重複出同一貨道而卡頓/出貨失敗。 4. DialogProductDetail 直購:改走 allocateBuyStructures 拆道。 5. PickupSheetSaleFlow:中國醫領藥 buildMaterialSlotMap 與 isAvailableCmuhStock 排除鎖定/過期藥道,避免藥單驗證通過卻在出貨時失敗。 6. build.gradle:verPatch 18→19(versionName 依硬體規格產生)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle | 2 +- .../java/com/unibuy/smartdevice/MyApp.java | 72 +++++++++++++++++++ .../smartdevice/ui/PickupSheetSaleFlow.java | 8 ++- .../smartdevice/ui/devs/vmc/VmcFragment.java | 58 +++++++++------ .../ui/dialog/DialogProductDetail.java | 6 +- .../java/com/unibuy/v2/vm/CartViewModel.kt | 7 +- 6 files changed, 122 insertions(+), 31 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 524a51c..26e2eda 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { // 避免不同尺寸機台間版本號產生衝突。 // ========================================================================= def verMinor = 2 -def verPatch = 18 +def verPatch = 19 diff --git a/app/src/main/java/com/unibuy/smartdevice/MyApp.java b/app/src/main/java/com/unibuy/smartdevice/MyApp.java index 9e0b16b..d9436ae 100644 --- a/app/src/main/java/com/unibuy/smartdevice/MyApp.java +++ b/app/src/main/java/com/unibuy/smartdevice/MyApp.java @@ -885,6 +885,78 @@ public class MyApp extends Application { throw new LogsEmptyException(logs, "Slot data not found"); } + /** + * 把「某商品要買 quantity 個」依可賣貨道(count>0 && !lock && !expired)拆成多筆 count=1 的 BuyStructure。 + * + * 用途:VMC 出貨狀態機不支援單筆 DispatchStructure quantity>1(出完第 1 個後 count allocateBuyStructures(SlotStructure representative, int quantity) { + List result = new ArrayList<>(); + if (representative == null || quantity <= 0) { + return result; + } + ProductStructure repProduct = representative.getProduct(); + if (repProduct == null || repProduct.getProductID() == null) { + // 退化情形:仍維持「一筆一個」不變式,避免漏單或重新塞回 quantity>1 + for (int i = 0; i < quantity; i++) { + result.add(new BuyStructure(representative.getField(), representative.getSlot(), repProduct, 1)); + } + return result; + } + + int field = representative.getField(); + String pid = repProduct.getProductID(); + int preferredSlot = representative.getSlot(); + + // 收集同商品可賣貨道:代表道優先,其餘依貨道號遞增 + SlotStructure preferred = null; + List others = new ArrayList<>(); + List slotList = getSlotList(field); + if (slotList != null) { + for (SlotStructure s : slotList) { + if (s == null || s.getProduct() == null) continue; + if (!pid.equals(s.getProduct().getProductID())) continue; + if (s.getCount() <= 0 || s.isLock() || s.isExpired()) continue; + if (s.getSlot() == preferredSlot) { + preferred = s; + } else { + others.add(s); + } + } + } + others.sort((a, b) -> Integer.compare(a.getSlot(), b.getSlot())); + + List candidates = new ArrayList<>(); + if (preferred != null) candidates.add(preferred); + candidates.addAll(others); + + // greedy 配貨:每個候選道按其真實庫存填,逐一拆成 count=1 + int need = quantity; + for (SlotStructure s : candidates) { + if (need <= 0) break; + int take = Math.min(need, s.getCount()); + for (int i = 0; i < take; i++) { + result.add(new BuyStructure(s.getField(), s.getSlot(), s.getProduct(), 1)); + } + need -= take; + } + + // 可賣庫存不足 quantity 的溢位:掛回代表貨道,交既有失敗→退款保護網 + for (int i = 0; i < need; i++) { + result.add(new BuyStructure(field, preferredSlot, repProduct, 1)); + } + return result; + } + public List getMarketingPlanList() { return marketingPlanList; } diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/PickupSheetSaleFlow.java b/app/src/main/java/com/unibuy/smartdevice/ui/PickupSheetSaleFlow.java index b1fdd15..0a02874 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/PickupSheetSaleFlow.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/PickupSheetSaleFlow.java @@ -386,13 +386,19 @@ public class PickupSheetSaleFlow extends SaleFlowHandler { MyApp.getInstance().getLogs().info("Skip invalid VMC slot for CMUH pickup: " + slotData.getSlot()); continue; } + // 鎖貨道/效期過期的藥道不得列入可用清單:否則庫存驗證與分配會把它算進去,出貨時才失敗。 + if (slotData.isLock() || slotData.isExpired()) { + MyApp.getInstance().getLogs().info("Skip locked/expired VMC slot for CMUH pickup: slot=" + slotData.getSlot()); + continue; + } materialSlotMap.computeIfAbsent(materialCode.trim(), k -> new ArrayList<>()).add(slotData); } return materialSlotMap; } private boolean isAvailableCmuhStock(SlotStructure slotData) { - return slotData != null && slotData.getCount() > 0 && slotData.getCount() <= 20; + return slotData != null && slotData.getCount() > 0 && slotData.getCount() <= 20 + && !slotData.isLock() && !slotData.isExpired(); } private int safeParseInt(String value, int defaultValue) { diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcFragment.java b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcFragment.java index 16ae582..93a6c05 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcFragment.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/devs/vmc/VmcFragment.java @@ -267,36 +267,48 @@ public class VmcFragment extends FragmentAbstract { new TypeToken>() {}.getType() ); if (rawSlots == null || rawSlots.isEmpty()) return; - // 2. 处理数据(确保不修改任何对象) - Map uniqueProductMap = new LinkedHashMap<>(); + // 2. 按 ProductID 分組(同一商品的多個貨道收成一組) + LinkedHashMap> groups = new LinkedHashMap<>(); for (SlotStructure slot : rawSlots) { ProductStructure product = slot.getProduct(); if (isInvalidProduct(product)) continue; + groups.computeIfAbsent(product.getProductID(), k -> new ArrayList<>()).add(slot); + } - String productId = product.getProductID(); - SlotStructure existingSlot = uniqueProductMap.get(productId); - - if (existingSlot == null) { - // 直接使用拷贝后的 slot(避免修改) - uniqueProductMap.put(productId, slot); - } else { - // 數量累加 - int newCount = existingSlot.getCount() + slot.getCount(); - - // 判斷代表 slot:如果 existingSlot 數量是 0,而這個 slot 有貨,就換掉 - if (existingSlot.getCount() == 0 && slot.getCount() > 0) { - SlotStructure newSlot = CloneUtils.deepClone(slot, SlotStructure.class); - newSlot.setCount(newCount); - uniqueProductMap.put(productId, newSlot); - } else { - // 否則保留原本的貨道,只更新數量 - SlotStructure newSlot = CloneUtils.deepClone(existingSlot, SlotStructure.class); - newSlot.setCount(newCount); - uniqueProductMap.put(productId, newSlot); + // 3. 每個商品產生一張卡:可售判定看「同商品任一可賣貨道(count>0 && !lock && !expired)」, + // 只有全部貨道都不可賣時才鎖整張卡。避免「鎖一個貨道/單一貨道過期」就把整個商品擋掉。 + Map uniqueProductMap = new LinkedHashMap<>(); + for (Map.Entry> entry : groups.entrySet()) { + List group = entry.getValue(); + int sellableCount = 0; // 可賣貨道庫存加總(顯示與可加購上限,排除鎖定/過期/0 庫存,防買超) + SlotStructure sellableRep = null; // 第一個可賣貨道 + SlotStructure lockedRep = null; // 第一個被鎖貨道(全不可賣時用來顯示「暫停販售」) + SlotStructure expiredRep = null; // 第一個過期貨道(全不可賣時用來顯示「暫不販售」) + for (SlotStructure s : group) { + if (s.getCount() > 0 && !s.isLock() && !s.isExpired()) { + sellableCount += s.getCount(); + if (sellableRep == null) sellableRep = s; + } else if (s.isLock()) { + if (lockedRep == null) lockedRep = s; + } else if (s.isExpired()) { + if (expiredRep == null) expiredRep = s; } } + + SlotStructure card; + if (sellableRep != null) { + // 有可賣貨道:用可賣道當代表(不鎖、不過期),庫存只算可賣道加總 + card = CloneUtils.deepClone(sellableRep, SlotStructure.class); + card.setCount(sellableCount); + } else { + // 全部不可賣才鎖整卡:依 鎖定 > 過期 > 售完 擇一代表道,讓遮罩顯示正確原因 + SlotStructure rep = lockedRep != null ? lockedRep + : (expiredRep != null ? expiredRep : group.get(0)); + card = CloneUtils.deepClone(rep, SlotStructure.class); + } + uniqueProductMap.put(entry.getKey(), card); } - // 3. 更新UI + // 4. 更新UI if (!uniqueProductMap.isEmpty()) { slotList.clear(); slotList.addAll(uniqueProductMap.values()); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java index e00263f..176f43f 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/DialogProductDetail.java @@ -8,7 +8,6 @@ import com.unibuy.smartdevice.MyApp; import com.unibuy.smartdevice.R; import com.unibuy.smartdevice.databinding.DialogProductDetailBinding; import com.unibuy.smartdevice.exception.LogsEmptyException; -import com.unibuy.smartdevice.structure.BuyStructure; import com.unibuy.smartdevice.structure.SlotStructure; import com.unibuy.smartdevice.tools.HandlerMain; import com.unibuy.smartdevice.tools.ToastHandlerMain; @@ -181,8 +180,9 @@ public class DialogProductDetail extends DialogAbstract { cancel(); } else { MyApp.getInstance().getBuyList().clear(); - MyApp.getInstance().getBuyList().add( - new BuyStructure(slot.getField(), slot.getSlot(), slot.getProduct(), quantity)); + // 依可賣貨道把數量拆成多筆 count=1,規避 VMC 出貨狀態機不支援單筆 quantity>1(會卡 40 秒看門狗)。 + MyApp.getInstance().getBuyList().addAll( + MyApp.getInstance().allocateBuyStructures(slot, quantity)); cancel(); requestBuy(); } diff --git a/app/src/main/java/com/unibuy/v2/vm/CartViewModel.kt b/app/src/main/java/com/unibuy/v2/vm/CartViewModel.kt index 272242f..1e567bd 100644 --- a/app/src/main/java/com/unibuy/v2/vm/CartViewModel.kt +++ b/app/src/main/java/com/unibuy/v2/vm/CartViewModel.kt @@ -30,11 +30,12 @@ class CartViewModel : ViewModel() { buyList.clear() // 1. 免費商品(來店禮;General 不做時恆空) buyList += freeGoods - // 2. 購物車商品 + // 2. 購物車商品:依可賣貨道把數量拆成多筆 count=1,規避 VMC 出貨狀態機不支援單筆 quantity>1 + // (出完第 1 個後不會自動觸發下一次出貨,會卡 40 秒看門狗)。拆道邏輯集中在 MyApp.allocateBuyStructures。 buyList += UnibuyHelper.getCartViewModel() .getCartItemsSnapshot() - .map { cartItem -> - BuyStructure(cartItem.field, cartItem.slot, cartItem.product, cartItem.cartSelectedCount) + .flatMap { cartItem -> + allocateBuyStructures(cartItem, cartItem.cartSelectedCount) } } }