[MERGE] feat/general-cart-and-payment:整合領藥單功能與遠端鎖貨道/效期/交易上報更新

This commit is contained in:
terrylee 2026-06-24 11:29:06 +08:00
commit 78f497d3f8
38 changed files with 703 additions and 87 deletions

View File

@ -10,7 +10,7 @@ plugins {
//
// =========================================================================
def verMinor = 2
def verPatch = 13
def verPatch = 19

View File

@ -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);
}
}

View File

@ -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] ");
}

View File

@ -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 送出成敗待 callbackfalse=未連線保留於發件箱待下次補送
*/
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 {

View File

@ -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) {

View File

@ -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掃碼開啟且玉山子項開啟。 */

View File

@ -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");
}
}

View File

@ -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:點數全額折抵
*/

View File

@ -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:" + entryIdO(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);
}
}

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -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) {
// 查詢失敗時不阻擋讓原流程繼續
}

View File

@ -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("停售中");

View File

@ -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());

View File

@ -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 官方支付獨立

View File

@ -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:

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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()) {

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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) {}
}

View File

@ -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 &&

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 fallbackagy 查核)**:現場 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 非平台簽章/系統 Appdebug 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勿混入購物車支付