[MERGE] feat/general-cart-and-payment:整合領藥單功能與遠端鎖貨道/效期/交易上報更新
This commit is contained in:
commit
78f497d3f8
@ -10,7 +10,7 @@ plugins {
|
||||
// 避免不同尺寸機台間版本號產生衝突。
|
||||
// =========================================================================
|
||||
def verMinor = 2
|
||||
def verPatch = 13
|
||||
def verPatch = 19
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -896,6 +896,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<quantity,
|
||||
* 狀態機不會主動再觸發下一次出貨,只能等 40 秒看門狗逾時被動補出,形同卡死)。因此多數量必須在
|
||||
* 進狀態機之前就拆成「一筆一個」,與中國醫領藥既有做法一致。
|
||||
*
|
||||
* greedy 分配:優先用代表貨道(representative),再依貨道號遞增溢位到下一個可賣道,盡量集中以維持
|
||||
* 貨道補貨整齊;單道不足才換道。可賣總庫存若仍不足 quantity(例如加入購物車後庫存又被消耗),
|
||||
* 剩餘數量掛回代表貨道,交由既有「出貨失敗→後台依 dispense 退款」保護網處理,不在此處吞掉訂單金額。
|
||||
*
|
||||
* 注意:representative 可能是銷售畫面合併後的「代表卡」(getCount() 為加總值),所以這裡一律以
|
||||
* getSlotList(field) 的真實逐道庫存為準,不信任 representative.getCount()。
|
||||
*/
|
||||
public List<BuyStructure> allocateBuyStructures(SlotStructure representative, int quantity) {
|
||||
List<BuyStructure> 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<SlotStructure> others = new ArrayList<>();
|
||||
List<SlotStructure> 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<SlotStructure> 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<MarketingPlanStructure> getMarketingPlanList() {
|
||||
return marketingPlanList;
|
||||
}
|
||||
@ -1213,13 +1285,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] ");
|
||||
}
|
||||
|
||||
@ -119,6 +119,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();
|
||||
|
||||
@ -158,6 +165,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());
|
||||
}
|
||||
@ -370,14 +381,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))
|
||||
@ -385,11 +408,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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -199,7 +199,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:掃碼開啟且玉山子項開啟。 */
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:點數全額折抵。
|
||||
*/
|
||||
|
||||
@ -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<String> 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<String, ?> 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<String, ?> 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<String, ?> 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -89,6 +89,7 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter<RecyclerVmcBu
|
||||
int slotUpperLimit = slot.getProduct().getSlotUpperLimit();
|
||||
int count = slot.getCount();
|
||||
boolean isLock = slot.isLock();
|
||||
boolean isExpired = slot.isExpired();
|
||||
|
||||
int price = machinePrice > 0 ? machinePrice : sellingPrice;
|
||||
holder.binding.textPrice.setVisibility(View.VISIBLE);
|
||||
@ -105,12 +106,18 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter<RecyclerVmcBu
|
||||
holder.binding.textMask.setText("售完");
|
||||
}
|
||||
|
||||
// 效期過期:顯示「暫不販售」遮罩,並一律禁止購買(蓋過售完顯示,主要情境是有貨但過效期)
|
||||
if (isExpired) {
|
||||
holder.binding.mask.setVisibility(View.VISIBLE);
|
||||
holder.binding.textMask.setText(context.getString(R.string.product_expired));
|
||||
}
|
||||
|
||||
if (isLock) {
|
||||
holder.binding.mask.setVisibility(View.VISIBLE);
|
||||
holder.binding.textMask.setText("暫停販售");
|
||||
}
|
||||
|
||||
if (count > 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<RecyclerVmcBu
|
||||
|
||||
// 商品卡右下角「+」快速加購:僅購物車功能開啟時顯示;無購物車時隱藏(只能點商品開詳情頁)
|
||||
boolean cartOn = MyApp.getInstance().getDevSet().isShoppingCar();
|
||||
if (cartOn && count > 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<RecyclerVmcBu
|
||||
VmcFragment.Option.SHIPPED_PRODUCT.getOption(), "refresh");
|
||||
return;
|
||||
}
|
||||
// ✅ 即時效期檢查:過期商品一律暫不販售
|
||||
if (liveSlot != null && liveSlot.isExpired()) {
|
||||
logs.info("即時效期檢查:商品已過效期 slot=" + slotProduct.getSlot());
|
||||
android.widget.Toast.makeText(context, context.getString(R.string.product_expired),
|
||||
android.widget.Toast.LENGTH_SHORT).show();
|
||||
fragment.getSrcHandlerMain().start(getClass().getSimpleName(),
|
||||
VmcFragment.Option.SHIPPED_PRODUCT.getOption(), "refresh");
|
||||
return;
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
// 查詢失敗時不阻擋,讓原流程繼續
|
||||
}
|
||||
|
||||
@ -76,6 +76,21 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
// numberPassword 預設會遮罩輸入,關閉遮罩以顯示明碼數字
|
||||
holder.binding.editCount.setTransformationMethod(null);
|
||||
|
||||
// 效期顯示:有設定效期才顯示;已過期以紅字標示,方便維護人員辨識
|
||||
String expiry = slot.getExpiryDate();
|
||||
if (expiry.isEmpty()) {
|
||||
holder.binding.textExpiry.setVisibility(View.GONE);
|
||||
} else {
|
||||
holder.binding.textExpiry.setVisibility(View.VISIBLE);
|
||||
if (slot.isExpired()) {
|
||||
holder.binding.textExpiry.setText("效期 " + expiry + "(已過期)");
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.warning));
|
||||
} else {
|
||||
holder.binding.textExpiry.setText("效期 " + expiry);
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.black));
|
||||
}
|
||||
}
|
||||
|
||||
if (slot.isLock()) {
|
||||
holder.binding.textLock.setChecked(false);
|
||||
holder.binding.textLockStatus.setText("停售中");
|
||||
|
||||
@ -267,36 +267,48 @@ public class VmcFragment extends FragmentAbstract {
|
||||
new TypeToken<List<SlotStructure>>() {}.getType()
|
||||
);
|
||||
if (rawSlots == null || rawSlots.isEmpty()) return;
|
||||
// 2. 处理数据(确保不修改任何对象)
|
||||
Map<String, SlotStructure> uniqueProductMap = new LinkedHashMap<>();
|
||||
// 2. 按 ProductID 分組(同一商品的多個貨道收成一組)
|
||||
LinkedHashMap<String, List<SlotStructure>> 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<String, SlotStructure> uniqueProductMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<SlotStructure>> entry : groups.entrySet()) {
|
||||
List<SlotStructure> 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());
|
||||
|
||||
@ -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 官方支付(獨立)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
@ -153,6 +152,12 @@ public class DialogProductDetail extends DialogAbstract {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
// 效期過期商品一律暫不販售
|
||||
if (slot.isExpired()) {
|
||||
ToastUtils.showShort(R.string.product_expired);
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
addSelectedQuantityToCart();
|
||||
ToastUtils.showShort(R.string.cart_added);
|
||||
cancel();
|
||||
@ -175,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();
|
||||
}
|
||||
@ -217,4 +223,13 @@ public class DialogProductDetail extends DialogAbstract {
|
||||
}
|
||||
new BuyDialog(hostContext, getSrcHandlerMain()).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品詳情僅為瀏覽性質,非交易/出貨忙碌狀態。
|
||||
* 標記為非忙碌,閒置逾時時可自動關閉返回首頁,且不會擋掉遠端重啟([Dialog Open])。
|
||||
*/
|
||||
@Override
|
||||
public boolean isBusyDialog() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -936,6 +936,11 @@ public class DispatchDialog extends DialogAbstract {
|
||||
int slotCount = MyApp.getInstance().getSlotData(field, slot).getCount();
|
||||
remaining = slotCount - increase; // 計算剩餘數量
|
||||
MyApp.getInstance().getSlotData(field, slot).setCount(remaining);
|
||||
// 賣完(庫存歸 0)即清除效期/批號,避免舊批貨效期殘留,導致下次補新貨被舊效期誤鎖
|
||||
if (remaining <= 0) {
|
||||
MyApp.getInstance().getSlotData(field, slot).clearExpiry();
|
||||
getLogs().info("貨道 " + slot + " 庫存歸 0,已清除效期資料。");
|
||||
}
|
||||
MyApp.getInstance().getReportDispatchInfoData().setRemaining(remaining);
|
||||
} catch (LogsEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()) {
|
||||
@ -199,6 +205,12 @@ public class MemberCardPickupDialog extends DialogAbstract {
|
||||
dialogCancel(); // 內部已清空 BuyList + dismiss
|
||||
return;
|
||||
}
|
||||
if (liveSlot != null && liveSlot.isExpired()) {
|
||||
getLogs().info("商品已過效期 slot=" + buy.getSlot() + ",取消出貨,返回銷售平台。");
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.TOAST.getOption(), "商品已過效期,暫不販售");
|
||||
dialogCancel(); // 內部已清空 BuyList + dismiss
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,6 +49,11 @@ class CartViewModel : ViewModel() {
|
||||
|
||||
// 添加商品到購物車(以 field + slot + productID 唯一識別)
|
||||
fun addToCart(slot: SlotStructure): CartOperationResult {
|
||||
// 效期過期商品一律暫不販售,禁止加入購物車
|
||||
if (slot.isExpired) {
|
||||
ToastUtils.showLong(R.string.product_expired)
|
||||
return createResult(null)
|
||||
}
|
||||
val currentItems = _cartItems.value?.toMutableList() ?: mutableListOf()
|
||||
val item = currentItems.find {
|
||||
it.field == slot.field &&
|
||||
|
||||
@ -102,6 +102,21 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/imageProduct"
|
||||
android:layout_marginTop="0dp" />
|
||||
|
||||
<!-- 效期顯示(無設定時隱藏;已過期由 Adapter 改紅字提示) -->
|
||||
<TextView
|
||||
android:id="@+id/textExpiry"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/black"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:text=""
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/textProductName"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<!-- 第四列:庫存數量控制列 - Minus -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/buttonCountMinus"
|
||||
@ -113,7 +128,7 @@
|
||||
android:textSize="30sp"
|
||||
app:backgroundTint="@color/background"
|
||||
android:textColor="@color/primary"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textProductName"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textExpiry"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/textCount" />
|
||||
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Restzeit 00H:00M</string>
|
||||
<string name="expires_at">Ablauf 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">Produkt ausverkauft</string>
|
||||
<string name="product_expired">Nicht verfügbar</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Schachtnummer</string>
|
||||
<string name="amount">Menge</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Time left 00H:00M</string>
|
||||
<string name="expires_at">Expires at 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">Product sold out</string>
|
||||
<string name="product_expired">Unavailable</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Lane Number</string>
|
||||
<string name="amount">Amount</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Tiempo restante 00H:00M</string>
|
||||
<string name="expires_at">Vence el 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">Producto agotado</string>
|
||||
<string name="product_expired">No disponible</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Número de canal</string>
|
||||
<string name="amount">Cantidad</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Temps restant 00H:00M</string>
|
||||
<string name="expires_at">Expire le 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">Produit épuisé</string>
|
||||
<string name="product_expired">Indisponible</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Numéro de couloir</string>
|
||||
<string name="amount">Quantité</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Sisa waktu 00J:00M</string>
|
||||
<string name="expires_at">Berakhir pada 0000/00/00 00J:00M</string>
|
||||
<string name="product_sold_out">Produk habis terjual</string>
|
||||
<string name="product_expired">Tidak dijual</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Nomor Jalur</string>
|
||||
<string name="amount">Jumlah</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">残り時間 00時:00分</string>
|
||||
<string name="expires_at">0000/00/00 00時:00分 期限</string>
|
||||
<string name="product_sold_out">商品は売り切れました</string>
|
||||
<string name="product_expired">販売停止</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">車線番号</string>
|
||||
<string name="amount">量</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">남은 시간 00H:00M</string>
|
||||
<string name="expires_at">만료 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">상품 매진</string>
|
||||
<string name="product_expired">판매 중지</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">레인 번호</string>
|
||||
<string name="amount">수량</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">เหลือเวลา 00H:00M</string>
|
||||
<string name="expires_at">หมดอายุ 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">สินค้าจำหน่ายหมดแล้ว</string>
|
||||
<string name="product_expired">งดจำหน่ายชั่วคราว</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">หมายเลขช่องสินค้า</string>
|
||||
<string name="amount">จำนวน</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">Còn lại 00H:00M</string>
|
||||
<string name="expires_at">Hết hạn lúc 0000/00/00 00H:00M</string>
|
||||
<string name="product_sold_out">Sản phẩm đã bán hết</string>
|
||||
<string name="product_expired">Tạm ngừng bán</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">Số ngăn hàng</string>
|
||||
<string name="amount">Số lượng</string>
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
<string name="time_left">剩余 00时:00分</string>
|
||||
<string name="expires_at">0000/00/00 00时:00分 到期</string>
|
||||
<string name="product_sold_out">商品销售完毕</string>
|
||||
<string name="product_expired">暂不贩售</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">货道编号</string>
|
||||
<string name="amount">数量</string>
|
||||
|
||||
@ -199,6 +199,7 @@
|
||||
<string name="time_left">剩餘 00時:00分</string>
|
||||
<string name="expires_at">0000/00/00 00時:00分 到期</string>
|
||||
<string name="product_sold_out">商品銷售完畢</string>
|
||||
<string name="product_expired">暫不販售</string>
|
||||
<!-- recycler_electric_slot_list.xml -->
|
||||
<string name="lane_number">貨道編號</string>
|
||||
<string name="amount">數量</string>
|
||||
|
||||
@ -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 <pkg> | 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 <context.getPackageName()> 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 <pkg> | grep FINE_LOCATION`);未授則 `pm grant` 或補執行期申請(§1.2-4,非阻斷但不確認會全空)
|
||||
- [ ] **權限自我授權**:新增 `LocationPermissionHelper.ensureGrantedViaRoot()`,啟動時以 `su -c "pm grant <pkg> 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(勿混入購物車/支付)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user