[FEAT] 效期過期商品自動下架(暫不販售)+ 訊號權限 root 自授權規格

1. SlotStructure 新增 isExpired()/getExpiryDate()/clearExpiry():以 ISO 日期字串字典序比對效期(避開時區/解析誤差),當天可賣、隔天起才鎖;解析失敗 fail-open 不擋售,避免時鐘或資料異常誤鎖全機。
2. VMC 銷售清單與貨道清單:過期商品顯示「暫不販售」遮罩並禁止加入購物車/購買(recycler_vmc_slot_list.xml + RecyclerVmcBuyListAdpter/RecyclerVmcSlotListAdpter)。
3. 出貨前即時效期複查:DialogProductDetail/DispatchDialog/MemberCardPickupDialog/CartViewModel 於出貨當下再驗一次,過期則取消出貨並返回銷售平台。
4. 貨道庫存歸 0(賣完)時自動清除效期與批號,避免舊批效期殘留導致補新貨被誤鎖。
5. 新增多語系字串 product_expired「暫不販售」(11 語系)。
6. docs:network-signal-reporting-spec 補上「機台 rooted → App 啟動時以 su 自我授權 ACCESS_FINE_LOCATION」的定案做法(失敗靜默、不影響其他功能)。
7. 版號遞增 verPatch 17→18(versionName 21_02_18_D / versionCode 210218)供 OTA 辨識。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-23 17:08:29 +08:00
parent ed9b05636c
commit 8db3fab1c0
21 changed files with 143 additions and 8 deletions

View File

@ -10,7 +10,7 @@ plugins {
//
// =========================================================================
def verMinor = 2
def verPatch = 16
def verPatch = 18

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

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

@ -153,6 +153,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();

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

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

@ -48,6 +48,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勿混入購物車支付