Compare commits
135 Commits
main
...
feat/app-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f569645a2 | ||
|
|
9e78119c0a | ||
|
|
5c4ac16d48 | ||
|
|
fa5525ad87 | ||
|
|
d224354c23 | ||
|
|
bfaad4ea3b | ||
|
|
2fa44dfb01 | ||
|
|
0e1a6cbdf5 | ||
|
|
e1a2a45810 | ||
|
|
569cfeac09 | ||
|
|
b3258c2b0d | ||
|
|
0c303ffccb | ||
|
|
1d11367066 | ||
|
|
954d4a3716 | ||
|
|
436e9b8328 | ||
|
|
9112f46f4e | ||
|
|
f46dbe0b51 | ||
|
|
8dc0433e66 | ||
|
|
c1da062d1d | ||
|
|
54b44f5cf4 | ||
|
|
e6ebac8dc1 | ||
|
|
e4b095b971 | ||
|
|
e4e62de5b8 | ||
|
|
377da0a2da | ||
|
|
a24bdfdc0e | ||
| 78f497d3f8 | |||
| efd6d62449 | |||
|
|
90f86cf3c9 | ||
|
|
8db3fab1c0 | ||
|
|
ed9b05636c | ||
|
|
ebfa25da72 | ||
|
|
8d764ba388 | ||
|
|
c7da4d1a12 | ||
|
|
6dfadbe246 | ||
|
|
272164e0ed | ||
|
|
16420ae5a3 | ||
|
|
258aa2f87b | ||
|
|
84c7ac8933 | ||
|
|
f905380597 | ||
|
|
42184c7077 | ||
|
|
4afc3a59d4 | ||
|
|
309518f3ca | ||
|
|
c986b69e16 | ||
|
|
9a4f68455d | ||
|
|
dd87f1ca6e | ||
|
|
eae0342ab8 | ||
|
|
c7bc554eca | ||
|
|
647e72aa9b | ||
|
|
89c7d04f7a | ||
|
|
f700881788 | ||
|
|
7ec2d3916a | ||
|
|
6caaf0890e | ||
|
|
9bfb429392 | ||
|
|
317dec1084 | ||
|
|
be3c25bbf5 | ||
|
|
ff65559b4c | ||
|
|
092c0e9a16 | ||
|
|
7875db4c7d | ||
|
|
b32f76c753 | ||
|
|
3fd34becb6 | ||
|
|
a99f7b0572 | ||
|
|
7cb9039a50 | ||
|
|
d7bef7b827 | ||
|
|
1c9022cc5e | ||
|
|
df96f33d5f | ||
|
|
bd23c31ba1 | ||
|
|
0900dc20f9 | ||
|
|
35af9fec8f | ||
|
|
eb0b9c8271 | ||
|
|
8e02e7adeb | ||
|
|
a80bb4dd85 | ||
|
|
92553d96d2 | ||
|
|
c7134fe712 | ||
|
|
a10a51ec10 | ||
|
|
0a19e965a8 | ||
|
|
e331e399bc | ||
|
|
5b8a0d7cf6 | ||
|
|
2e70c16aa3 | ||
|
|
318bfde1fb | ||
|
|
fdf02189fb | ||
|
|
73dee29485 | ||
|
|
a33c0c97bd | ||
|
|
39245a0608 | ||
|
|
f6028ffb80 | ||
|
|
79e1f69325 | ||
|
|
8d2adee5ab | ||
|
|
b0f7381832 | ||
|
|
5af9a94a0c | ||
|
|
ee732e03a3 | ||
|
|
fc21186a53 | ||
|
|
2683b850a2 | ||
|
|
e8b3185b8a | ||
|
|
018da036b1 | ||
|
|
3b1764b75c | ||
|
|
b56c539549 | ||
|
|
42947dce0c | ||
|
|
1d4c4ab071 | ||
|
|
998b3ae89f | ||
|
|
33d79076a6 | ||
|
|
73e2b55c53 | ||
|
|
696e6cb952 | ||
|
|
d4fe3c6cf4 | ||
|
|
c24f409674 | ||
|
|
3d798eb94d | ||
|
|
ab3b53da99 | ||
|
|
2c3255cb64 | ||
|
|
275d6d1fa9 | ||
|
|
588da4a2eb | ||
|
|
53ea3dafa9 | ||
|
|
1720d2007e | ||
|
|
f10f6f5879 | ||
|
|
e8856d0a8b | ||
|
|
2315a32f56 | ||
|
|
402490ee58 | ||
|
|
cfc802dbb5 | ||
|
|
404ba69fba | ||
|
|
8e4a0993c7 | ||
|
|
4d7717edc3 | ||
|
|
5f480d6cb6 | ||
|
|
b1732987ce | ||
|
|
d917f6445c | ||
|
|
d1bf7d18ce | ||
|
|
f2ed393686 | ||
|
|
907d5095d5 | ||
|
|
28a3d623f0 | ||
|
|
2e71cc29b8 | ||
|
|
8f6b5217d6 | ||
|
|
5d9c5ba5cf | ||
|
|
a933bb22f6 | ||
|
|
aed8d9bc1f | ||
|
|
54e0c9ffa5 | ||
|
|
02ae6623d9 | ||
|
|
12e61d2a10 | ||
|
|
c7d75a8522 | ||
|
|
c7b8372dfc |
@ -28,8 +28,8 @@ trigger: always_on
|
||||
依據公司標準規範,實體機台發佈之 APK 與版本名稱需嚴格符合以下欄位對照:
|
||||
|
||||
```
|
||||
來源 _ 安卓號 _ 主板號 _ 機器系列 _ 專案名稱 _ 螢幕大小 _ 次版號 _ 修補版號 _ 環境
|
||||
S _ 12 _ XY _ U _ Chengwai _ 10 _ 08 _ 8 _ R
|
||||
來源 _ 安卓號 _ 主板號 _ 機器系列 _ 螢幕大小 _ 次版號 _ 修補版號 _ 環境
|
||||
S _ 12 _ XY _ U _ 10 _ 08 _ 8 _ R
|
||||
```
|
||||
|
||||
### 欄位解析:
|
||||
@ -37,7 +37,6 @@ S _ 12 _ XY _ U _ Chengwai _ 10 _ 08 _ 8 _ R
|
||||
* **`12` (安卓號)**:Android 系統主版本號。
|
||||
* **`XY` (主板號)**:主板型號規格。
|
||||
* **`U` (機器系列 / 機型規格)**:例如代表 U 系列機型。
|
||||
* **`Chengwai` (專案名稱)**:客戶/銷售場景專案代碼(如 `General`, `Chengwai`, `Cmuh`)。
|
||||
* **`10` (螢幕大小)**:對應 Gradle 中的 `verMajor`。
|
||||
* **`08` (次版號)**:對應 Gradle 中的 `verMinor`(格式化為雙位數)。
|
||||
* **`8` (修補版號)**:對應 Gradle 中的 `verPatch`。
|
||||
@ -55,38 +54,32 @@ S _ 12 _ XY _ U _ Chengwai _ 10 _ 08 _ 8 _ R
|
||||
|
||||
---
|
||||
|
||||
## 4. 雙維度產品風味 (Product Flavors) 與 APK 命名規範
|
||||
## 4. 單維度硬體規格 (Hardware Flavor) 與 APK 命名規範
|
||||
|
||||
本專案採用**雙維度 Flavor** 架構,以完美分離「客戶銷售場景」與「底層硬體規格」,方便後續快速擴充。
|
||||
本專案原本採用雙維度,但目前客戶專案(購買流程)已收斂為執行期 `shopping_mode`(雲端 B014 下發)動態決定,因此不再使用 flavor 分家。
|
||||
目前僅保留**單一維度 `hardware`**,專門處理底層硬體規格差異。
|
||||
|
||||
### 4.1 雙維度定義 (flavorDimensions)
|
||||
本專案於 `app/build.gradle` 中定義了兩個 Flavor 維度:
|
||||
### 4.1 硬體維度定義 (flavorDimensions)
|
||||
本專案於 `app/build.gradle` 中定義了唯一的 Flavor 維度 `hardware`:
|
||||
|
||||
1. **`project` 維度 (客戶/銷售場景區隔)**:
|
||||
* **`General`** (通用主幹):預設顯示付款方式選單(彈出 `BuyDialog`)。未來新客戶最常用此通用主幹。
|
||||
* **`Chengwai`** (晟崴客製):直接跳出員工卡刷卡畫面(彈出 `MemberCardPickupDialog`),不顯示付款選單。
|
||||
* **`Cmuh`** (中國醫客製):支援 QR Code 掃碼、處方籤醫令離線 AES 解密與解析領藥流程。
|
||||
2. **`hardware` 維度 (硬體與系統規格區隔)**:
|
||||
* **`S12XYU`**:代表 `S` (來源) + `12` (安卓號) + `XY` (主板號) + `U` (機器系列/機型規格)。
|
||||
* **`S9XYU`**:代表中國醫現場使用的 Android 9 / 21.5 吋硬體規格;除非明確指定中國醫 10 吋或 `S12XYU`,中國醫 (`Cmuh`) 相關修正與驗證預設必須使用此硬體 flavor。
|
||||
* **`S12XYU`** (10 吋預設硬體):代表 `S` (來源) + `12` (安卓號) + `XY` (主板號) + `U` (機器系列/機型規格)。
|
||||
* **`S9XYU`** (21.5 吋硬體):代表中國醫現場使用的 Android 9 / 21.5 吋硬體規格。
|
||||
|
||||
### 4.2 Flavor 驗證與編譯指令選擇
|
||||
|
||||
針對特定客戶或現場問題修改後,必須編譯對應的 Gradle variant,不可只用通用 `assembleDebug` 或錯誤硬體 flavor 代替。
|
||||
修改後,必須編譯對應硬體規格的 Gradle variant,不可只用通用 `assembleDebug`:
|
||||
|
||||
* **中國醫現場版 (`Cmuh`) 驗證指令**:`./gradlew :app:assembleCmuhS9XYUDebug --no-daemon`
|
||||
* **晟崴版 (`Chengwai`) 預設驗證指令**:`./gradlew :app:assembleChengwaiS12XYUDebug --no-daemon`
|
||||
* **通用版 (`General`) 預設驗證指令**:`./gradlew :app:assembleGeneralS12XYUDebug --no-daemon`
|
||||
* **10 吋一般機台版 (含晟崴、通用等)**:`./gradlew :app:assembleS12XYUDebug --no-daemon`
|
||||
* **21.5 吋中國醫等大螢幕機台版**:`./gradlew :app:assembleS9XYUDebug --no-daemon`
|
||||
|
||||
### 4.3 實體機台 APK 命名鐵律
|
||||
為了符合實體機台 OTA 平台發佈規範,打包輸出的 APK 檔名必須嚴格連動雙維度,並遵循以下格式:
|
||||
`app-[格式化硬體規格]_[專案名稱]-[版本號]-[BuildType].apk`
|
||||
為了符合實體機台 OTA 平台發佈規範,打包輸出的 APK 檔名必須遵循以下格式:
|
||||
`app-[格式化硬體規格]-[版本號]-[BuildType].apk`
|
||||
|
||||
* **格式化規則**:`S12XYU` 會在 Gradle 打包時被自動展開格式化為 `S_12_XY_U`。
|
||||
* **輸出檔名範例**:
|
||||
* `app-S_12_XY_U_General-10_08_8_R-release.apk`
|
||||
* `app-S_12_XY_U_Chengwai-10_08_8_R-release.apk`
|
||||
* `app-S_12_XY_U_Cmuh-10_08_8_R-release.apk`
|
||||
* `app-S_12_XY_U-10_08_8_R-release.apk`
|
||||
* `app-S_9_XY_U-21_08_8_R-release.apk`
|
||||
* **注意**:此檔名轉換規則已完全由 `app/build.gradle` 頂部 Gradle 變數與 `applicationVariants.all` 自動連動生成,**嚴禁手動修改輸出檔名**。
|
||||
|
||||
---
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
---
|
||||
name: android-deploy
|
||||
description: 用於 TaiwanStar 專案的 Android App 編譯、安裝與自動啟動。當使用者要求「安裝到手機」、「部署 APK」或「更新 App」時,應使用此技能。
|
||||
---
|
||||
|
||||
# Android 部署技能 (TaiwanStar 專案專用)
|
||||
|
||||
此技能定義了在 WSL 環境下編譯 Android App 並透過 Windows ADB 安裝至實體手機的標準流程。
|
||||
|
||||
## 前置要求
|
||||
|
||||
1. **Java 版本**:必須使用 OpenJDK 17。
|
||||
2. **ADB 工具**:使用 Windows 端安裝的 ADB (路徑:`/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe`)。
|
||||
3. **手機連線**:手機必須開啟 USB 調試,且已連線至電腦。
|
||||
|
||||
## 標準部署流程
|
||||
|
||||
執行以下複合指令以完成「編譯 + 安裝 + 自動啟動」:
|
||||
|
||||
```bash
|
||||
# 1. 設定環境變數
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
|
||||
# 2. 執行編譯與安裝 (包含自動開啟 HomeActivity)
|
||||
./gradlew :app:assembleS_12_XY_U_Standard_Debug && \
|
||||
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe install -r -d -t app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk && \
|
||||
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe shell am start -n com.unibuy.smartdevice/com.unibuy.smartdevice.HomeActivity
|
||||
```
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **安裝失敗**:若出現 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,代表簽名或版本衝突,請先執行 `adb uninstall com.unibuy.smartdevice`。
|
||||
- **ADB 找不到裝置**:請確認手機 USB 模式是否為「檔案傳輸」或「PTP」,並檢查 Windows 端的 ADB server 是否正常執行。
|
||||
- **Mock Mode**:此部署版本已包含 Mock Mode,可在無串口設備的手機上正常運作不崩潰。
|
||||
@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TaiwanStar Android 部署腳本
|
||||
# 自動執行:編譯 -> 安裝 -> 啟動
|
||||
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ADB_PATH="/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe"
|
||||
APK_PATH="app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08_2_R-debug.apk"
|
||||
PACKAGE_NAME="com.unibuy.smartdevice"
|
||||
LAUNCHER_ACTIVITY=".HomeActivity"
|
||||
|
||||
echo "🚀 開始編譯 APK..."
|
||||
./gradlew :app:assembleS_12_XY_U_Standard_Debug
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 編譯成功,正在安裝至手機..."
|
||||
$ADB_PATH install -r -d -t $APK_PATH
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "🎉 安裝成功,正在啟動 App..."
|
||||
$ADB_PATH shell am start -n $PACKAGE_NAME/$LAUNCHER_ACTIVITY
|
||||
else
|
||||
echo "❌ 安裝失敗,請檢查手機連線狀態。"
|
||||
fi
|
||||
else
|
||||
echo "❌ 編譯失敗,請檢查程式碼錯誤。"
|
||||
fi
|
||||
@ -4,13 +4,19 @@ description: 自動編譯 Android APK、輸出至專屬目錄並安裝至實體
|
||||
|
||||
# 編譯與安裝 APK 工作流 (compiler-apk)
|
||||
|
||||
這個 Workflow 主要是協助您一鍵完成 Android 專案的編譯、匯出與安裝。當您呼叫 `/compiler-apk` 時,AI 會自動為您執行以下步驟:
|
||||
這個 Workflow 協助您一鍵完成 Android 專案的編譯、匯出與安裝。當您呼叫 `/compiler-apk` 時,AI 會自動執行下列步驟。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **客戶專案(購買流程)已收斂為執行期 `shopping_mode`(雲端 B014 下發),不再用 flavor 分家。**
|
||||
> 打包對象只剩 **硬體規格(hardware flavor)**:`S12XYU`(10 吋)/ `S9XYU`(21.5 吋 / Android 9)。
|
||||
> 同一支 APK 在現場會依雲端 `shopping_mode` 自動切換為 basic(購物車)/ employee_card(員工卡)/ pickup_sheet(取貨單)。
|
||||
|
||||
> [!TIP]
|
||||
> **支援智慧硬體判斷與參數化覆寫:**
|
||||
> * 預設(不帶參數):編譯 **`Chengwai` 晟崴版 (S12XYU)**。
|
||||
> * **智慧判斷**:當編譯 **`中國醫 (Cmuh)`** 時,會自動識別並預設編譯 **`S9XYU` (Android 9 / 21.5 吋)**;編譯 **`General`** 與 **`Chengwai`** 時則預設編譯 **`S12XYU` (10 吋)**。
|
||||
> * **參數手動覆寫**:支援手動指定硬體尺寸。例如 `/compiler-apk Cmuh S12` 可強行編譯中國醫的 10 吋版本;`/compiler-apk General S9` 可強行編譯通用版的大螢幕 21.5 吋版本。
|
||||
> **參數 = 硬體規格:**
|
||||
> * 預設(不帶參數):編譯 **`S12XYU`(10 吋)**。
|
||||
> * `/compiler-apk S9`(或 `S9XYU` / `21.5` / `大螢幕`)→ 編譯 **`S9XYU`(21.5 吋 / Android 9)**。
|
||||
> * `/compiler-apk S12`(或 `S12XYU` / `10`)→ 編譯 **`S12XYU`(10 吋)**。
|
||||
> * 相容舊用語:`中國醫`→S9XYU、`晟崴`/`通用`→S12XYU(僅作硬體對應,流程仍由雲端決定)。
|
||||
|
||||
## 步驟零:自動檢查變更與升級版號
|
||||
在開始編譯之前,AI 助手會自動檢查是否有程式碼變更,以落實實體機台的 OTA 升級規範:
|
||||
@ -19,60 +25,49 @@ description: 自動編譯 Android APK、輸出至專屬目錄並安裝至實體
|
||||
3. 遞增完成後,向使用者呈報最新的版本號資訊,接著繼續執行步驟一。
|
||||
|
||||
## 步驟一:設定環境並編譯 Debug APK
|
||||
使用 Java 17 與 Gradle 編譯對應 Flavor 與硬體規格的 Debug 程式碼。
|
||||
使用 Java 17 與 Gradle 編譯對應硬體規格的 Debug 程式碼。
|
||||
|
||||
```bash
|
||||
// turbo
|
||||
# 終止殘留的 Gradle Daemon 程序,避免多個 Daemon 互搶資源,同時避免誤殺 VS Code Gradle 擴充功能
|
||||
./gradlew --stop 2>/dev/null; pkill -9 -f "org.gradle.launcher.daemon.bootstrap.GradleDaemon" 2>/dev/null; sleep 3
|
||||
# 防呆:先優雅 --stop;僅在仍殘留時才升級終止(先 TERM 再 KILL),避免無差別強殺撞鎖導致 clean 編譯隨機失敗
|
||||
#
|
||||
# ⚠️ 關鍵:pgrep/pkill 的比對字串必須「拆開」拼接,不可在本檔字面寫出完整 class 名。
|
||||
# 否則在背景任務(bash -c "...eval '整段腳本'...")情境下,腳本自己的命令列會含有
|
||||
# 完整的 GradleDaemon class 字串 → pgrep -f 比對到自己 → pkill 把跑腳本的 bash 自殺
|
||||
# → exit 144,編譯根本還沒開始。再加 -v $$ 排除自身 PID 雙重保險。
|
||||
DAEMON_PAT='org.gradle.launcher.daemon.bootstrap''.GradleDaemon' # 字面被拆開,pgrep 不會命中本腳本
|
||||
daemon_alive() { pgrep -f "$DAEMON_PAT" 2>/dev/null | grep -vx "$$" | grep -q .; }
|
||||
./gradlew --stop 2>/dev/null || true
|
||||
sleep 2
|
||||
if daemon_alive; then
|
||||
pkill -TERM -f "$DAEMON_PAT" 2>/dev/null || true
|
||||
sleep 3
|
||||
if daemon_alive; then
|
||||
pkill -9 -f "$DAEMON_PAT" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
|
||||
|
||||
# 取得目標 Flavor 參數
|
||||
# 取得目標硬體規格(預設 S12XYU;接受 S9/S12 與相容舊用語)
|
||||
RAW_INPUT="$1"
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER_INPUT=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_INPUT" = "general" ] || [ "$LOWER_INPUT" = "通用" ] || [ "$LOWER_INPUT" = "通用版" ] || [ "$LOWER_INPUT" = "標準" ] || [ "$LOWER_INPUT" = "標準版" ]; then
|
||||
TARGET_FLAVOR="General"
|
||||
elif [ "$LOWER_INPUT" = "chengwai" ] || [ "$LOWER_INPUT" = "晟崴" ] || [ "$LOWER_INPUT" = "晟崴版" ]; then
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
elif [ "$LOWER_INPUT" = "cmuh" ] || [ "$LOWER_INPUT" = "中國醫" ] || [ "$LOWER_INPUT" = "中國醫版" ]; then
|
||||
TARGET_FLAVOR="Cmuh"
|
||||
else
|
||||
TARGET_FLAVOR=$(echo "$RAW_INPUT" | awk '{print toupper(substr($0,1,1))tolower(substr($0,2))}')
|
||||
fi
|
||||
fi
|
||||
|
||||
# 根據 Flavor 動態設定智慧硬體規格預設值
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
if [ "$TARGET_FLAVOR" = "Cmuh" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER" in
|
||||
s9|s9xyu|21.5|大螢幕|中國醫|中國醫版|cmuh) TARGET_HARDWARE="S9XYU" ;;
|
||||
s12|s12xyu|10|晟崴|晟崴版|chengwai|通用|通用版|general) TARGET_HARDWARE="S12XYU" ;;
|
||||
*) echo "⚠️ 無法識別「$RAW_INPUT」,沿用預設 S12XYU" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 取得手動指定的第二個硬體規格參數 (S9 / S12)
|
||||
RAW_HW_INPUT="$2"
|
||||
if [ -n "$RAW_HW_INPUT" ]; then
|
||||
LOWER_HW=$(echo "$RAW_HW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_HW" = "s9" ] || [ "$LOWER_HW" = "s9xyu" ] || [ "$LOWER_HW" = "大螢幕" ] || [ "$LOWER_HW" = "21.5" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
elif [ "$LOWER_HW" = "s12" ] || [ "$LOWER_HW" = "s12xyu" ] || [ "$LOWER_HW" = "10" ]; then
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 安全防護:檢查是否為支援的客製項目
|
||||
if [ "$TARGET_FLAVOR" != "General" ] && [ "$TARGET_FLAVOR" != "Chengwai" ] && [ "$TARGET_FLAVOR" != "Cmuh" ]; then
|
||||
echo "❌ 不支援的 Flavor: $TARGET_FLAVOR,請使用 General, Chengwai 或 Cmuh。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 開始編譯: ${TARGET_FLAVOR}${TARGET_HARDWARE}Debug"
|
||||
echo "🚀 開始編譯: assemble${TARGET_HARDWARE}Debug"
|
||||
|
||||
# 使用 clean 確保完整重新編譯,避免舊快取遮蔽 import 缺失等錯誤
|
||||
./gradlew clean :app:assemble${TARGET_FLAVOR}${TARGET_HARDWARE}Debug --no-daemon
|
||||
./gradlew clean :app:assemble${TARGET_HARDWARE}Debug --no-daemon
|
||||
```
|
||||
|
||||
## 步驟二:導出 APK 到 outputs 資料夾
|
||||
@ -83,80 +78,44 @@ echo "🚀 開始編譯: ${TARGET_FLAVOR}${TARGET_HARDWARE}Debug"
|
||||
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
|
||||
mkdir -p outputs
|
||||
|
||||
# 取得 Flavor 參數
|
||||
RAW_INPUT="$1"
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER_INPUT=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_INPUT" = "general" ] || [ "$LOWER_INPUT" = "通用" ] || [ "$LOWER_INPUT" = "通用版" ] || [ "$LOWER_INPUT" = "標準" ] || [ "$LOWER_INPUT" = "標準版" ]; then
|
||||
TARGET_FLAVOR="General"
|
||||
elif [ "$LOWER_INPUT" = "chengwai" ] || [ "$LOWER_INPUT" = "晟崴" ] || [ "$LOWER_INPUT" = "晟崴版" ]; then
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
elif [ "$LOWER_INPUT" = "cmuh" ] || [ "$LOWER_INPUT" = "中國醫" ] || [ "$LOWER_INPUT" = "中國醫版" ]; then
|
||||
TARGET_FLAVOR="Cmuh"
|
||||
else
|
||||
TARGET_FLAVOR=$(echo "$RAW_INPUT" | awk '{print toupper(substr($0,1,1))tolower(substr($0,2))}')
|
||||
fi
|
||||
fi
|
||||
|
||||
# 根據 Flavor 動態設定智慧硬體規格預設值
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
if [ "$TARGET_FLAVOR" = "Cmuh" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER" in
|
||||
s9|s9xyu|21.5|大螢幕|中國醫|中國醫版|cmuh) TARGET_HARDWARE="S9XYU" ;;
|
||||
s12|s12xyu|10|晟崴|晟崴版|chengwai|通用|通用版|general) TARGET_HARDWARE="S12XYU" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 取得手動指定的第二個硬體規格參數 (S9 / S12)
|
||||
RAW_HW_INPUT="$2"
|
||||
if [ -n "$RAW_HW_INPUT" ]; then
|
||||
LOWER_HW=$(echo "$RAW_HW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_HW" = "s9" ] || [ "$LOWER_HW" = "s9xyu" ] || [ "$LOWER_HW" = "大螢幕" ] || [ "$LOWER_HW" = "21.5" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
elif [ "$LOWER_HW" = "s12" ] || [ "$LOWER_HW" = "s12xyu" ] || [ "$LOWER_HW" = "10" ]; then
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 解析硬體規格來決定輸出檔案名格式 (S9 -> S_9, S12 -> S_12)
|
||||
FORMATTED_HW=""
|
||||
if [ "$TARGET_HARDWARE" = "S9XYU" ]; then
|
||||
FORMATTED_HW="S_9_XY_U"
|
||||
FORMATTED_HW="S_9_XY_U"; MAJOR=21
|
||||
else
|
||||
FORMATTED_HW="S_12_XY_U"
|
||||
FORMATTED_HW="S_12_XY_U"; MAJOR=10
|
||||
fi
|
||||
|
||||
# 動態尋找編譯產出的最新 APK 檔案
|
||||
APK_SOURCE=$(find app/build/outputs/apk/${TARGET_FLAVOR}${TARGET_HARDWARE}/debug/ -name "app-${FORMATTED_HW}_${TARGET_FLAVOR}-*-debug.apk" | head -n 1)
|
||||
# 動態尋找編譯產出的最新 APK 檔案(檔名已無 project 段:app-<HW>-<ver>-debug.apk)
|
||||
APK_SOURCE=$(find app/build/outputs/apk/${TARGET_HARDWARE}/debug/ -name "app-${FORMATTED_HW}-*-debug.apk" | head -n 1)
|
||||
|
||||
if [ -n "$APK_SOURCE" ]; then
|
||||
APK_NAME=$(basename "$APK_SOURCE")
|
||||
APK_DEST="outputs/$APK_NAME"
|
||||
cp "$APK_SOURCE" "$APK_DEST"
|
||||
echo "✅ APK 已經複製到 outputs 資料夾,路徑為:$APK_DEST"
|
||||
|
||||
# 提取與計算版本資訊
|
||||
MAJOR=$(grep "def verMajor" app/build.gradle | awk '{print $4}')
|
||||
|
||||
# 若在 build.gradle 內 Major 是由 hardware 動態判定,直接讀取並在 Shell 依據當前規格計算
|
||||
if [ "$TARGET_HARDWARE" = "S9XYU" ]; then
|
||||
MAJOR=21
|
||||
else
|
||||
MAJOR=10
|
||||
fi
|
||||
|
||||
|
||||
MINOR=$(grep "def verMinor" app/build.gradle | awk '{print $4}')
|
||||
PATCH=$(grep "def verPatch" app/build.gradle | awk '{print $4}')
|
||||
VERSION_NAME="${MAJOR}_0${MINOR}_${PATCH}_R"
|
||||
VERSION_NAME="${MAJOR}_0${MINOR}_${PATCH}_D"
|
||||
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
|
||||
FLAVOR="${TARGET_FLAVOR}${TARGET_HARDWARE}"
|
||||
|
||||
|
||||
echo "=================================================="
|
||||
echo "📢 後台更新 keyin 參考資料:"
|
||||
echo "--------------------------------------------------"
|
||||
echo "* 產品風味 (Flavor):$FLAVOR"
|
||||
echo "* 硬體規格 (hardware):$TARGET_HARDWARE(${MAJOR} 吋)"
|
||||
echo "* 版本名稱 (versionName):$VERSION_NAME"
|
||||
echo "* 版本代碼 (versionCode):$VERSION_CODE"
|
||||
echo "* APK 檔案名稱:$APK_NAME"
|
||||
echo "* 購買流程:由雲端 shopping_mode 決定(basic / employee_card / pickup_sheet)"
|
||||
echo "* 更新說明:[請在此處附上口語化的一句話更新說明]"
|
||||
echo "=================================================="
|
||||
else
|
||||
@ -172,54 +131,27 @@ fi
|
||||
// turbo
|
||||
ADB_PATH="/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe"
|
||||
|
||||
# 取得 Flavor 參數
|
||||
RAW_INPUT="$1"
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER_INPUT=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_INPUT" = "general" ] || [ "$LOWER_INPUT" = "通用" ] || [ "$LOWER_INPUT" = "通用版" ] || [ "$LOWER_INPUT" = "標準" ] || [ "$LOWER_INPUT" = "標準版" ]; then
|
||||
TARGET_FLAVOR="General"
|
||||
elif [ "$LOWER_INPUT" = "chengwai" ] || [ "$LOWER_INPUT" = "晟崴" ] || [ "$LOWER_INPUT" = "晟崴版" ]; then
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
elif [ "$LOWER_INPUT" = "cmuh" ] || [ "$LOWER_INPUT" = "中國醫" ] || [ "$LOWER_INPUT" = "中國醫版" ]; then
|
||||
TARGET_FLAVOR="Cmuh"
|
||||
else
|
||||
TARGET_FLAVOR=$(echo "$RAW_INPUT" | awk '{print toupper(substr($0,1,1))tolower(substr($0,2))}')
|
||||
fi
|
||||
fi
|
||||
|
||||
# 根據 Flavor 動態設定智慧硬體規格預設值
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
if [ "$TARGET_FLAVOR" = "Cmuh" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER" in
|
||||
s9|s9xyu|21.5|大螢幕|中國醫|中國醫版|cmuh) TARGET_HARDWARE="S9XYU" ;;
|
||||
s12|s12xyu|10|晟崴|晟崴版|chengwai|通用|通用版|general) TARGET_HARDWARE="S12XYU" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 取得手動指定的第二個硬體規格參數 (S9 / S12)
|
||||
RAW_HW_INPUT="$2"
|
||||
if [ -n "$RAW_HW_INPUT" ]; then
|
||||
LOWER_HW=$(echo "$RAW_HW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$LOWER_HW" = "s9" ] || [ "$LOWER_HW" = "s9xyu" ] || [ "$LOWER_HW" = "大螢幕" ] || [ "$LOWER_HW" = "21.5" ]; then
|
||||
TARGET_HARDWARE="S9XYU"
|
||||
elif [ "$LOWER_HW" = "s12" ] || [ "$LOWER_HW" = "s12xyu" ] || [ "$LOWER_HW" = "10" ]; then
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 解析硬體規格來決定輸出檔案名格式 (S9 -> S_9, S12 -> S_12)
|
||||
FORMATTED_HW=""
|
||||
if [ "$TARGET_HARDWARE" = "S9XYU" ]; then
|
||||
FORMATTED_HW="S_9_XY_U"
|
||||
else
|
||||
FORMATTED_HW="S_12_XY_U"
|
||||
fi
|
||||
|
||||
# 檢查是否有設備連接
|
||||
DEVICE_COUNT=$($ADB_PATH devices | grep -v "List of devices" | grep "device" | wc -l)
|
||||
|
||||
if [ "$DEVICE_COUNT" -gt 0 ]; then
|
||||
echo "📱 偵測到 $DEVICE_COUNT 個設備,開始安裝..."
|
||||
APK_TARGET=$(find outputs/ -name "app-${FORMATTED_HW}_${TARGET_FLAVOR}-*-debug.apk" | head -n 1)
|
||||
APK_TARGET=$(find outputs/ -name "app-${FORMATTED_HW}-*-debug.apk" | head -n 1)
|
||||
if [ -n "$APK_TARGET" ]; then
|
||||
$ADB_PATH install -r -d -t "/home/mama/projects/TaiwanStar_general_size_s_Chengwai/$APK_TARGET"
|
||||
echo "🚀 安裝完成,正在啟動 App..."
|
||||
|
||||
@ -4,19 +4,26 @@ description: 自動編譯正式發行 Release APK、升級 Patch 版本號、並
|
||||
|
||||
# 生產環境發版打包工作流 (release-apk)
|
||||
|
||||
這個 Workflow 專門為應用程式「生產環境上線/發版」設計。當您準備好將特定客戶的最新功能派送至實體販賣機時,呼叫 `/release-apk` 將自動為您完成嚴格的發行檢查、版號遞增、Release 簽章打包與發版資訊生成。
|
||||
這個 Workflow 專門為應用程式「生產環境上線/發版」設計。呼叫 `/release-apk` 將自動完成嚴格的發行檢查、版號遞增、Release 簽章打包與發版資訊生成。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **客戶專案(購買流程)已收斂為執行期 `shopping_mode`(雲端 B014 下發),不再用 flavor 分家。**
|
||||
> 發版對象只剩 **硬體規格(hardware flavor)**:`S12XYU`(10 吋)/ `S9XYU`(21.5 吋 / Android 9)。
|
||||
> 同一支 APK 上線後,現場機台由雲端 `shopping_mode` 決定購買流程。
|
||||
> **部署鐵律**:發布統一版前,star-cloud 必須先為每台現役機設好 `shopping_mode`
|
||||
> (晟崴=employee_card、中國醫=pickup_sheet),否則預設 basic 會跑錯流程。
|
||||
|
||||
> [!TIP]
|
||||
> **支援參數化指定編譯變體:**
|
||||
> * 預設(不帶參數):打包 **`Chengwai` (晟崴客製正式版)**。
|
||||
> * 您可以呼叫 `/release-apk General`(或 `general`)打包 **`General` (通用正式版)**。
|
||||
> * 您可以呼叫 `/release-apk Cmuh`(或 `cmuh`)打包 **`Cmuh` (中國醫客製正式版)**。
|
||||
> **參數 = 硬體規格:**
|
||||
> * 預設(不帶參數):打包 **`S12XYU`(10 吋)**。
|
||||
> * `/release-apk S9`(或 `S9XYU` / `中國醫`)→ 打包 **`S9XYU`(21.5 吋)**。
|
||||
> * `/release-apk S12`(或 `S12XYU` / `晟崴` / `通用`)→ 打包 **`S12XYU`(10 吋)**。
|
||||
|
||||
---
|
||||
|
||||
## 步驟零:發行檢查與版號自動遞增 (OTA 鐵律)
|
||||
為了確保實體機台能夠順利觸發 OTA 自動下載覆蓋安裝,新編譯的 `versionCode` 必須嚴格大於現場的舊版號:
|
||||
1. AI 助手將讀取 `app/build.gradle` 並自動將 `verPatch`(修補版號)**遞增 1**(例如從 `8` 自動升級為 `9`),確保 versionCode 順利變大。
|
||||
1. AI 助手將讀取 `app/build.gradle` 並自動將 `verPatch`(修補版號)**遞增 1**,確保 versionCode 順利變大。
|
||||
2. 自動在 terminal 呈報即將打包的最新版本號資訊。
|
||||
|
||||
## 步驟一:清理快取並進行正式簽章編譯
|
||||
@ -25,33 +32,44 @@ description: 自動編譯正式發行 Release APK、升級 Patch 版本號、並
|
||||
```bash
|
||||
// turbo
|
||||
# 終止殘留的 Gradle Daemon 程序,避免多個 Daemon 互搶資源,同時避免誤殺 VS Code Gradle 擴充功能
|
||||
./gradlew --stop 2>/dev/null; pkill -9 -f "org.gradle.launcher.daemon.bootstrap.GradleDaemon" 2>/dev/null; sleep 3
|
||||
# 防呆:先優雅 --stop;僅在仍殘留時才升級終止(先 TERM 再 KILL),避免無差別強殺撞鎖導致 clean 編譯隨機失敗
|
||||
#
|
||||
# ⚠️ 關鍵:pgrep/pkill 的比對字串必須「拆開」拼接,不可在本檔字面寫出完整 class 名。
|
||||
# 否則在背景任務(bash -c "...eval '整段腳本'...")情境下,腳本自己的命令列會含有
|
||||
# 完整的 GradleDaemon class 字串 → pgrep -f 比對到自己 → pkill 把跑腳本的 bash 自殺
|
||||
# → exit 144,編譯根本還沒開始。再加 -vx $$ 排除自身 PID 雙重保險。
|
||||
DAEMON_PAT='org.gradle.launcher.daemon.bootstrap''.GradleDaemon' # 字面被拆開,pgrep 不會命中本腳本
|
||||
daemon_alive() { pgrep -f "$DAEMON_PAT" 2>/dev/null | grep -vx "$$" | grep -q .; }
|
||||
./gradlew --stop 2>/dev/null || true
|
||||
sleep 2
|
||||
if daemon_alive; then
|
||||
pkill -TERM -f "$DAEMON_PAT" 2>/dev/null || true
|
||||
sleep 3
|
||||
if daemon_alive; then
|
||||
pkill -9 -f "$DAEMON_PAT" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
|
||||
|
||||
# 取得目標 Flavor 參數(預設為 Chengwai,支援 General, Chengwai, Cmuh)
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
if [ -n "$1" ]; then
|
||||
TARGET_FLAVOR=$(echo "$1" | awk '{print toupper(substr($0,1,1))tolower(substr($0,2))}')
|
||||
# 取得目標硬體規格(預設 S12XYU)
|
||||
RAW_INPUT="$1"
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER" in
|
||||
s9|s9xyu|21.5|大螢幕|中國醫|中國醫版|cmuh) TARGET_HARDWARE="S9XYU" ;;
|
||||
s12|s12xyu|10|晟崴|晟崴版|chengwai|通用|通用版|general) TARGET_HARDWARE="S12XYU" ;;
|
||||
*) echo "⚠️ 無法識別「$RAW_INPUT」,沿用預設 S12XYU" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 安全防護:檢查是否為支援的客製項目
|
||||
if [ "$TARGET_FLAVOR" != "General" ] && [ "$TARGET_FLAVOR" != "Chengwai" ] && [ "$TARGET_FLAVOR" != "Cmuh" ]; then
|
||||
echo "❌ 不支援的 Flavor: $TARGET_FLAVOR,請使用 General, Chengwai 或 Cmuh。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 根據專案 Flavor 判定硬體 Flavor(中國醫 Cmuh 使用 S9XYU,其餘使用 S12XYU)
|
||||
HW_FLAVOR="S12XYU"
|
||||
if [ "$TARGET_FLAVOR" = "Cmuh" ]; then
|
||||
HW_FLAVOR="S9XYU"
|
||||
fi
|
||||
|
||||
echo "📦 [Release] 開始編譯正式版變體: ${TARGET_FLAVOR}${HW_FLAVOR}Release"
|
||||
echo "📦 [Release] 開始編譯正式版變體: assemble${TARGET_HARDWARE}Release"
|
||||
|
||||
# 使用 clean 確保完整重新編譯,避開 incremental 快取問題
|
||||
./gradlew clean :app:assemble${TARGET_FLAVOR}${HW_FLAVOR}Release --no-daemon
|
||||
./gradlew clean :app:assemble${TARGET_HARDWARE}Release --no-daemon
|
||||
```
|
||||
|
||||
## 步驟二:導出正式 APK 並產出後台 Keyin 派送參考資料
|
||||
@ -62,19 +80,24 @@ echo "📦 [Release] 開始編譯正式版變體: ${TARGET_FLAVOR}${HW_FLAVOR}Re
|
||||
cd /home/mama/projects/TaiwanStar_general_size_s_Chengwai
|
||||
mkdir -p outputs
|
||||
|
||||
# 取得 Flavor 參數
|
||||
TARGET_FLAVOR="Chengwai"
|
||||
if [ -n "$1" ]; then
|
||||
TARGET_FLAVOR=$(echo "$1" | awk '{print toupper(substr($0,1,1))tolower(substr($0,2))}')
|
||||
RAW_INPUT="$1"
|
||||
TARGET_HARDWARE="S12XYU"
|
||||
if [ -n "$RAW_INPUT" ]; then
|
||||
LOWER=$(echo "$RAW_INPUT" | tr '[:upper:]' '[:lower:]')
|
||||
case "$LOWER" in
|
||||
s9|s9xyu|21.5|大螢幕|中國醫|中國醫版|cmuh) TARGET_HARDWARE="S9XYU" ;;
|
||||
s12|s12xyu|10|晟崴|晟崴版|chengwai|通用|通用版|general) TARGET_HARDWARE="S12XYU" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
HW_FLAVOR="S12XYU"
|
||||
if [ "$TARGET_FLAVOR" = "Cmuh" ]; then
|
||||
HW_FLAVOR="S9XYU"
|
||||
if [ "$TARGET_HARDWARE" = "S9XYU" ]; then
|
||||
FORMATTED_HW="S_9_XY_U"; MAJOR=21
|
||||
else
|
||||
FORMATTED_HW="S_12_XY_U"; MAJOR=10
|
||||
fi
|
||||
|
||||
# 動態尋找編譯產出的最新正式版 APK 檔案
|
||||
APK_SOURCE=$(find app/build/outputs/apk/${TARGET_FLAVOR}${HW_FLAVOR}/release/ -name "app-S_*_${TARGET_FLAVOR}-*-release.apk" | head -n 1)
|
||||
# 動態尋找編譯產出的最新正式版 APK 檔案(檔名已無 project 段)
|
||||
APK_SOURCE=$(find app/build/outputs/apk/${TARGET_HARDWARE}/release/ -name "app-${FORMATTED_HW}-*-release.apk" | head -n 1)
|
||||
|
||||
if [ -n "$APK_SOURCE" ]; then
|
||||
APK_NAME=$(basename "$APK_SOURCE")
|
||||
@ -83,31 +106,21 @@ if [ -n "$APK_SOURCE" ]; then
|
||||
echo "=================================================="
|
||||
echo "✅ 正式版 APK 封裝成功,已導出至:$APK_DEST"
|
||||
echo "=================================================="
|
||||
|
||||
# 提取與計算版本資訊
|
||||
|
||||
MINOR=$(grep "def verMinor" app/build.gradle | awk '{print $4}')
|
||||
PATCH=$(grep "def verPatch" app/build.gradle | awk '{print $4}')
|
||||
|
||||
# 根據硬體規格決定 Major (S9XYU 為 21 吋,S12XYU 為 10 吋)
|
||||
if [ "$HW_FLAVOR" = "S9XYU" ]; then
|
||||
MAJOR=21
|
||||
else
|
||||
MAJOR=10
|
||||
fi
|
||||
|
||||
VERSION_NAME="${MAJOR}_0${MINOR}_${PATCH}_R"
|
||||
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
|
||||
FLAVOR="${TARGET_FLAVOR}${HW_FLAVOR}"
|
||||
|
||||
|
||||
echo "🚀 實體販賣機 OTA 後台新增版本 Keyin 參考資料:"
|
||||
echo "--------------------------------------------------"
|
||||
echo "1. 專案/客戶風味 (Flavor) : $FLAVOR"
|
||||
echo "1. 硬體規格 (hardware) : $TARGET_HARDWARE(${MAJOR} 吋)"
|
||||
echo "2. 版本代碼 (versionCode) : $VERSION_CODE"
|
||||
echo "3. 版本名稱 (versionName) : $VERSION_NAME"
|
||||
echo "4. 螢幕吋數 (Screen Size) : ${MAJOR} 吋"
|
||||
echo "5. 正式版 APK 檔名 : $APK_NAME"
|
||||
echo "4. 正式版 APK 檔名 : $APK_NAME"
|
||||
echo "5. 購買流程 : 由雲端 shopping_mode 決定(須先於後台設好)"
|
||||
echo "--------------------------------------------------"
|
||||
echo "📢 [溫馨提醒] 請將此 APK 上傳至 OTA 平台,並設定為對應機型的強迫升級。"
|
||||
echo "📢 [溫馨提醒] 請將此 APK 上傳至 OTA 平台(依硬體分版),並確認該機 shopping_mode 已設定。"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "❌ 找不到編譯產出的正式版 APK 檔案,封裝失敗!"
|
||||
@ -116,14 +129,13 @@ fi
|
||||
```
|
||||
|
||||
## 步驟三:自動化 Git 發版標記引導
|
||||
為了落實公司發版規範第 5 條「必須在主分支合併節點打上 Git Tag,以利日後追溯特定現場機台的版本程式碼」,AI 助手會自動為您計算出對應的版本 Tag 標籤與提示:
|
||||
為了落實公司發版規範第 5 條「必須在主分支合併節點打上 Git Tag,以利日後追溯特定現場機台的版本程式碼」,AI 助手會自動計算出對應的版本 Tag 標籤與提示:
|
||||
|
||||
```bash
|
||||
// turbo
|
||||
MAJOR=$(grep "def verMajor" app/build.gradle | awk '{print $4}')
|
||||
MINOR=$(grep "def verMinor" app/build.gradle | awk '{print $4}')
|
||||
PATCH=$(grep "def verPatch" app/build.gradle | awk '{print $4}')
|
||||
TAG_NAME="v${MAJOR}.${MINOR}.${PATCH}"
|
||||
TAG_NAME="v1.${MINOR}.${PATCH}"
|
||||
|
||||
echo "=================================================="
|
||||
echo "🔔 Git 發版追溯標記指南"
|
||||
|
||||
25
.claude/commands/compiler-apk.md
Normal file
25
.claude/commands/compiler-apk.md
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
description: 編譯 Debug APK、導出 outputs/、偵測到設備則安裝(依硬體規格 S9/S12 打包;購買流程由雲端 shopping_mode 決定)
|
||||
argument-hint: [S9|S12]
|
||||
allowed-tools: Bash, Read, Edit
|
||||
---
|
||||
|
||||
執行本專案的 APK 編譯安裝 workflow,定義於 `.agents/workflows/compiler-apk.md`(單一真相來源)。
|
||||
|
||||
本次參數:「$ARGUMENTS」(硬體規格:S9 / S12;可能為空,預設 S12XYU)
|
||||
|
||||
> 注意:客戶專案(購買流程)已收斂為執行期 `shopping_mode`(雲端 B014 下發),打包對象只剩硬體規格。
|
||||
> 同一支 APK 在現場依雲端 `shopping_mode` 切換 basic / employee_card / pickup_sheet。
|
||||
|
||||
## 執行方式
|
||||
1. 先 Read `.agents/workflows/compiler-apk.md`,理解步驟零~三。
|
||||
2. 步驟零(版號):執行 `git status --porcelain app/`;若有未提交改動且本輪對話尚未升過版,讀 `app/build.gradle` 把 `verPatch` 遞增 1 並回報新版號。
|
||||
3. 步驟一~三:依序執行 workflow 內每個 bash 區塊。**關鍵**:workflow 的 bash 以位置參數 `$1` 取得硬體規格,所以執行每個區塊前,先在區塊最前面注入本次參數:
|
||||
|
||||
set -- $ARGUMENTS
|
||||
|
||||
讓 workflow 內的 `$1` 正確解析(S9/S12,或相容舊用語 中國醫→S9、晟崴/通用→S12)。若本次無參數,workflow 自身預設 S12XYU。
|
||||
4. 環境用 workflow 指定的 Java 17(`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`)。
|
||||
5. 編譯/導出屬長任務 → 用 run_in_background,完成再回報。
|
||||
6. 步驟三會偵測 adb 設備:有連設備才安裝並啟動 App;沒設備就保留 APK 結束。
|
||||
7. 最後完整回報 workflow 輸出的「後台 keyin 參考資料」(硬體規格 / versionName / versionCode / APK 檔名)。
|
||||
26
.claude/commands/release-apk.md
Normal file
26
.claude/commands/release-apk.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
description: 正式版 Release APK 打包、升 Patch 版號、產出 OTA keyin 與 Git tag 指引(依硬體 S9/S12;流程由雲端 shopping_mode 決定)
|
||||
argument-hint: [S9|S12]
|
||||
allowed-tools: Bash, Read, Edit
|
||||
---
|
||||
|
||||
執行本專案的正式發版 workflow,定義於 `.agents/workflows/release-apk.md`(單一真相來源)。
|
||||
|
||||
本次參數:「$ARGUMENTS」(硬體規格:S9 / S12;可能為空,預設 S12XYU)
|
||||
|
||||
> 注意:客戶專案(購買流程)已收斂為執行期 `shopping_mode`(雲端 B014 下發),發版對象只剩硬體規格。
|
||||
> 部署鐵律:發布前 star-cloud 必須先為每台現役機設好 `shopping_mode`,否則預設 basic 跑錯流程。
|
||||
|
||||
## 執行方式
|
||||
1. 先 Read `.agents/workflows/release-apk.md`,理解步驟零~三。
|
||||
2. 步驟零(OTA 鐵律):讀 `app/build.gradle` 把 `verPatch` 遞增 1(確保 versionCode 變大可觸發 OTA),回報新版號。
|
||||
3. 步驟一~三:依序執行 workflow 內每個 bash 區塊。**關鍵**:workflow 的 bash 以位置參數 `$1` 取得硬體規格,執行每個區塊前先注入本次參數:
|
||||
|
||||
set -- $ARGUMENTS
|
||||
|
||||
無參數時預設 S12XYU(接受 S9/S12,或相容 中國醫→S9、晟崴/通用→S12)。
|
||||
4. 環境用 Java 17(`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`);Release 會跑混淆與 lintVital,較久 → 用 run_in_background。
|
||||
5. 步驟二導出正式簽章 APK 到 `outputs/` 並印出 OTA 後台 keyin 對照資料。
|
||||
6. 步驟三回報 Git 發版 tag 指引。
|
||||
|
||||
> 注意:這是「正式發版」,會升版號並產正式簽章 APK。除非使用者明確指示,不要自行 commit / push / 打 tag,只回報指引。
|
||||
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git switch *)",
|
||||
"Bash(grep -n 'is_spring_slot\\\\|protected $fillable\\\\|protected $casts\\\\|'\\\\''settings'\\\\''' app/Models/Machine/Machine.php)"
|
||||
]
|
||||
}
|
||||
}
|
||||
22
.editorconfig
Normal file
22
.editorconfig
Normal file
@ -0,0 +1,22 @@
|
||||
root = true
|
||||
|
||||
# 全專案預設:UTF-8(不含 BOM)、LF、檔尾換行
|
||||
# 重點:charset = utf-8(非 utf-8-bom)可避免編輯器在 XML 開頭塞入 BOM,
|
||||
# 否則 aapt2 會報「Cannot read field "elmName" because "root" is null」導致打包失敗。
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{xml,java,kt,kts,gradle,pro}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# 二進位資源不套用規則
|
||||
[*.{png,webp,jpg,jpeg,gif,ttf,otf,9.png}]
|
||||
charset = unset
|
||||
end_of_line = unset
|
||||
insert_final_newline = unset
|
||||
trim_trailing_whitespace = unset
|
||||
@ -24,7 +24,8 @@
|
||||
- 禁止直接手改成品 `versionCode`/`versionName` 值。
|
||||
- 版號管理透過 `verMajor`、`verMinor`、`verPatch` 變數。
|
||||
- OTA 發版時,`versionCode` 必須嚴格遞增。
|
||||
- 中國醫現場版 (`Cmuh`) 修正預設以 `:app:assembleCmuhS9XYUDebug` 驗證。
|
||||
- 21.5 吋大螢幕現場版(如中國醫)修正預設以 `:app:assembleS9XYUDebug` 驗證。
|
||||
- 10 吋一般現場版(如晟崴、通用)修正預設以 `:app:assembleS12XYUDebug` 驗證。
|
||||
|
||||
## When Rules Conflict
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
1-2-1版本修改
|
||||
1. 修改HandlerAbstract中的start方法
|
||||
- 問題不在於 Handler 本身壞掉,而是 start() 方法設計成「修改類別變數後 post 自己」。在多執行緒環境(串口讀取 vs UI 顯示)下,這種寫法會導致參數被後來的呼叫覆蓋,導致前面的指令消失。
|
||||
- 問題不在於 Handler 本身壞掉,而是 start() 方法設計成「修改類別變數後 post 自己」。在多執行緒環境(串口讀取 vs UI 顯示)下,這種寫法會導致參數被後來的呼叫覆蓋,導致前面的指令消失。
|
||||
- 修改成原來start的方法,變成send的方法
|
||||
2. 修改成無多國語版本
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
# - 修改取貨碼太久出貨問題 -> FontendActivity 及 HomeActivity 每半小時下位機連線這一段要備註掉
|
||||
# - 修改若原本是要用手動輸入載具,但後來改用掃描載具的方式,就自動將原來手動輸入的畫面關閉 -> 修改InvoiceBarcodeDialog
|
||||
|
||||
2026/02/06
|
||||
2026/02/06
|
||||
1.修改取貨碼判斷取貨口有東西的功能
|
||||
|
||||
2026/02/09 S_12_XY_U_Standard_10_04_R
|
||||
@ -109,4 +109,7 @@
|
||||
2. 介面操作與防呆機制優化:
|
||||
- 優化貨道資料上傳流程,並在出貨完成後隱藏「取貨確認」按鈕以簡化流程。
|
||||
- 優化商品選購之出貨口狀態檢查,並實作防重複點擊的防呆機制。
|
||||
- 修復閒置逾時觸發判定,補上對話框關閉時釋放音訊/影片等資源之邏輯,並修正介面繁體中文字串。
|
||||
- 修復閒置逾時觸發判定,補上對話框關閉時釋放音訊/影片等資源之邏輯,並修正介面繁體中文字串。
|
||||
|
||||
2026/06/22
|
||||
北護版本用ai
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@ -8,8 +9,8 @@ plugins {
|
||||
// 螢幕尺寸 (verMajor) 已改為下方依據編譯 Flavor (S12XYU / S9XYU) 自動動態判定,
|
||||
// 避免不同尺寸機台間版本號產生衝突。
|
||||
// =========================================================================
|
||||
def verMinor = 1
|
||||
def verPatch = 67
|
||||
def verMinor = 2
|
||||
def verPatch = 42
|
||||
|
||||
|
||||
|
||||
@ -29,30 +30,37 @@ android {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 雙維度 Flavor:project(客戶專案)× hardware(硬體規格)
|
||||
// 單維度 Flavor:hardware(硬體規格 / IPC 世代)
|
||||
// 客戶專案(購買流程)已收斂為執行期 shopping_mode(雲端 B014 下發),不再用 flavor 分家。
|
||||
// hardware 維度決定螢幕尺寸、minSdk(安卓版本)與 COM port(見 ComPort.getFilepath)。
|
||||
// =====================================================================
|
||||
flavorDimensions "project", "hardware"
|
||||
flavorDimensions "hardware"
|
||||
|
||||
productFlavors {
|
||||
General {
|
||||
dimension "project"
|
||||
}
|
||||
|
||||
Chengwai {
|
||||
dimension "project"
|
||||
}
|
||||
|
||||
Cmuh {
|
||||
dimension "project"
|
||||
}
|
||||
|
||||
S12XYU {
|
||||
dimension "hardware"
|
||||
}
|
||||
|
||||
S9XYU {
|
||||
dimension "hardware"
|
||||
minSdk 28
|
||||
minSdk 24 // Android 7.0(原為 28=Android 9;降至 24 讓 Android 7 機台可安裝)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 共用簽章金鑰(OTA 鐵律)
|
||||
// 現場機台的 App 全部以這把 key 簽章;OTA 覆蓋安裝要求新舊 APK 簽章一致,
|
||||
// 否則 INSTALL_FAILED_UPDATE_INCOMPATIBLE。過去依賴各電腦自動生成的
|
||||
// ~/.android/debug.keystore(每台不同)→ 換電腦編就無法 OTA。
|
||||
// 改為指向 repo 內固定 keystore,任何電腦 / compiler-apk / release-apk 皆共用同一把。
|
||||
// 指紋 SHA1: 23:57:65:A1:AA:86:21:5C:78:A7:20:4C:AB:5B:38:B7:A2:93:5C:8F
|
||||
// =====================================================================
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("signing/taiwanstar.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,8 +75,7 @@ android {
|
||||
// 自定義打包檔案名稱與動態版本號計算
|
||||
// 遵循公司命名規範:來源_安卓號_主板號_機器系列_專案名稱_螢幕大小_次版號_修補版號_環境
|
||||
applicationVariants.all { variant ->
|
||||
def project = variant.productFlavors[0].name // project dimension
|
||||
def hardware = variant.productFlavors[1].name // hardware dimension
|
||||
def hardware = variant.productFlavors[0].name // hardware dimension(唯一維度)
|
||||
def buildType = variant.buildType.name
|
||||
|
||||
// 根據硬體規格自動動態判定螢幕吋數 (verMajor) 與版號
|
||||
@ -86,9 +93,12 @@ android {
|
||||
verMajor = 10 // 10 吋
|
||||
}
|
||||
|
||||
// 環境後綴:release → R(正式)、debug → D(測試),供現場一眼辨識 APK 來源
|
||||
def envSuffix = (buildType == "release") ? "R" : "D"
|
||||
|
||||
// 動態重算並覆寫此 Variant 的版本號,避免互相污染
|
||||
def calculatedVersionCode = verMajor * 10000 + calculatedMinor * 100 + calculatedPatch
|
||||
def calculatedVersionName = "${verMajor}_0${calculatedMinor}_${calculatedPatch}_R"
|
||||
def calculatedVersionName = "${verMajor}_0${calculatedMinor}_${calculatedPatch}_${envSuffix}"
|
||||
|
||||
|
||||
variant.outputs.each { output ->
|
||||
@ -99,8 +109,8 @@ android {
|
||||
def hwFormatted = hardware
|
||||
.replaceAll(/^([A-Z])(\d+)([A-Z]+)([A-Z])$/, '$1_$2_$3_$4')
|
||||
|
||||
// 輸出範例:app-S_12_XY_U_Chengwai-10_08_8_R-release.apk
|
||||
output.outputFileName = "app-${hwFormatted}_${project}-${calculatedVersionName}-${buildType}.apk"
|
||||
// 輸出範例:app-S_12_XY_U-10_08_8_R-release.apk(依硬體分版,購買流程由雲端 shopping_mode 決定)
|
||||
output.outputFileName = "app-${hwFormatted}-${calculatedVersionName}-${buildType}.apk"
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +121,10 @@ android {
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '11'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
@ -146,4 +160,11 @@ dependencies {
|
||||
implementation 'io.netty:netty-codec-http:4.1.94.Final'
|
||||
implementation 'io.netty:netty-handler:4.1.94.Final'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
||||
// Kotlin / MVVM(basic 模式的多品項購物車)
|
||||
implementation libs.kotlin.stdlib
|
||||
implementation libs.lifecycle.viewmodel.ktx
|
||||
implementation libs.lifecycle.livedata.ktx
|
||||
implementation libs.lifecycle.runtime.ktx
|
||||
implementation libs.brvah
|
||||
}
|
||||
|
||||
BIN
app/signing/taiwanstar.keystore
Normal file
BIN
app/signing/taiwanstar.keystore
Normal file
Binary file not shown.
@ -1,42 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.MemberCardPickupDialog;
|
||||
|
||||
/**
|
||||
* Chengwai (晟崴版) 銷售流程處理器。
|
||||
* 點選商品/購物車時直接進入員工卡刷卡取貨 Dialog (MemberCardPickupDialog)。
|
||||
*/
|
||||
public class SaleFlowHandler {
|
||||
|
||||
protected final Context context;
|
||||
protected final SaleFlowCallback callback;
|
||||
protected final HandlerMain handlerMain;
|
||||
|
||||
public SaleFlowHandler(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
this.context = context;
|
||||
this.handlerMain = handlerMain;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void onSetupUI() {
|
||||
// 晟崴版 UI 設定,預設無動作
|
||||
}
|
||||
|
||||
public void onSetupUI(android.view.View rootView) {
|
||||
// 晟崴版 UI 設定,預設無動作
|
||||
}
|
||||
|
||||
public void onBuyRequested() {
|
||||
new MemberCardPickupDialog(context, handlerMain).show();
|
||||
}
|
||||
|
||||
public void onBarcodeReceived(String rawData) {
|
||||
// 晟崴版不處理掃碼
|
||||
}
|
||||
|
||||
public void onPickupRequested() {
|
||||
// 晟崴版不處理取貨碼
|
||||
}
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.databinding.DialogPickupCodeBuyBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.recycler.RecyclerDialogPickupCodeBuyListAdpter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
public enum Option {
|
||||
TOAST(0),
|
||||
COUNTDOWN_CANCEL(1),
|
||||
SET_COUNTDOWN_TEXT(2),
|
||||
SET_PRICE(3),
|
||||
RESET_COUNTDOWM(4),
|
||||
RESET_COUNTDOWM_TOAST(5),
|
||||
;
|
||||
|
||||
private int option;
|
||||
|
||||
public int getOption() {
|
||||
return option;
|
||||
}
|
||||
|
||||
Option(int option) {
|
||||
this.option = option;
|
||||
}
|
||||
}
|
||||
|
||||
public static final Map<Integer, Option> optionMap = new HashMap<>();
|
||||
static {
|
||||
for (Option option : Option.values()) {
|
||||
optionMap.put(option.getOption(), option);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return getContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<? extends DialogAbstract> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getContext(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
if (MyApp.getInstance().getBuyList().size() <= 0) {
|
||||
|
||||
dialogCancel();
|
||||
} else {
|
||||
Option option = optionMap.get(commandCode);
|
||||
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
break;
|
||||
case COUNTDOWN_CANCEL:
|
||||
cancelOnThreadCountdown();
|
||||
break;
|
||||
case SET_COUNTDOWN_TEXT:
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case SET_PRICE:
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM:
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM_TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private DialogPickupCodeBuyBinding binding;
|
||||
private RecyclerDialogPickupCodeBuyListAdpter recyclerDialogPickupCodeBuyListAdpter;
|
||||
private CloseDialogOnThreadHandler closeDialogCountdown;
|
||||
private boolean isChangeDispatch;
|
||||
|
||||
public BuyPickupCodeDialog(Context context, HandlerMain srcHandlerMain) {
|
||||
super(context, srcHandlerMain);
|
||||
getLogs().info("open:" + getClass().getSimpleName());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogPickupCodeBuyBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
isChangeDispatch = true;
|
||||
|
||||
this.recyclerDialogPickupCodeBuyListAdpter = new RecyclerDialogPickupCodeBuyListAdpter(getCtx(), MyApp.getInstance().getBuyList(), getHandlerMain());
|
||||
this.binding.recyclerDialogBuyList.setAdapter(recyclerDialogPickupCodeBuyListAdpter);
|
||||
this.binding.recyclerDialogBuyList.setLayoutManager(new LinearLayoutManager(context));
|
||||
|
||||
MyApp.getInstance().getReportFlowInfoData().setOrderId(Tools.generateTimeBasedUUID());
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(6);
|
||||
MyApp.getInstance().getPostInvoiceByGreenData().setOrderId(MyApp.getInstance().getReportFlowInfoData().getOrderId());
|
||||
|
||||
this.binding.layoutMemberTransactions.setVisibility(View.GONE);
|
||||
|
||||
this.binding.buttonGetProduct.setOnClickListener(v -> gotoGetProduct());
|
||||
this.binding.buttonCancel.setOnClickListener(v -> dialogCancel());
|
||||
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void gotoGetProduct() {
|
||||
if (isChangeDispatch) {
|
||||
closeDialogCountdown.shutdown();
|
||||
isChangeDispatch = false;
|
||||
changeDialog(DispatchDialog.class);
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogPickupCodeBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void cancelOnThreadCountdown() {
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogPickupCodeBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
getTools().dialogClose();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText(getContext().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public void gotoDialog() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
if (flowType == 5) {
|
||||
changeCheckout();
|
||||
} else {
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
if (MyApp.getInstance().getDevSet().isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
} else {
|
||||
if (MyApp.getInstance().getDevSet().isInvoice()) {
|
||||
BuyStructure buyData = MyApp.getInstance().getBuyList().get(0);
|
||||
try {
|
||||
SlotField slotField = SlotField.getSlotField(buyData.getField());
|
||||
if (slotField.isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
}
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void changeCheckout() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
switch (flowType) {
|
||||
case 1:
|
||||
changeDialog(CheckoutNfcCardDialog.class);
|
||||
break;
|
||||
case 2:
|
||||
changeDialog(CheckoutNfcRechargeDialog.class);
|
||||
break;
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
changeDialog(CheckoutAccessCodeDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
changeDialog(CashPayDialog.class);
|
||||
break;
|
||||
case 70:
|
||||
changeDialog(CheckoutLinePayDialog.class);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
public CloseDialogOnThreadHandler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.COUNTDOWN_CANCEL.getOption(), "close dialog");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(long countdown, HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(countdown));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,263 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.databinding.DialogPickupCodeBuyBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.recycler.RecyclerDialogPickupCodeBuyListAdpter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
public enum Option {
|
||||
TOAST(0),
|
||||
COUNTDOWN_CANCEL(1),
|
||||
SET_COUNTDOWN_TEXT(2),
|
||||
SET_PRICE(3),
|
||||
RESET_COUNTDOWM(4),
|
||||
RESET_COUNTDOWM_TOAST(5),
|
||||
;
|
||||
|
||||
private int option;
|
||||
|
||||
public int getOption() {
|
||||
return option;
|
||||
}
|
||||
|
||||
Option(int option) {
|
||||
this.option = option;
|
||||
}
|
||||
}
|
||||
|
||||
public static final Map<Integer, Option> optionMap = new HashMap<>();
|
||||
static {
|
||||
for (Option option : Option.values()) {
|
||||
optionMap.put(option.getOption(), option);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return getContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<? extends DialogAbstract> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getContext(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
if (MyApp.getInstance().getBuyList().size() <= 0) {
|
||||
|
||||
dialogCancel();
|
||||
} else {
|
||||
Option option = optionMap.get(commandCode);
|
||||
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
break;
|
||||
case COUNTDOWN_CANCEL:
|
||||
cancelOnThreadCountdown();
|
||||
break;
|
||||
case SET_COUNTDOWN_TEXT:
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case SET_PRICE:
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM:
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM_TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private DialogPickupCodeBuyBinding binding;
|
||||
private RecyclerDialogPickupCodeBuyListAdpter recyclerDialogPickupCodeBuyListAdpter;
|
||||
private CloseDialogOnThreadHandler closeDialogCountdown;
|
||||
private boolean isChangeDispatch;
|
||||
|
||||
public BuyPickupCodeDialog(Context context, HandlerMain srcHandlerMain) {
|
||||
super(context, srcHandlerMain);
|
||||
getLogs().info("open:" + getClass().getSimpleName());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogPickupCodeBuyBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
isChangeDispatch = true;
|
||||
|
||||
this.recyclerDialogPickupCodeBuyListAdpter = new RecyclerDialogPickupCodeBuyListAdpter(getCtx(), MyApp.getInstance().getBuyList(), getHandlerMain());
|
||||
this.binding.recyclerDialogBuyList.setAdapter(recyclerDialogPickupCodeBuyListAdpter);
|
||||
this.binding.recyclerDialogBuyList.setLayoutManager(new LinearLayoutManager(context));
|
||||
|
||||
MyApp.getInstance().getReportFlowInfoData().setOrderId(Tools.generateTimeBasedUUID());
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(6);
|
||||
MyApp.getInstance().getPostInvoiceByGreenData().setOrderId(MyApp.getInstance().getReportFlowInfoData().getOrderId());
|
||||
|
||||
this.binding.layoutMemberTransactions.setVisibility(View.GONE);
|
||||
|
||||
this.binding.buttonGetProduct.setOnClickListener(v -> gotoGetProduct());
|
||||
this.binding.buttonCancel.setOnClickListener(v -> dialogCancel());
|
||||
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void gotoGetProduct() {
|
||||
if (isChangeDispatch) {
|
||||
closeDialogCountdown.shutdown();
|
||||
isChangeDispatch = false;
|
||||
changeDialog(DispatchDialog.class);
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogPickupCodeBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void cancelOnThreadCountdown() {
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogPickupCodeBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
getTools().dialogClose();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
}
|
||||
|
||||
public void gotoDialog() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
if (flowType == 5) {
|
||||
changeCheckout();
|
||||
} else {
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
if (MyApp.getInstance().getDevSet().isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
} else {
|
||||
if (MyApp.getInstance().getDevSet().isInvoice()) {
|
||||
BuyStructure buyData = MyApp.getInstance().getBuyList().get(0);
|
||||
try {
|
||||
SlotField slotField = SlotField.getSlotField(buyData.getField());
|
||||
if (slotField.isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
}
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void changeCheckout() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
switch (flowType) {
|
||||
case 1:
|
||||
changeDialog(CheckoutNfcCardDialog.class);
|
||||
break;
|
||||
case 2:
|
||||
changeDialog(CheckoutNfcRechargeDialog.class);
|
||||
break;
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
changeDialog(CheckoutAccessCodeDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
changeDialog(CheckoutCashDialog.class);
|
||||
break;
|
||||
case 70:
|
||||
changeDialog(CheckoutLinePayDialog.class);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
public CloseDialogOnThreadHandler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.COUNTDOWN_CANCEL.getOption(), "close dialog");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(long countdown, HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(countdown));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,429 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.databinding.DialogAccessCodeBinding;
|
||||
import com.unibuy.smartdevice.databinding.DialogPickupCodeFontendBinding;
|
||||
import com.unibuy.smartdevice.devices.DeviceType;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.devices.UsbDev;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.exception.LogsIOException;
|
||||
import com.unibuy.smartdevice.exception.LogsParseException;
|
||||
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
|
||||
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
|
||||
import com.unibuy.smartdevice.external.HttpAPI;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainScheduler;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.FontendActivity;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class FontendPickupCodeDialog extends DialogAbstract {
|
||||
public enum Option {
|
||||
TOAST(0),
|
||||
COUNTDOWN_CANCEL(1),
|
||||
SET_COUNTDOWN_TEXT(2),
|
||||
INVOICE_SUCCESSFUL(4),
|
||||
INVOICE_FAILED(5),
|
||||
CHANGE_DISPATCH(6),
|
||||
CHANGE_BUY(7),
|
||||
SET_STATUS_TEXT(8),
|
||||
;
|
||||
|
||||
private int option;
|
||||
|
||||
public int getOption() {
|
||||
return option;
|
||||
}
|
||||
|
||||
Option(int option) {
|
||||
this.option = option;
|
||||
}
|
||||
}
|
||||
|
||||
public static final Map<Integer, Option> optionMap = new HashMap<>();
|
||||
static {
|
||||
for (Option option : Option.values()) {
|
||||
optionMap.put(option.getOption(), option);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return getContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<? extends DialogAbstract> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getCtx(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
Option option = optionMap.get(commandCode);
|
||||
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
break;
|
||||
case COUNTDOWN_CANCEL:
|
||||
cancelOnThreadCountdown();
|
||||
break;
|
||||
case SET_COUNTDOWN_TEXT:
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case INVOICE_SUCCESSFUL:
|
||||
MyApp.getInstance().getInvoiceData().setCarrier(message);
|
||||
MyApp.getInstance().getReportFlowInfoData().setInvoiceInfo("carrier:" + MyApp.getInstance().getInvoiceData().getCarrier());
|
||||
break;
|
||||
case INVOICE_FAILED:
|
||||
break;
|
||||
case CHANGE_DISPATCH:
|
||||
reportAccessCodeData();//上傳通行碼資訊到後台系統
|
||||
changeDispatch();
|
||||
break;
|
||||
case CHANGE_BUY:
|
||||
try {
|
||||
SlotStructure slotProduct = MyApp.getInstance().getSlotDataByPid(message);
|
||||
SlotField slotField = SlotField.getSlotField(slotProduct.getField());
|
||||
switch (slotField) {
|
||||
case VMC:
|
||||
if (slotProduct.isLock()) {
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@商品暫停販售");
|
||||
dialogCancel();
|
||||
} else if (slotProduct.getCount() == 0) {
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@商品無存貨");
|
||||
dialogCancel();
|
||||
}
|
||||
|
||||
BuyStructure buyProduct = new BuyStructure(slotProduct.getField(), slotProduct.getSlot(), slotProduct.getProduct(), 1);
|
||||
MyApp.getInstance().getBuyList().add(buyProduct);
|
||||
changeBuy();
|
||||
|
||||
break;
|
||||
case ELECTRIC:
|
||||
if (slotProduct.isLock())
|
||||
MyApp.getInstance().getSlotDataByPid(message).setLock(false);
|
||||
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@解鎖" + slotProduct.getProduct().getProductName());
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", FontendActivity.Option.RESET_DATA.getOption(), "reset data");
|
||||
dialogCancel();
|
||||
|
||||
break;
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
setStatusText("找不到商品");
|
||||
}
|
||||
break;
|
||||
case SET_STATUS_TEXT:
|
||||
setStatusText(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private DialogPickupCodeFontendBinding binding;
|
||||
private CloseDialogOnThreadHandler closeDialogCountdown;
|
||||
private boolean isChangeDispatch;
|
||||
private boolean openBarcode;
|
||||
private HttpAPI httpAPI;
|
||||
|
||||
public FontendPickupCodeDialog(Context context, HandlerMain srcHandlerMain) {
|
||||
super(context, srcHandlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogPickupCodeFontendBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
MyApp.getInstance().initialReportData();
|
||||
|
||||
isChangeDispatch = true;
|
||||
openBarcode = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
|
||||
binding.buttonFinish.setOnClickListener(v -> changeOk());
|
||||
binding.buttonCancel.setOnClickListener(v -> changeBuy());
|
||||
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
binding.buttonR1.setOnClickListener(v -> {
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text.substring(0, text.length()-1));
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.buttonC1.setOnClickListener(v -> {
|
||||
binding.editTextNumber.setText("");
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button10.setOnClickListener(v -> {
|
||||
String btnName = binding.button10.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button11.setOnClickListener(v -> {
|
||||
String btnName = binding.button11.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button12.setOnClickListener(v -> {
|
||||
String btnName = binding.button12.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button13.setOnClickListener(v -> {
|
||||
String btnName = binding.button13.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button14.setOnClickListener(v -> {
|
||||
String btnName = binding.button14.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button15.setOnClickListener(v -> {
|
||||
String btnName = binding.button15.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button16.setOnClickListener(v -> {
|
||||
String btnName = binding.button16.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button17.setOnClickListener(v -> {
|
||||
String btnName = binding.button17.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button18.setOnClickListener(v -> {
|
||||
String btnName = binding.button18.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button19.setOnClickListener(v -> {
|
||||
String btnName = binding.button19.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public class ReadBarCodeOnScheduler extends HandlerMainScheduler {
|
||||
public ReadBarCodeOnScheduler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
try {
|
||||
int totalPrice = 0;
|
||||
for (BuyStructure buy : MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100);
|
||||
for (int i=0; i<7; i++) {
|
||||
if (isRun() && openBarcode) {
|
||||
int size = usbDev.read(buffer, 5000);
|
||||
if (size > 5) {
|
||||
String barCodeString = new String(buffer, 0, size);
|
||||
getHandlerMain().start(getLogs().getClassName(), "收到條碼:"+ Tools.randomReplace(barCodeString, 0.3)+",送出交易");
|
||||
|
||||
try {
|
||||
// TODO: [新後台改接] 待未來新後台啟用後,此處取貨碼驗證需改串接新的 StarCloudAPI B660 介面
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(barCodeString);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException | LogsSettingEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
|
||||
openBarcode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
usbDev.closePort();
|
||||
} catch (LogsUnsupportedOperationException | LogsIOException | LogsEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void changeDispatch() {
|
||||
if (isChangeDispatch) {
|
||||
closeDialogCountdown.shutdown();
|
||||
isChangeDispatch = false;
|
||||
changeDialog(DispatchDialog.class);
|
||||
}
|
||||
}
|
||||
|
||||
public void changeOk() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(number);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void changeBuy() {
|
||||
closeDialogCountdown.shutdown();
|
||||
BuyPickupCodeDialog buyDialog = new BuyPickupCodeDialog(getContext(), getSrcHandlerMain());
|
||||
buyDialog.show();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
closeDialogCountdown.shutdown();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void cancelOnThreadCountdown() {
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("返回("+countdown+")");
|
||||
}
|
||||
|
||||
public void setStatusText(String message) {
|
||||
binding.textStatus.setText(message);
|
||||
}
|
||||
|
||||
public void reportAccessCodeData() {
|
||||
try {
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setField(MyApp.getInstance().getReportAccessCodeStructure().getField());
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setSlot(MyApp.getInstance().getBuyList().get(0).getSlot());
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setProductId(MyApp.getInstance().getReportAccessCodeStructure().getProductId());
|
||||
httpAPI.reportAccessCode(MyApp.getInstance().getReportAccessCodeStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updatePickupCodeData() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(number);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
public CloseDialogOnThreadHandler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
handlerMain.start(getClass().getSimpleName(), CheckoutCashDialog.Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(getSrcCountdown()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.COUNTDOWN_CANCEL.getOption(), "close dialog");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(long countdown, HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(countdown));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,193 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.recycler;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.unibuy.smartdevice.databinding.RecyclerDialogBuyListBinding;
|
||||
import com.unibuy.smartdevice.databinding.RecyclerDialogPickupCodeBuyListBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.Logs;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.BuyDialog;
|
||||
import com.unibuy.smartdevice.ui.tools.ImageGlide;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RecyclerDialogPickupCodeBuyListAdpter extends RecyclerView.Adapter<RecyclerDialogPickupCodeBuyListAdpter.ViewHolder> {
|
||||
private Logs logs;
|
||||
private Context context;
|
||||
private final List<BuyStructure> buyList;
|
||||
private HandlerMain handlerMain;
|
||||
|
||||
public RecyclerDialogPickupCodeBuyListAdpter(Context context, List<BuyStructure> buyList, HandlerMain handlerMain) {
|
||||
this.logs = new Logs(this.getClass());
|
||||
this.context = context;
|
||||
this.buyList = buyList;
|
||||
this.handlerMain = handlerMain;
|
||||
}
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
RecyclerDialogPickupCodeBuyListBinding binding;
|
||||
|
||||
public ViewHolder(@NonNull RecyclerDialogPickupCodeBuyListBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
// 使用 ViewBinding 來綁定佈局
|
||||
RecyclerDialogPickupCodeBuyListBinding binding = RecyclerDialogPickupCodeBuyListBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
return new ViewHolder(binding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
logs.info("position:" + position);
|
||||
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
holder.binding.textPosition.setText(String.valueOf(position + 1));
|
||||
String productID = buyProduct.getProduct().getProductID();
|
||||
if (!productID.isEmpty() && !productID.equals("?")) {
|
||||
holder.binding.textProductName.setText(buyProduct.getProduct().getProductName());
|
||||
} else {
|
||||
holder.binding.textProductName.setText("點我選擇商品");
|
||||
}
|
||||
// holder.binding.textDelete.setOnClickListener(new textDeleteOnClickListener(position));
|
||||
String urlString = buyProduct.getProduct().getProductImg();
|
||||
try {
|
||||
new ImageGlide(context).fileload(urlString, holder.binding.imageProductPicture);
|
||||
} catch (LogsEmptyException e) {
|
||||
this.logs.warning(e);
|
||||
}
|
||||
int machinePrice = buyProduct.getProduct().getMachinePrice();
|
||||
int sellingPrice = buyProduct.getProduct().getSellingPrice();
|
||||
int memberPrice = buyProduct.getProduct().getMemberPrice();
|
||||
|
||||
int slotUpperLimit = buyProduct.getProduct().getSlotUpperLimit();
|
||||
int count = buyProduct.getCount();
|
||||
|
||||
int price = machinePrice > 0? machinePrice: sellingPrice;
|
||||
|
||||
holder.binding.textPrice.setText("$"+price);
|
||||
holder.binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
logs.info("field:" + buyProduct.getField());
|
||||
}
|
||||
|
||||
public class addition1OnClickListener implements View.OnClickListener {
|
||||
private Context context;
|
||||
private RecyclerDialogBuyListBinding binding;
|
||||
private int position;
|
||||
|
||||
public addition1OnClickListener(Context context, RecyclerDialogBuyListBinding binding, int position) {
|
||||
this.context = context;
|
||||
this.binding = binding;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
|
||||
int count = buyProduct.getCount();
|
||||
count++;
|
||||
buyList.get(position).setCount(count);
|
||||
binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_PRICE.getOption(), "total price");
|
||||
}
|
||||
}
|
||||
|
||||
public class subtraction1OnClickListener implements View.OnClickListener {
|
||||
private Context context;
|
||||
private RecyclerDialogBuyListBinding binding;
|
||||
private int position;
|
||||
public subtraction1OnClickListener(Context context, RecyclerDialogBuyListBinding binding, int position) {
|
||||
this.context = context;
|
||||
this.binding = binding;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
|
||||
int count = buyProduct.getCount();
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
buyList.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
notifyItemRangeChanged(position, buyList.size() - position);
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(),"刪除一筆資料");
|
||||
} else {
|
||||
buyList.get(position).setCount(count);
|
||||
binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_PRICE.getOption(),"total price");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class editCountOnEditorActionListener implements TextView.OnEditorActionListener{
|
||||
private ViewHolder holder;
|
||||
private int position;
|
||||
|
||||
public editCountOnEditorActionListener(ViewHolder holder, int position) {
|
||||
this.holder = holder;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
int count = Integer.valueOf(v.getText().toString());
|
||||
buyList.get(position).setCount(count);
|
||||
notifyItemChanged(position);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public class textDeleteOnClickListener implements View.OnClickListener{
|
||||
private int position;
|
||||
|
||||
public textDeleteOnClickListener(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
delProductData(position);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return buyList.size();
|
||||
}
|
||||
|
||||
public void addProductData(BuyStructure buyProduct) {
|
||||
buyList.add(buyProduct);
|
||||
notifyItemInserted(buyList.size() -1);
|
||||
}
|
||||
|
||||
public void delProductData(int position) {
|
||||
buyList.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
}
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/light_gray"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:background="@color/red"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingRight="20dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textVersion"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="start"
|
||||
android:text="20250108ver1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textThreadCount"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="start"
|
||||
android:text="TC:0"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/btnswitchlanTW"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="start"
|
||||
android:text="中"
|
||||
android:visibility="gone"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/white"
|
||||
android:textStyle="bold"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnswitchlanEn"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="start"
|
||||
android:text="En"
|
||||
android:visibility="gone"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/white"
|
||||
android:textStyle="bold"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnswitchlanJa"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="start"
|
||||
android:text="日"
|
||||
android:visibility="gone"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/white"
|
||||
android:textStyle="bold"
|
||||
/>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTemperature"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="0 ℃"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textTime"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="end"
|
||||
android:text="1911/01/01 01:01:01"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textNetwork"
|
||||
android:layout_width="150dp"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical|end"
|
||||
android:text="@string/no_internet"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/relativeLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="60dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:background="@color/black"
|
||||
app:layout_constraintDimensionRatio="18:21"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/videoView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:show_subtitle_button="true"
|
||||
app:use_artwork="true"
|
||||
app:use_controller="false"
|
||||
app:resize_mode="fit"
|
||||
app:surface_type="texture_view" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageAd"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:adjustViewBounds="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
/>
|
||||
</RelativeLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fragment_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/relativeLayout" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/bottom_navigation"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="10dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/buttonVmc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/drinksale"
|
||||
app:cornerRadius="12dp"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
app:strokeColor="@color/gray"
|
||||
android:textSize="38sp"
|
||||
app:strokeWidth="2dp"
|
||||
android:visibility="gone"
|
||||
android:layout_margin="4dp"/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/buttonElectric"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/board_games"
|
||||
app:cornerRadius="12dp"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
app:strokeColor="@color/gray"
|
||||
android:textSize="38sp"
|
||||
android:visibility="gone"
|
||||
app:strokeWidth="2dp"
|
||||
android:layout_margin="4dp"/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/buttonOther"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:visibility="gone"
|
||||
android:text="@string/other"
|
||||
app:cornerRadius="12dp"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
app:strokeColor="@color/gray"
|
||||
android:textSize="38sp"
|
||||
app:strokeWidth="2dp"
|
||||
android:layout_margin="4dp"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/floatButtonPickupCode"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="150dp"
|
||||
android:text="取\n貨\n碼"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="80dp"
|
||||
android:backgroundTint="@color/gray"
|
||||
android:textColor="@color/black"
|
||||
android:alpha="0.8"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/floatButtonShoppingCart"
|
||||
app:layout_constraintEnd_toEndOf="@+id/bottom_navigation"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/floatButtonShoppingCart"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="51dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="80dp"
|
||||
android:contentDescription="TODO"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/gray"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/fragment_container"
|
||||
app:layout_constraintEnd_toEndOf="@+id/bottom_navigation"
|
||||
app:maxImageSize="60dp"
|
||||
app:srcCompat="@drawable/baseline_add_shopping_cart_24" />
|
||||
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTopRight"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="100dp"
|
||||
android:text="進入後台"
|
||||
android:textSize="24sp"
|
||||
android:backgroundTint="@color/red"
|
||||
android:textColor="@android:color/white"
|
||||
android:alpha="1"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="60dp"
|
||||
android:layout_marginEnd="8dp"/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -1,104 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/linearLayout3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/light_gray">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/light_gray"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:background="@color/red"
|
||||
android:gravity="center"
|
||||
android:text="購物車"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:background="@color/white"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1.15"
|
||||
android:gravity="center|end"
|
||||
android:text="合計金額:"
|
||||
android:textColor="@color/red"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textPrice"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_weight="0.85"
|
||||
android:gravity="center|start"
|
||||
android:text="0"
|
||||
android:textColor="@color/red"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerDialogBuyList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="750dp"
|
||||
/>
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMemberTransactions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="340dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:orientation="vertical">
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonCancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/white"
|
||||
android:text="取消(40)"
|
||||
android:textSize="20sp"
|
||||
android:textColor="@color/black" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonGetProduct"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/white"
|
||||
android:text="取貨"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="20sp"
|
||||
android:visibility="visible" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -1,194 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/light_gray">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/light_gray"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:background="@color/red"
|
||||
android:gravity="center"
|
||||
android:text="取貨碼"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:background="@color/white"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textStatus"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="輸入取貨碼 或 掃描QRCode"
|
||||
android:textColor="@color/red"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearMark1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="20dp"
|
||||
android:background="@color/white"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/editTextNumber"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:textSize="20sp"
|
||||
android:hint="取貨碼"
|
||||
android:gravity="center"
|
||||
android:inputType="number" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button10"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="0" />
|
||||
<Button
|
||||
android:id="@+id/button11"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="1" />
|
||||
<Button
|
||||
android:id="@+id/button12"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="2" />
|
||||
<Button
|
||||
android:id="@+id/button13"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="3" />
|
||||
<Button
|
||||
android:id="@+id/button14"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="4" />
|
||||
<Button
|
||||
android:id="@+id/buttonR1"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/light_gray"
|
||||
android:text="←" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button15"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="5" />
|
||||
<Button
|
||||
android:id="@+id/button16"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="6" />
|
||||
<Button
|
||||
android:id="@+id/button17"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="7" />
|
||||
<Button
|
||||
android:id="@+id/button18"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="8" />
|
||||
<Button
|
||||
android:id="@+id/button19"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/black"
|
||||
android:text="9" />
|
||||
<Button
|
||||
android:id="@+id/buttonC1"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/light_gray"
|
||||
android:text="C" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonCancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/white"
|
||||
android:text="返回(40)"
|
||||
android:textSize="20sp"
|
||||
android:textColor="@color/black" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/buttonFinish"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:backgroundTint="@color/white"
|
||||
android:text="輸入完成"
|
||||
android:textSize="20sp"
|
||||
android:textColor="@color/black" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black"
|
||||
tools:context=".ui.devs.vmc.VmcFragment">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerBuy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="500dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<Button
|
||||
android:id="@+id/scanbarcodebtn"
|
||||
android:layout_width="400dp"
|
||||
android:layout_height="400dp"
|
||||
android:layout_marginTop="-50dp"
|
||||
android:padding="10dp"
|
||||
android:text="掃描單據\n按鈕"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="70sp"
|
||||
app:strokeColor="@color/gray"
|
||||
app:strokeWidth="5dp" />
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -1,109 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="1dp"
|
||||
android:paddingBottom="1dp"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:background="@color/white"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textPosition"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:layout_weight="0.3"
|
||||
android:gravity="center"
|
||||
android:text="0"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="visible" />
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="0.5"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageProductPicture"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:adjustViewBounds="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/default_product"
|
||||
app:layout_constraintDimensionRatio="16:9" />
|
||||
</RelativeLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@color/white"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
tools:layout_editor_absoluteY="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@color/white"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textProductName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="商品名稱"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@color/white"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textPrice"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="$0"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/textCount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="x0"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -1,42 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.BuyDialog;
|
||||
|
||||
/**
|
||||
* General (通用版) 銷售流程處理器。
|
||||
* 點選商品/購物車時彈出標準付款 Dialog (BuyDialog)。
|
||||
*/
|
||||
public class SaleFlowHandler {
|
||||
|
||||
protected final Context context;
|
||||
protected final SaleFlowCallback callback;
|
||||
protected final HandlerMain handlerMain;
|
||||
|
||||
public SaleFlowHandler(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
this.context = context;
|
||||
this.handlerMain = handlerMain;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void onSetupUI() {
|
||||
// 通用版 UI 設定,預設無動作
|
||||
}
|
||||
|
||||
public void onSetupUI(android.view.View rootView) {
|
||||
// 通用版 UI 設定,預設無動作
|
||||
}
|
||||
|
||||
public void onBuyRequested() {
|
||||
new BuyDialog(context, handlerMain).show();
|
||||
}
|
||||
|
||||
public void onBarcodeReceived(String rawData) {
|
||||
// 通用版不處理掃碼
|
||||
}
|
||||
|
||||
public void onPickupRequested() {
|
||||
// 通用版不處理取貨碼
|
||||
}
|
||||
}
|
||||
@ -1,591 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.databinding.DialogAccessCodeBinding;
|
||||
import com.unibuy.smartdevice.databinding.DialogPickupCodeFontendBinding;
|
||||
import com.unibuy.smartdevice.devices.DeviceType;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.devices.UsbDev;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.exception.LogsIOException;
|
||||
import com.unibuy.smartdevice.exception.LogsParseException;
|
||||
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
|
||||
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
|
||||
import com.unibuy.smartdevice.external.HttpAPI;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainScheduler;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.FontendActivity;
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import com.unibuy.smartdevice.controller.DevController;
|
||||
import com.unibuy.smartdevice.devices.PortTools;
|
||||
import com.unibuy.smartdevice.ui.dialog.BuyDialog;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class FontendPickupCodeDialog extends DialogAbstract {
|
||||
public enum Option {
|
||||
TOAST(0),
|
||||
COUNTDOWN_CANCEL(1),
|
||||
SET_COUNTDOWN_TEXT(2),
|
||||
INVOICE_SUCCESSFUL(4),
|
||||
INVOICE_FAILED(5),
|
||||
CHANGE_DISPATCH(6),
|
||||
CHANGE_BUY(7),
|
||||
SET_STATUS_TEXT(8),
|
||||
;
|
||||
|
||||
private int option;
|
||||
|
||||
public int getOption() {
|
||||
return option;
|
||||
}
|
||||
|
||||
Option(int option) {
|
||||
this.option = option;
|
||||
}
|
||||
}
|
||||
|
||||
public static final Map<Integer, Option> optionMap = new HashMap<>();
|
||||
static {
|
||||
for (Option option : Option.values()) {
|
||||
optionMap.put(option.getOption(), option);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return getContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<? extends DialogAbstract> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getCtx(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
Option option = optionMap.get(commandCode);
|
||||
|
||||
switch (option) {
|
||||
case TOAST:
|
||||
super.execute(context, commandCode, message);
|
||||
break;
|
||||
case COUNTDOWN_CANCEL:
|
||||
cancelOnThreadCountdown();
|
||||
break;
|
||||
case SET_COUNTDOWN_TEXT:
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case INVOICE_SUCCESSFUL:
|
||||
MyApp.getInstance().getInvoiceData().setCarrier(message);
|
||||
MyApp.getInstance().getReportFlowInfoData().setInvoiceInfo("carrier:" + MyApp.getInstance().getInvoiceData().getCarrier());
|
||||
break;
|
||||
case INVOICE_FAILED:
|
||||
break;
|
||||
case CHANGE_DISPATCH:
|
||||
//reportAccessCodeData();//上傳通行碼資訊到後台系統
|
||||
changeDispatch();
|
||||
break;
|
||||
case CHANGE_BUY:
|
||||
try {
|
||||
getLogs().info("CHANGE_BUY triggered with message: " + message);
|
||||
SlotStructure slotProduct = MyApp.getInstance().getSlotDataByPid(message);
|
||||
if (slotProduct == null) {
|
||||
setStatusText(getContext().getString(R.string.product_not_found));
|
||||
return;
|
||||
}
|
||||
|
||||
getLogs().info("Slot found: " + slotProduct.getSlot() + ", Field: " + slotProduct.getField());
|
||||
SlotField slotField = SlotField.getSlotField(slotProduct.getField());
|
||||
switch (slotField) {
|
||||
case VMC:
|
||||
if (slotProduct.isLock()) {
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@"+getContext().getString(R.string.product_sales_suspended));//商品暫停販售
|
||||
dialogCancel();
|
||||
} else if (slotProduct.getCount() == 0) {
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@"+getContext().getString(R.string.product_out_of_stock));//商品無存貨
|
||||
dialogCancel();
|
||||
} else {
|
||||
// 取貨口檢查已在 onCreate 執行,這裡直接進行
|
||||
BuyStructure buyProduct = new BuyStructure(slotProduct.getField(), slotProduct.getSlot(), slotProduct.getProduct(), 1);
|
||||
MyApp.getInstance().getBuyList().add(buyProduct);
|
||||
changeBuy();
|
||||
}
|
||||
break;
|
||||
case ELECTRIC:
|
||||
if (slotProduct.isLock())
|
||||
MyApp.getInstance().getSlotDataByPid(message).setLock(false);
|
||||
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", "@"+getContext().getString(R.string.unlock) + slotProduct.getProduct().getProductName());//解鎖
|
||||
getSrcHandlerMain().start("FontendPickupCodeDialog", FontendActivity.Option.RESET_DATA.getOption(), "reset data");
|
||||
dialogCancel();
|
||||
|
||||
break;
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
setStatusText(getContext().getString(R.string.product_not_found));//找不到商品
|
||||
} catch (Exception e) {
|
||||
getLogs().info("Error in CHANGE_BUY: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
// 發生未知錯誤時,嘗試繼續或顯示錯誤
|
||||
setStatusText("System Error: " + e.getMessage());
|
||||
}
|
||||
break;
|
||||
case SET_STATUS_TEXT:
|
||||
setStatusText(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private DialogPickupCodeFontendBinding binding;
|
||||
private CloseDialogOnThreadHandler closeDialogCountdown;
|
||||
private boolean isChangeDispatch;
|
||||
private boolean openBarcode;
|
||||
private HttpAPI httpAPI;
|
||||
private boolean haveThings, doorError;
|
||||
|
||||
public FontendPickupCodeDialog(Context context, HandlerMain srcHandlerMain) {
|
||||
super(context, srcHandlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogPickupCodeFontendBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
MyApp.getInstance().initialReportData();
|
||||
|
||||
isChangeDispatch = true;
|
||||
openBarcode = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
|
||||
binding.buttonFinish.setOnClickListener(v -> changeOk());
|
||||
binding.buttonCancel.setOnClickListener(v -> changeBuy());
|
||||
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
binding.buttonR1.setOnClickListener(v -> {
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
if (text.length() > 0) { // ✅ 避免長度為 0 崩潰
|
||||
binding.editTextNumber.setText(text.substring(0, text.length() - 1));
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
}
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.buttonC1.setOnClickListener(v -> {
|
||||
binding.editTextNumber.setText("");
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button10.setOnClickListener(v -> {
|
||||
String btnName = binding.button10.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button11.setOnClickListener(v -> {
|
||||
String btnName = binding.button11.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button12.setOnClickListener(v -> {
|
||||
String btnName = binding.button12.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button13.setOnClickListener(v -> {
|
||||
String btnName = binding.button13.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button14.setOnClickListener(v -> {
|
||||
String btnName = binding.button14.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button15.setOnClickListener(v -> {
|
||||
String btnName = binding.button15.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button16.setOnClickListener(v -> {
|
||||
String btnName = binding.button16.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button17.setOnClickListener(v -> {
|
||||
String btnName = binding.button17.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button18.setOnClickListener(v -> {
|
||||
String btnName = binding.button18.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.button19.setOnClickListener(v -> {
|
||||
String btnName = binding.button19.getText().toString();
|
||||
String text = binding.editTextNumber.getText().toString();
|
||||
binding.editTextNumber.setText(text+btnName);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(5, TimeUnit.SECONDS);
|
||||
|
||||
// 進入頁面時立即檢查取貨口 (使用貨道1作為代表)
|
||||
// callback 為 null,表示檢查通過後不做額外動作,僅在檢查失敗時顯示警告
|
||||
checkSlotHaveProduct(1, 1, null);
|
||||
}
|
||||
|
||||
public class ReadBarCodeOnScheduler extends HandlerMainScheduler {
|
||||
public ReadBarCodeOnScheduler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
try {
|
||||
int totalPrice = 0;
|
||||
for (BuyStructure buy : MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100);
|
||||
for (int i=0; i<7; i++) {
|
||||
if (isRun() && openBarcode) {
|
||||
int size = usbDev.read(buffer, 5000);
|
||||
if (size > 5) {
|
||||
String barCodeString = new String(buffer, 0, size);
|
||||
getHandlerMain().start(getLogs().getClassName(), getCtx().getString(R.string.receive_barcode)+":"+ Tools.randomReplace(barCodeString, 0.3)+","+getCtx().getString(R.string.send_transaction));//收到條碼,送出交易
|
||||
|
||||
try {
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(barCodeString);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException | LogsSettingEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
|
||||
openBarcode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
usbDev.closePort();
|
||||
} catch (LogsUnsupportedOperationException | LogsIOException | LogsEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
totalPrice += buy.getFullPrice();
|
||||
}
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void changeDispatch() {
|
||||
if (isChangeDispatch) {
|
||||
closeDialogCountdown.shutdown();
|
||||
isChangeDispatch = false;
|
||||
changeDialog(DispatchDialog.class);
|
||||
}
|
||||
}
|
||||
|
||||
public void changeOk() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
// 長度檢查
|
||||
if (number.length() < 8) {
|
||||
getHandlerMain().start(getClass().getSimpleName(), "統一編號長度不能少於 8 碼");
|
||||
return;
|
||||
}
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(number);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void changeBuy() {
|
||||
closeDialogCountdown.shutdown();
|
||||
BuyPickupCodeDialog buyDialog = new BuyPickupCodeDialog(getContext(), getSrcHandlerMain());
|
||||
buyDialog.show();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
closeDialogCountdown.shutdown();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void cancelOnThreadCountdown() {
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText(getContext().getString(R.string.return_to)+"("+countdown+")");//返回
|
||||
}
|
||||
|
||||
public void setStatusText(String message) {
|
||||
binding.textStatus.setText(message);
|
||||
}
|
||||
|
||||
public void reportAccessCodeData() {
|
||||
try {
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setField(MyApp.getInstance().getReportAccessCodeStructure().getField());
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setSlot(MyApp.getInstance().getBuyList().get(0).getSlot());
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setProductId(MyApp.getInstance().getReportAccessCodeStructure().getProductId());
|
||||
httpAPI.reportAccessCode(MyApp.getInstance().getReportAccessCodeStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updatePickupCodeData() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductId("0");
|
||||
MyApp.getInstance().getMemberVerificationStructure().setProductPrice(0);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setMemberBarCode(number);
|
||||
MyApp.getInstance().getMemberVerificationStructure().setVerificationType(2);
|
||||
httpAPI.memberVerification(MyApp.getInstance().getMemberVerificationStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
public CloseDialogOnThreadHandler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
handlerMain.start(getClass().getSimpleName(), CashPayDialog.Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(getSrcCountdown()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.COUNTDOWN_CANCEL.getOption(), "close dialog");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(long countdown, HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_COUNTDOWN_TEXT.getOption(), String.valueOf(countdown));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
|
||||
public void checkSlotHaveProduct(int slotnum, int slotcount, Runnable onSafe) {
|
||||
haveThings = false;
|
||||
doorError = false;
|
||||
|
||||
// 使用 AtomicBoolean 防止重複執行 (Timeout vs Callback)
|
||||
// 預設為 false,只要有一方執行過就設為 true,避免重複觸發
|
||||
java.util.concurrent.atomic.AtomicBoolean isHandled = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
|
||||
// 設定 3000ms 超時機制:如果機器沒反應,視為正常並繼續
|
||||
// 增加這段是因為如果 Slot 為空,VMC 可能不會回傳任何資料,導致程式卡住
|
||||
// 但如果 onSafe 為 null (代表僅監控不阻塞流程),則不啟動超時,以免太快判定為安全而蓋過隨後回傳的 Error
|
||||
if (onSafe != null) {
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (!isHandled.getAndSet(true)) {
|
||||
getLogs().info("VMC Check Slot Timeout - Approving Safe (Timeout Safeguard)");
|
||||
onSafe.run();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlotPre(slotnum,slotcount));
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlot(slotnum));
|
||||
MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(new DevCheckSlotByVMC(getHandlerMain(), onSafe, isHandled));
|
||||
}
|
||||
|
||||
public class DevCheckSlotByVMC extends DevController.ReadBufferOnScheduler {
|
||||
private Runnable onSafe;
|
||||
private java.util.concurrent.atomic.AtomicBoolean isHandled;
|
||||
|
||||
public DevCheckSlotByVMC(HandlerMain handlerMain, Runnable onSafe, java.util.concurrent.atomic.AtomicBoolean isHandled) {
|
||||
super(handlerMain);
|
||||
this.onSafe = onSafe;
|
||||
this.isHandled = isHandled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
|
||||
// 每次檢查前先重置門狀態旗標
|
||||
doorError = false;
|
||||
for (byte[] buffer: devController.getReadBufferByMax()) {
|
||||
if (buffer[0] == 0x02) {
|
||||
getLogs().info("XinYuan Read:" + PortTools.showHex(buffer));
|
||||
if (buffer[1] == 0x05) {
|
||||
haveThings = true;
|
||||
break;
|
||||
}else if (buffer[1] == 0x06 || buffer[1] == 0x09) {
|
||||
// 0x06:取貨口門沒關上
|
||||
// 0x09:微波爐取貨口門關閉錯誤
|
||||
getLogs().info("檢測到取貨口門未正常關閉,狀態碼=" + String.format("%02X", buffer[1]));
|
||||
doorError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(haveThings){
|
||||
// 防止 Timeout 後又執行這裡,或者重複執行
|
||||
if (isHandled.getAndSet(true)) return;
|
||||
|
||||
// 使用主執行緒顯示 AlertDialog
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
AlertDialog dialog = new AlertDialog.Builder(getContext())
|
||||
.setTitle(R.string.abnormal_reminder)
|
||||
.setMessage(R.string.there_is_something_at_the_pickup_port)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.i_knew, (d, which) -> {
|
||||
d.dismiss();
|
||||
// 如果有東西,可能需要重置或關閉
|
||||
FontendPickupCodeDialog.this.dialogCancel();
|
||||
})
|
||||
.create();
|
||||
|
||||
dialog.show();
|
||||
|
||||
// 加入 10 秒後自動觸發點擊確定
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (dialog.isShowing()) {
|
||||
dialog.dismiss();
|
||||
FontendPickupCodeDialog.this.dialogCancel();
|
||||
}
|
||||
}, 10_000); // 10 秒(10000 毫秒)
|
||||
});
|
||||
} else if (doorError) {
|
||||
if (isHandled.getAndSet(true)) return;
|
||||
|
||||
// 取貨口門未關閉,提示使用者並阻止後續購買流程
|
||||
handlerMain.start("FontendPickupCodeDialog", "取貨口門未正常關閉,請先確認門已關好");
|
||||
// 使用主執行緒顯示 AlertDialog
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
AlertDialog dialog = new AlertDialog.Builder(getContext())
|
||||
.setTitle(R.string.abnormal_reminder)
|
||||
.setMessage("取貨口門未正常關閉,請先確認門已關好。")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.i_knew, (d, which) -> {
|
||||
d.dismiss();
|
||||
FontendPickupCodeDialog.this.dialogCancel();
|
||||
})
|
||||
.create();
|
||||
|
||||
dialog.show();
|
||||
|
||||
// 加入 10 秒後自動觸發點擊確定
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (dialog.isShowing()) {
|
||||
dialog.dismiss();
|
||||
FontendPickupCodeDialog.this.dialogCancel();
|
||||
}
|
||||
}, 10_000); // 10 秒(10000 毫秒)
|
||||
});
|
||||
} else {
|
||||
// 沒有其他狀況,執行後續動作
|
||||
// 注意:這裡假設 VMC 回傳就代表檢測完成。如果 VMC 會分段回傳,可能需要更嚴謹的判斷。
|
||||
// 但依照原邏輯,只要沒 error 就算 pass
|
||||
if (!isHandled.getAndSet(true)) {
|
||||
if (onSafe != null) {
|
||||
new Handler(Looper.getMainLooper()).post(onSafe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,194 +0,0 @@
|
||||
package com.unibuy.smartdevice.ui.recycler;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.databinding.RecyclerDialogBuyListBinding;
|
||||
import com.unibuy.smartdevice.databinding.RecyclerDialogPickupCodeBuyListBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.Logs;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.BuyDialog;
|
||||
import com.unibuy.smartdevice.ui.tools.ImageGlide;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class RecyclerDialogPickupCodeBuyListAdpter extends RecyclerView.Adapter<RecyclerDialogPickupCodeBuyListAdpter.ViewHolder> {
|
||||
private Logs logs;
|
||||
private Context context;
|
||||
private final List<BuyStructure> buyList;
|
||||
private HandlerMain handlerMain;
|
||||
|
||||
public RecyclerDialogPickupCodeBuyListAdpter(Context context, List<BuyStructure> buyList, HandlerMain handlerMain) {
|
||||
this.logs = new Logs(this.getClass());
|
||||
this.context = context;
|
||||
this.buyList = buyList;
|
||||
this.handlerMain = handlerMain;
|
||||
}
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
RecyclerDialogPickupCodeBuyListBinding binding;
|
||||
|
||||
public ViewHolder(@NonNull RecyclerDialogPickupCodeBuyListBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.binding = binding;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
// 使用 ViewBinding 來綁定佈局
|
||||
RecyclerDialogPickupCodeBuyListBinding binding = RecyclerDialogPickupCodeBuyListBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
return new ViewHolder(binding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
logs.info("position:" + position);
|
||||
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
holder.binding.textPosition.setText(String.valueOf(position));
|
||||
String productID = buyProduct.getProduct().getProductID();
|
||||
if (!productID.isEmpty() && !productID.equals("?")) {
|
||||
holder.binding.textProductName.setText(buyProduct.getProduct().getProductName());
|
||||
} else {
|
||||
holder.binding.textProductName.setText(context.getString(R.string.click_select_product));//
|
||||
}
|
||||
// holder.binding.textDelete.setOnClickListener(new textDeleteOnClickListener(position));
|
||||
String urlString = buyProduct.getProduct().getProductImg();
|
||||
try {
|
||||
new ImageGlide(context).fileload(urlString, holder.binding.imageProductPicture);
|
||||
} catch (LogsEmptyException e) {
|
||||
this.logs.warning(e);
|
||||
}
|
||||
int machinePrice = buyProduct.getProduct().getMachinePrice();
|
||||
int sellingPrice = buyProduct.getProduct().getSellingPrice();
|
||||
int memberPrice = buyProduct.getProduct().getMemberPrice();
|
||||
|
||||
int slotUpperLimit = buyProduct.getProduct().getSlotUpperLimit();
|
||||
int count = buyProduct.getCount();
|
||||
|
||||
int price = machinePrice > 0? machinePrice: sellingPrice;
|
||||
|
||||
holder.binding.textPrice.setText("$"+price);
|
||||
holder.binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
logs.info("field:" + buyProduct.getField());
|
||||
}
|
||||
|
||||
public class addition1OnClickListener implements View.OnClickListener {
|
||||
private Context context;
|
||||
private RecyclerDialogBuyListBinding binding;
|
||||
private int position;
|
||||
|
||||
public addition1OnClickListener(Context context, RecyclerDialogBuyListBinding binding, int position) {
|
||||
this.context = context;
|
||||
this.binding = binding;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
|
||||
int count = buyProduct.getCount();
|
||||
count++;
|
||||
buyList.get(position).setCount(count);
|
||||
binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_PRICE.getOption(), "total price");
|
||||
}
|
||||
}
|
||||
|
||||
public class subtraction1OnClickListener implements View.OnClickListener {
|
||||
private Context context;
|
||||
private RecyclerDialogBuyListBinding binding;
|
||||
private int position;
|
||||
public subtraction1OnClickListener(Context context, RecyclerDialogBuyListBinding binding, int position) {
|
||||
this.context = context;
|
||||
this.binding = binding;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
BuyStructure buyProduct = buyList.get(position);
|
||||
|
||||
int count = buyProduct.getCount();
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
buyList.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
notifyItemRangeChanged(position, buyList.size() - position);
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(),context.getString(R.string.delete_a_record));//刪除一筆資料
|
||||
} else {
|
||||
buyList.get(position).setCount(count);
|
||||
binding.textCount.setText("x"+String.valueOf(count));
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(), BuyDialog.Option.SET_PRICE.getOption(),"total price");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class editCountOnEditorActionListener implements TextView.OnEditorActionListener{
|
||||
private ViewHolder holder;
|
||||
private int position;
|
||||
|
||||
public editCountOnEditorActionListener(ViewHolder holder, int position) {
|
||||
this.holder = holder;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
int count = Integer.valueOf(v.getText().toString());
|
||||
buyList.get(position).setCount(count);
|
||||
notifyItemChanged(position);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public class textDeleteOnClickListener implements View.OnClickListener{
|
||||
private int position;
|
||||
|
||||
public textDeleteOnClickListener(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
delProductData(position);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return buyList.size();
|
||||
}
|
||||
|
||||
public void addProductData(BuyStructure buyProduct) {
|
||||
buyList.add(buyProduct);
|
||||
notifyItemInserted(buyList.size() -1);
|
||||
}
|
||||
|
||||
public void delProductData(int position) {
|
||||
buyList.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
}
|
||||
}
|
||||
@ -14,15 +14,17 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- OTA 以 DownloadManager VISIBILITY_HIDDEN 靜默下載,需此權限否則 enqueue 會丟 Invalid value for visibility: 2 -->
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
|
||||
<application
|
||||
android:name=".MyApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/cpu"
|
||||
android:icon="@drawable/starcloud_logo_circle"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/cpu"
|
||||
android:roundIcon="@drawable/starcloud_logo_circle"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SmartDevice"
|
||||
android:windowSoftInputMode="adjustPan"
|
||||
@ -82,7 +84,10 @@
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.MaintenanceActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.SmartDevice"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.MaintenanceSerialActivity"
|
||||
android:theme="@style/Theme.SmartDevice"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
|
||||
@ -43,8 +43,8 @@ public abstract class AppCompatActivityAbstract extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
// 讀取最後設定語言的設定
|
||||
// LanguageHelper.applySavedLanguage(this);
|
||||
// 讀取最後設定語言的設定(集中在 base,所有 Activity 在 inflate 前都會套用)
|
||||
LanguageHelper.applySavedLanguage(this);
|
||||
|
||||
ctx = setCtx();
|
||||
this.logs = new Logs(setCls());
|
||||
|
||||
@ -6,8 +6,13 @@ public final class AppEntry {
|
||||
private AppEntry() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為「取貨單」購買流程(原 Cmuh 中國醫版)。
|
||||
* 改由雲端 B014 shopping_mode 決定(pickup_sheet),取代舊 project flavor 判定。
|
||||
* 開機時 {@code MyApp.initialSystemSet} 已載入持久化值,故 {@link #initialActivity()} 取得到。
|
||||
*/
|
||||
public static boolean isCmuh() {
|
||||
return "Cmuh".equalsIgnoreCase(BuildConfig.FLAVOR_project);
|
||||
return "pickup_sheet".equals(MyApp.getInstance().getShoppingMode());
|
||||
}
|
||||
|
||||
public static Class<?> initialActivity() {
|
||||
|
||||
@ -88,6 +88,7 @@ public abstract class DialogAbstract extends AlertDialog {
|
||||
// 設定對話框寬度為螢幕的 90%
|
||||
Window window = getWindow();
|
||||
if (window != null) {
|
||||
window.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT));
|
||||
WindowManager.LayoutParams layoutParams = window.getAttributes();
|
||||
// layoutParams.width = (int) (getContext().getResources().getDisplayMetrics().widthPixels * 0.9);
|
||||
|
||||
@ -97,11 +98,11 @@ public abstract class DialogAbstract extends AlertDialog {
|
||||
// 設定高度為包裹內容(可調整為 MATCH_PARENT)
|
||||
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
|
||||
|
||||
// 設定對話框貼齊底部
|
||||
layoutParams.gravity = Gravity.BOTTOM;
|
||||
// 設定對話框居中顯示
|
||||
layoutParams.gravity = Gravity.CENTER;
|
||||
|
||||
window.setAttributes(layoutParams);
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.HIDDEN_MULTILANGUAGE_BUTTON.getOption(), "hidden button");
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.HIDE_SALE_OVERLAY.getOption(), "hide sale overlay");
|
||||
}
|
||||
|
||||
setCancelable(false);
|
||||
@ -144,6 +145,23 @@ public abstract class DialogAbstract extends AlertDialog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析當前 flavor 要使用的現金付款 Dialog 類別。
|
||||
* General 使用強化版 CashPayDialogGeneral,其餘 flavor 使用標準 CashPayDialog。
|
||||
* 透過 SaleFlowHandler.cashDialogClass() 由各 flavor 決定,避免共用碼直接相依 flavor 類別。
|
||||
*/
|
||||
protected Class<? extends DialogAbstract> cashDialogClass() {
|
||||
Context c = getCtx();
|
||||
if (c instanceof FontendActivity) {
|
||||
com.unibuy.smartdevice.ui.SaleFlowHandler handler =
|
||||
((FontendActivity) c).getSaleFlowHandler();
|
||||
if (handler != null) {
|
||||
return handler.cashDialogClass();
|
||||
}
|
||||
}
|
||||
return com.unibuy.smartdevice.ui.dialog.CashPayDialogGeneral.class;
|
||||
}
|
||||
|
||||
|
||||
// public void changeDialog(Class<? extends DialogAbstract> cls) {
|
||||
// try {
|
||||
@ -202,6 +220,77 @@ public abstract class DialogAbstract extends AlertDialog {
|
||||
public void cancel() {
|
||||
super.cancel();
|
||||
// MyApp.getInstance().setShowDialog(false); // 移至 dismiss 統一處理
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.SHOW_MULTILANGUAGE_BUTTON.getOption(), "show button");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -250,6 +250,16 @@ public class HomeActivity extends AppCompatActivityAbstract {
|
||||
binding = ActivityHomeBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// 整頁背景/待機圖(BG0):貼在滿版廣告區(relativeLayout2)背景(貼根版面會被廣告區黑底蓋住)。
|
||||
// 有設 BG0 時,把 videoView 的黑色遮板(shutter)設透明,未播廣告時 BG0 才透得出來;有播廣告則廣告蓋過(預期)。
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.applyBackground("BG0", binding.relativeLayout2);
|
||||
if (com.unibuy.smartdevice.ui.tools.UiSkin.getUrl(this, "BG0") != null) {
|
||||
try {
|
||||
binding.videoView.setShutterBackgroundColor(android.graphics.Color.TRANSPARENT);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
|
||||
binding.startButton.setOnClickListener(v -> {
|
||||
@ -284,7 +294,7 @@ public class HomeActivity extends AppCompatActivityAbstract {
|
||||
videoEoxPlayer = new VideoEoxPlayer(getCtx());
|
||||
|
||||
getHandlerMain().getHandler().postDelayed(() -> {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
if (isFinishing() || isDestroyed() || videoEoxPlayer == null) return; // ✅ 防止延遲執行時播放器已被 stopOnSwitchFragment() 清空導致 NPE
|
||||
try {
|
||||
// videoEoxPlayer.playMedia(binding.videoView, binding.imageAd); // ✅ 同時支援影片與圖片輪播
|
||||
videoEoxPlayer.playMediaByFlag("3", binding.videoView, binding.imageAd); // ✅ 同時支援影片與圖片輪播
|
||||
|
||||
@ -145,6 +145,9 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
showLoginDialog(getCtx(), this::updateLoginStatus);
|
||||
} else {
|
||||
MyApp.getInstance().setSuAccess("");
|
||||
// 登出時一併清除身分與登入 Token,避免唯讀判斷殘留
|
||||
MyApp.getInstance().setIdentity("");
|
||||
MyApp.getInstance().setLoginToken("");
|
||||
updateLoginStatus();
|
||||
Toast.makeText(getCtx(), "已登出後台權限", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
@ -206,6 +209,14 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔒 販賣機機台:導航列沒真的關掉就擋下並提醒(後門長按不受此限)
|
||||
// → 依需求隱藏此提示:進銷售平台不再檢查導航列,直接進入。
|
||||
// (保留原判斷程式碼於此,日後如要恢復把下方三行取消註解即可。)
|
||||
// if (!isNavigationReallyHidden()) {
|
||||
// showMustHideSystemBarsDialog();
|
||||
// return;
|
||||
// }
|
||||
|
||||
enterShoppingPlatform();
|
||||
});
|
||||
|
||||
@ -242,7 +253,9 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
// binding.buttonOtherTest.setOnClickListener(new
|
||||
// ButtonOtherTextOnClick(getHandlerMain()));
|
||||
|
||||
binding.buttonGotoXyshj.setOnClickListener(new ButtonGotoXyshjOnClick(getHandlerMain()));
|
||||
// 「下位機軟體」→ 進入維修模式(釋放串口):原本只是開原廠 app + killProcess,但 kiosk 自動重啟後
|
||||
// 又搶回串口導致原廠軟體讀不到;改為比照 StarApp-LockBoard 的維修模式(持久化旗標釋放串口→維修畫面)。
|
||||
binding.buttonGotoXyshj.setOnClickListener(v -> enterSerialMaintenance());
|
||||
|
||||
binding.buttonClearAds.setOnClickListener(view -> {
|
||||
VideoImageMixedPlayer.clearAdsCache(this);
|
||||
@ -289,6 +302,12 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
});
|
||||
|
||||
// 進後台一律先登入:未登入時第一時間跳出登入視窗(不可取消),輸入正確帳密才能操作主控台;
|
||||
// 按「取消」則直接返回前台(finish)。涵蓋所有進入 MainActivity 的入口(含長按序號的隱藏後門)。
|
||||
if (MyApp.getInstance().getSuAccess().isEmpty()) {
|
||||
showLoginDialog(getCtx(), null, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class ButtonQrCodeTestOnClick extends HandlerMainSchedulerOnClick {
|
||||
@ -386,60 +405,27 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
private class ButtonGotoXyshjOnClick extends HandlerMainSchedulerOnClick {
|
||||
|
||||
public ButtonGotoXyshjOnClick(HandlerMain srcHandlerMain) {
|
||||
super(srcHandlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(View view) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
try {
|
||||
MainActivity.this.getPackageManager().getPackageInfo("com.xyshj.machine", 0);
|
||||
|
||||
Intent it = new Intent("android.intent.action.xinyuan.setting");
|
||||
it.putExtra("showLoginDialog", true);
|
||||
MainActivity.this.startActivity(it);
|
||||
android.os.Process.killProcess(Process.myPid());
|
||||
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
handlerMain.start(getSrcHandlerMain().getLogs().getClassName(), "請先安裝興元管理程式");
|
||||
}
|
||||
|
||||
// List<PackageInfo> packages =
|
||||
// MainActivity.this.getPackageManager().getInstalledPackages(0);
|
||||
// for (PackageInfo packageInfo : packages) {
|
||||
// if ("com.xyshj.machine".equals(packageInfo.packageName)) {
|
||||
// Intent it = new Intent("android.intent.action.xinyuan.setting");
|
||||
// it.putExtra("showLoginDialog", true);
|
||||
// MainActivity.this.startActivity(it);
|
||||
// android.os.Process.killProcess(Process.myPid());
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// handlerMain.start(getSrcHandlerMain().getLogs().getClassName(),
|
||||
// "請先安裝興元管理程式");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
/**
|
||||
* 「下位機軟體」→ 進入維修模式:釋放下位機串口(主櫃 XinYuan)。設持久化旗標 → 即使自動重啟/開機自啟
|
||||
* 也不再佔用串口;並於當前行程立即關閉串口,再導向維修畫面(在維修畫面按「進到下位機維護」開下位機 app)。
|
||||
* (移植自 StarApp-LockBoard)
|
||||
*/
|
||||
private void enterSerialMaintenance() {
|
||||
new android.app.AlertDialog.Builder(this)
|
||||
.setTitle("下位機軟體(維修模式)")
|
||||
.setMessage("將停止本機對下位機的連線並釋放串口,接著進入維修畫面,可於畫面上「進到下位機維護」開啟原廠下位機軟體。\n\n"
|
||||
+ "期間本機無法販售,需於維修畫面按「退出維修模式」才會恢復連線。\n\n確定要進入維修模式嗎?")
|
||||
.setPositiveButton("進入維修模式", (dialog, which) -> {
|
||||
com.unibuy.smartdevice.devices.MaintenanceMode.setSerialReleased(getApplicationContext(), true);
|
||||
MyApp.getInstance().releaseAllDeviceSerialPorts();
|
||||
Intent intent = new Intent(MainActivity.this,
|
||||
com.unibuy.smartdevice.ui.MaintenanceSerialActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
public static class ButtonNfcCheckoutOnClick extends HandlerMainSchedulerOnClick {
|
||||
@ -542,10 +528,18 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
|
||||
public void showLoginDialog(Context context) {
|
||||
showLoginDialog(context, null);
|
||||
showLoginDialog(context, null, false);
|
||||
}
|
||||
|
||||
public void showLoginDialog(Context context, Runnable onSuccess) {
|
||||
showLoginDialog(context, onSuccess, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mandatory true=進後台強制登入:對話框不可取消(返回鍵/點外部無效),按「取消」直接 finish 返回前台,
|
||||
* 未登入不得停留在主控台。false=一般用途(取消僅關閉視窗)。
|
||||
*/
|
||||
public void showLoginDialog(Context context, Runnable onSuccess, boolean mandatory) {
|
||||
// 使用 LayoutInflater 載入自訂佈局
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.dialog_admin_login, null);
|
||||
|
||||
@ -570,6 +564,7 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
// 建立對話框
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setView(view)
|
||||
.setCancelable(!mandatory) // 強制登入時不可用返回鍵/點外部關閉
|
||||
.create();
|
||||
activeLoginDialog = dialog;
|
||||
|
||||
@ -589,9 +584,27 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
if (access.isEmpty() || password.isEmpty()) {
|
||||
getLogs().info("[LoginTrace][" + traceId + "] blocked_empty_input");
|
||||
Toast.makeText(context, "請輸入帳號和密碼", Toast.LENGTH_SHORT).show();
|
||||
} else if (MyApp.getInstance().getMachine().getMachineID().isEmpty()
|
||||
&& "90543814".equals(access) && "1234abcd".equals(password)) {
|
||||
// 未綁定機台無法向雲端驗證帳密(雲端沒有這台的綁定資料,login 一定失敗)→
|
||||
// 提供「預設離線帳密」放行,讓現場人員能進後台完成綁定。綁定後(machineID 非空)
|
||||
// 此分支不成立,即恢復正常雲端帳密驗證。
|
||||
getLogs().info("[LoginTrace][" + traceId + "] unbound_default_login_ok");
|
||||
MyApp.getInstance().setSuAccess(access); // 標記已登入(與雲端成功路徑一致)
|
||||
MyApp.getInstance().setLoginToken("");
|
||||
if (rememberAccountCb.isChecked()) {
|
||||
prefs.edit().putString("saved_account", access).apply();
|
||||
} else {
|
||||
prefs.edit().remove("saved_account").apply();
|
||||
}
|
||||
pendingAction = onSuccess;
|
||||
// 走與雲端相同的成功回調(updateLoginStatus + 關窗 + 跑 pendingAction)
|
||||
getHandlerMain().start("MainActivity", 200, "登入成功");
|
||||
getTools().hideSystemBars((Activity) getCtx());
|
||||
return;
|
||||
} else {
|
||||
Toast.makeText(context, "正在驗證登入...", Toast.LENGTH_SHORT).show();
|
||||
|
||||
|
||||
// 為了處理 StarCloudAPI 登入成功後的回調
|
||||
// 我們重新設計 handlerMain 讓它能夠在 onSuccess 回調中跑 pendingAction
|
||||
pendingAction = onSuccess;
|
||||
@ -616,6 +629,10 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
getTools().hideSystemBars((Activity) getCtx());
|
||||
dialog.dismiss();
|
||||
activeLoginDialog = null;
|
||||
if (mandatory) {
|
||||
// 強制登入下按取消 → 未登入不得進後台,直接返回前台
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
@ -627,12 +644,12 @@ public class MainActivity extends AppCompatActivityAbstract {
|
||||
private void updateLoginStatus() {
|
||||
String suAccess = MyApp.getInstance().getSuAccess();
|
||||
if (suAccess.isEmpty()) {
|
||||
binding.tvWelcomeTitle.setText("歡迎使用 AAT\n智能販賣機系統");
|
||||
binding.tvWelcomeTitle.setText("歡迎使用 Star Cloud\n智能系統");
|
||||
binding.tvWelcomeHint.setText("請點擊右上角登入帳號");
|
||||
binding.btnStatusAction.setText("登入");
|
||||
binding.btnStatusAction.setBackgroundTintList(android.content.res.ColorStateList.valueOf(getResources().getColor(R.color.primary)));
|
||||
} else {
|
||||
binding.tvWelcomeTitle.setText("歡迎使用 AAT\n" + suAccess);
|
||||
binding.tvWelcomeTitle.setText("歡迎使用 Star Cloud\n" + suAccess);
|
||||
binding.tvWelcomeHint.setText("請點擊下方按鈕操作");
|
||||
binding.btnStatusAction.setText("登出");
|
||||
binding.btnStatusAction.setBackgroundTintList(android.content.res.ColorStateList.valueOf(getResources().getColor(R.color.error)));
|
||||
|
||||
@ -32,6 +32,7 @@ import com.unibuy.smartdevice.exception.LogsNoSuchElementException;
|
||||
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
|
||||
import com.unibuy.smartdevice.structure.AccessCodeStructure;
|
||||
import com.unibuy.smartdevice.structure.CashSetStructure;
|
||||
import com.unibuy.smartdevice.structure.HardwareSetStructure;
|
||||
import com.unibuy.smartdevice.structure.CreditCardReceiptStructure;
|
||||
import com.unibuy.smartdevice.structure.FreeGiftStructure;
|
||||
import com.unibuy.smartdevice.structure.MemberVerificationStructure;
|
||||
@ -80,6 +81,9 @@ import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MyApp extends Application {
|
||||
/** 取貨口關閉時間(秒):開機時寫入 VMC 韌體(4.5.11)。取貨倒數為 15 秒,設 17 可讓門開到倒數接近結束才關。range 3-250。 */
|
||||
public static final int PICKUP_DOOR_CLOSE_SECONDS = 17;
|
||||
|
||||
private Logs logs;
|
||||
private static MyApp instance;
|
||||
public static MyApp getInstance() {
|
||||
@ -101,6 +105,7 @@ public class MyApp extends Application {
|
||||
private String cmuhPickupErrorMessage;
|
||||
private Map<String, List<MemoStructure>> memoMap;
|
||||
private CashSetStructure cashSetStructure;
|
||||
private HardwareSetStructure hardwareSetStructure;
|
||||
private AccessCodeStructure accessCodeStructure;
|
||||
private FreeGiftStructure freeGiftStructure;
|
||||
private MemberVerificationStructure memberVerificationStructure;
|
||||
@ -110,6 +115,20 @@ public class MyApp extends Application {
|
||||
private EsunpayStructure esunpay;
|
||||
private MachineStructure machine;
|
||||
private DevSetStructure devSet;
|
||||
// 購買流程模式:basic(多品項購物車)/ employee_card(員工卡)/ pickup_sheet(取貨單),
|
||||
// 由雲端 B014 ShoppingMode 下發,取代舊 project flavor 分家。預設 basic。
|
||||
private String shoppingMode = "basic";
|
||||
// 上傳 log 的寫檔門檻等級。預設 INFO:debug.txt 只留 INFO/WARNING/ERROR/UPDATE,過濾掉 DEBUG/IGNORE 雜訊。
|
||||
// 工程師現場除錯硬體時可設為 DEBUG,讓 hex/協議細節重新寫檔(logcat 一律照印,不受此值影響)。
|
||||
private String logLevel = "INFO";
|
||||
// 機台顯示語系(由雲端 B014 LangSet 下發)。標準標籤(zh-TW/en/...),有序,第 0 個為預設。
|
||||
private java.util.List<String> machineLanguages = new java.util.ArrayList<>();
|
||||
private String defaultLanguage = "";
|
||||
// 商品名稱多語 map(B012 t060v01_i18n),key=商品ID,value=底線代碼→名稱 的 JSONObject。
|
||||
private JSONObject productNameI18nBlob = new JSONObject();
|
||||
private JSONObject productSpecI18nBlob = new JSONObject();
|
||||
// 商品彈簧補貨上限 map(B012 spring_limit),key=商品ID,value=int。履帶上限沿用 products 表 slotUpperLimit。
|
||||
private JSONObject productSpringLimitBlob = new JSONObject();
|
||||
private GreenInvoiceStructure greenInvoice;
|
||||
private GreenpaySettingStructure greenpaySetting;
|
||||
private GreenpayMerchantStructure greenpayMerchant;
|
||||
@ -125,6 +144,10 @@ public class MyApp extends Application {
|
||||
|
||||
public boolean isDetectHost;
|
||||
public String suAccess;
|
||||
// 登入身分(B000 回傳 identity):system=系統方(可編輯系統設定並回寫雲端)/ company / staff(唯讀)。
|
||||
private String identity = "";
|
||||
// B000 登入核發的使用者 Token,供系統設定回寫(B016, auth:sanctum)使用;不覆蓋 machine ApiKey。
|
||||
private String loginToken = "";
|
||||
|
||||
// ─── 全域閒置計時器 ───────────────────────────────────────
|
||||
// 記錄使用者最後一次觸碰螢幕的時間 (ms)
|
||||
@ -192,8 +215,29 @@ public class MyApp extends Application {
|
||||
this.comPort2ByNFCPay = ComPort.COM_PORT_S2;
|
||||
}
|
||||
|
||||
this.devSet = new DevSetStructure(true, false, false, true, true,
|
||||
true, true, false, false,true,true,true,true,true);
|
||||
// DevSet 初始預設值改由 res/values/devset_defaults.xml 提供,
|
||||
// 各 flavor 可覆寫(General override 為「購物車 + 現金/刷卡/掃碼/發票」全開)。
|
||||
android.content.res.Resources devSetRes = getResources();
|
||||
this.devSet = new DevSetStructure(
|
||||
devSetRes.getBoolean(R.bool.devset_default_vmc),
|
||||
devSetRes.getBoolean(R.bool.devset_default_electric),
|
||||
devSetRes.getBoolean(R.bool.devset_default_shopping_cart),
|
||||
devSetRes.getBoolean(R.bool.devset_default_invoice),
|
||||
devSetRes.getBoolean(R.bool.devset_default_nfc_pay),
|
||||
devSetRes.getBoolean(R.bool.devset_default_esun_pay),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay),
|
||||
devSetRes.getBoolean(R.bool.devset_default_cash),
|
||||
devSetRes.getBoolean(R.bool.devset_default_line_pay),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay_30),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay_31),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay_32),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay_33),
|
||||
devSetRes.getBoolean(R.bool.devset_default_tap_pay_34),
|
||||
devSetRes.getBoolean(R.bool.devset_default_pickup_code),
|
||||
devSetRes.getBoolean(R.bool.devset_default_pass_code));
|
||||
// FunctionSet 母開關/來店禮不在建構子參數,建構後補套 bootstrap 預設(雲端 B014 一到即覆蓋)
|
||||
this.devSet.setPickupModule(devSetRes.getBoolean(R.bool.devset_default_pickup_module));
|
||||
this.devSet.setWelcomeGift(devSetRes.getBoolean(R.bool.devset_default_welcome_gift));
|
||||
this.machine = new MachineStructure("");
|
||||
this.esunpay = new EsunpayStructure("", "", "");
|
||||
this.greenInvoice = new GreenInvoiceStructure("", "", "", "");
|
||||
@ -218,6 +262,8 @@ public class MyApp extends Application {
|
||||
"",
|
||||
"");
|
||||
this.cashSetStructure = new CashSetStructure(true, true, true, true, true, true, true);
|
||||
// HardwareSet(貨道型式)預設全 false,由雲端 B014 下發或系統方主控台設定後覆寫。
|
||||
this.hardwareSetStructure = new HardwareSetStructure(false, false, false, false, false, false);
|
||||
this.accessCodeStructure = new AccessCodeStructure("");
|
||||
this.freeGiftStructure = new FreeGiftStructure("");
|
||||
|
||||
@ -234,6 +280,9 @@ public class MyApp extends Application {
|
||||
}
|
||||
|
||||
this.devXinYuanController = new DevXinYuanController(toastHandlerMain);
|
||||
// 維修模式:只建立 controller 物件(避免下游 getDevXinYuanController() NPE),但不 start()、不送
|
||||
// 初始化指令 → 不佔用下位機串口,留給原廠下位機軟體讀取。(移植自 StarApp-LockBoard)
|
||||
if (!com.unibuy.smartdevice.devices.MaintenanceMode.isSerialReleased()) {
|
||||
devXinYuanController.start();
|
||||
|
||||
devXinYuanController.addSendBuffer(devXinYuanController.getDevXinYuan().syncPackNo((byte)0x01));
|
||||
@ -268,6 +317,13 @@ public class MyApp extends Application {
|
||||
devXinYuanController.addSendBuffer(devXinYuanController.getDevXinYuan().setMoneyType((byte) 0x01, (byte) 0x00, (byte) 0x00));
|
||||
devXinYuanController.addSendBuffer(devXinYuanController.getDevXinYuan().setMoneyType((byte) 0x02, (byte) 0x00, (byte) 0x00));
|
||||
|
||||
// 取貨口關閉時間:開機時寫入 VMC 韌體(4.5.11),讓取貨口開啟後維持到出貨畫面倒數接近結束才自動關閉。
|
||||
// 畫面取貨倒數為 15 秒、且取貨口門約在倒數開始前 1~2 秒就已開啟,故設 15 秒可讓門開到倒數剩約 1~2 秒才關,
|
||||
// 同時在多商品時於下一個商品出貨前已關門。range 3-250,主機機櫃號=0。如需微調只改此常數。
|
||||
devXinYuanController.addSendBuffer(
|
||||
devXinYuanController.getDevXinYuan().setPickupDoorCloseTime(0, PICKUP_DOOR_CLOSE_SECONDS));
|
||||
} // end if (!MaintenanceMode.isSerialReleased()):維修模式略過下位機串口啟動與初始化
|
||||
|
||||
initialSettingData();
|
||||
initialSystemSet(getApplicationContext());
|
||||
initialReportData();
|
||||
@ -332,11 +388,57 @@ public class MyApp extends Application {
|
||||
MyApp.getInstance().getDevSet().setDevTapPay(jsonObject.getBoolean("DevTapPay"));
|
||||
MyApp.getInstance().getDevSet().setDevCash(jsonObject.getBoolean("DevCash"));
|
||||
MyApp.getInstance().getDevSet().setDevLinePay(jsonObject.getBoolean("DevLinePay"));
|
||||
// TapPay30~34 與取貨碼/通行碼:用 optBoolean 容忍舊紀錄缺 key,避免整段載入中斷
|
||||
MyApp.getInstance().getDevSet().setTapPay30(jsonObject.optBoolean("TapPay30", false));
|
||||
MyApp.getInstance().getDevSet().setTapPay31(jsonObject.optBoolean("TapPay31", false));
|
||||
MyApp.getInstance().getDevSet().setTapPay32(jsonObject.optBoolean("TapPay32", false));
|
||||
MyApp.getInstance().getDevSet().setTapPay33(jsonObject.optBoolean("TapPay33", false));
|
||||
MyApp.getInstance().getDevSet().setTapPay34(jsonObject.optBoolean("TapPay34", false));
|
||||
MyApp.getInstance().getDevSet().setPickupCode(jsonObject.optBoolean("PickupCode", false));
|
||||
MyApp.getInstance().getDevSet().setPassCode(jsonObject.optBoolean("PassCode", false));
|
||||
// B014 新增開關(DevSet 細項 + FunctionSet 模組),optBoolean 容忍舊紀錄缺 key
|
||||
MyApp.getInstance().getDevSet().setDevCreditCard(jsonObject.optBoolean("DevCreditCard", false));
|
||||
MyApp.getInstance().getDevSet().setDevMobilePay(jsonObject.optBoolean("DevMobilePay", false));
|
||||
MyApp.getInstance().getDevSet().setDevCardPay(jsonObject.optBoolean("DevCardPay", false));
|
||||
MyApp.getInstance().getDevSet().setDevScanPay(jsonObject.optBoolean("DevScanPay", false));
|
||||
MyApp.getInstance().getDevSet().setPickupModule(jsonObject.optBoolean("PickupModule", false));
|
||||
MyApp.getInstance().getDevSet().setPharmacyPickup(jsonObject.optBoolean("PharmacyPickup", false));
|
||||
MyApp.getInstance().getDevSet().setWelcomeGift(jsonObject.optBoolean("WelcomeGift", false));
|
||||
MyApp.getInstance().getDevSet().setMemberSystem(jsonObject.optBoolean("MemberSystem", false));
|
||||
MyApp.getInstance().getDevSet().setAmbientTemp(jsonObject.optBoolean("AmbientTemp", false));
|
||||
MyApp.getInstance().setShoppingMode(jsonObject.optString("ShoppingMode", "basic"));
|
||||
MyApp.getInstance().setLogLevel(jsonObject.optString("LogLevel", "INFO"));
|
||||
// 機台顯示語系(B014 LangSet)
|
||||
java.util.List<String> langs = new java.util.ArrayList<>();
|
||||
JSONArray langArr = jsonObject.optJSONArray("MachineLanguages");
|
||||
if (langArr != null) {
|
||||
for (int i = 0; i < langArr.length(); i++) {
|
||||
String l = langArr.optString(i, "");
|
||||
if (l != null && !l.isEmpty()) langs.add(l);
|
||||
}
|
||||
}
|
||||
MyApp.getInstance().setMachineLanguages(langs);
|
||||
MyApp.getInstance().setDefaultLanguage(jsonObject.optString("DefaultLanguage", ""));
|
||||
} catch (JSONException e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 商品名稱多語 blob(B012 t060v01_i18n),離線重啟時供 fallback
|
||||
if (memoDao.existOnlyOne("ProductNameI18n")) {
|
||||
MyApp.getInstance().setProductNameI18nBlob(memoDao.getOnlyOne("ProductNameI18n").getData());
|
||||
}
|
||||
|
||||
// 商品規格多語 blob(B012 t060v03_i18n),離線重啟時供 fallback
|
||||
if (memoDao.existOnlyOne("ProductSpecI18n")) {
|
||||
MyApp.getInstance().setProductSpecI18nBlob(memoDao.getOnlyOne("ProductSpecI18n").getData());
|
||||
}
|
||||
|
||||
// 商品彈簧補貨上限 blob(B012 spring_limit),離線重啟時供一鍵補貨 fallback
|
||||
if (memoDao.existOnlyOne("ProductSpringLimit")) {
|
||||
MyApp.getInstance().setProductSpringLimitBlob(memoDao.getOnlyOne("ProductSpringLimit").getData());
|
||||
}
|
||||
|
||||
if (memoDao.existOnlyOne(MyApp.getInstance().getCashSetStructure().getClass().getSimpleName())) {
|
||||
JSONObject jsonObject = memoDao.getOnlyOne(MyApp.getInstance().getCashSetStructure().getClass().getSimpleName()).getData();
|
||||
logs.debug("initialSystemSet:" + jsonObject.toString());
|
||||
@ -352,6 +454,22 @@ public class MyApp extends Application {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (memoDao.existOnlyOne(MyApp.getInstance().getHardwareSetStructure().getClass().getSimpleName())) {
|
||||
JSONObject jsonObject = memoDao.getOnlyOne(MyApp.getInstance().getHardwareSetStructure().getClass().getSimpleName()).getData();
|
||||
logs.debug("initialSystemSet:" + jsonObject.toString());
|
||||
try {
|
||||
HardwareSetStructure h = MyApp.getInstance().getHardwareSetStructure();
|
||||
h.setSpringSlot1_10(jsonObject.optBoolean("SpringSlot1_10", false));
|
||||
h.setSpringSlot11_20(jsonObject.optBoolean("SpringSlot11_20", false));
|
||||
h.setSpringSlot21_30(jsonObject.optBoolean("SpringSlot21_30", false));
|
||||
h.setSpringSlot31_40(jsonObject.optBoolean("SpringSlot31_40", false));
|
||||
h.setSpringSlot41_50(jsonObject.optBoolean("SpringSlot41_50", false));
|
||||
h.setSpringSlot51_60(jsonObject.optBoolean("SpringSlot51_60", false));
|
||||
} catch (Exception e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void saveSystemSet(Context context) {
|
||||
MemoDao memoDao = new MemoDao(context);
|
||||
@ -367,6 +485,26 @@ public class MyApp extends Application {
|
||||
systemSetByJsonObject.put("DevTapPay", MyApp.getInstance().getDevSet().isDevTapPay());
|
||||
systemSetByJsonObject.put("DevCash", MyApp.getInstance().getDevSet().isDevCash());
|
||||
systemSetByJsonObject.put("DevLinePay", MyApp.getInstance().getDevSet().isDevLinePay());
|
||||
systemSetByJsonObject.put("TapPay30", MyApp.getInstance().getDevSet().isTapPay30());
|
||||
systemSetByJsonObject.put("TapPay31", MyApp.getInstance().getDevSet().isTapPay31());
|
||||
systemSetByJsonObject.put("TapPay32", MyApp.getInstance().getDevSet().isTapPay32());
|
||||
systemSetByJsonObject.put("TapPay33", MyApp.getInstance().getDevSet().isTapPay33());
|
||||
systemSetByJsonObject.put("TapPay34", MyApp.getInstance().getDevSet().isTapPay34());
|
||||
systemSetByJsonObject.put("PickupCode", MyApp.getInstance().getDevSet().isPickupCode());
|
||||
systemSetByJsonObject.put("PassCode", MyApp.getInstance().getDevSet().isPassCode());
|
||||
systemSetByJsonObject.put("DevCreditCard", MyApp.getInstance().getDevSet().isDevCreditCard());
|
||||
systemSetByJsonObject.put("DevMobilePay", MyApp.getInstance().getDevSet().isDevMobilePay());
|
||||
systemSetByJsonObject.put("DevCardPay", MyApp.getInstance().getDevSet().isDevCardPay());
|
||||
systemSetByJsonObject.put("DevScanPay", MyApp.getInstance().getDevSet().isDevScanPay());
|
||||
systemSetByJsonObject.put("PickupModule", MyApp.getInstance().getDevSet().isPickupModule());
|
||||
systemSetByJsonObject.put("PharmacyPickup", MyApp.getInstance().getDevSet().isPharmacyPickup());
|
||||
systemSetByJsonObject.put("WelcomeGift", MyApp.getInstance().getDevSet().isWelcomeGift());
|
||||
systemSetByJsonObject.put("MemberSystem", MyApp.getInstance().getDevSet().isMemberSystem());
|
||||
systemSetByJsonObject.put("AmbientTemp", MyApp.getInstance().getDevSet().isAmbientTemp());
|
||||
systemSetByJsonObject.put("ShoppingMode", MyApp.getInstance().getShoppingMode());
|
||||
systemSetByJsonObject.put("LogLevel", MyApp.getInstance().getLogLevel());
|
||||
systemSetByJsonObject.put("MachineLanguages", new JSONArray(MyApp.getInstance().getMachineLanguages()));
|
||||
systemSetByJsonObject.put("DefaultLanguage", MyApp.getInstance().getDefaultLanguage());
|
||||
|
||||
JSONObject cashSetByJsonObject = new JSONObject();
|
||||
cashSetByJsonObject.put("BillF1000", MyApp.getInstance().getCashSetStructure().isBillF1000());
|
||||
@ -377,6 +515,14 @@ public class MyApp extends Application {
|
||||
cashSetByJsonObject.put("CoinD5", MyApp.getInstance().getCashSetStructure().isCoinD5());
|
||||
cashSetByJsonObject.put("CoinC1", MyApp.getInstance().getCashSetStructure().isCoinC1());
|
||||
|
||||
JSONObject hardwareSetByJsonObject = new JSONObject();
|
||||
hardwareSetByJsonObject.put("SpringSlot1_10", MyApp.getInstance().getHardwareSetStructure().isSpringSlot1_10());
|
||||
hardwareSetByJsonObject.put("SpringSlot11_20", MyApp.getInstance().getHardwareSetStructure().isSpringSlot11_20());
|
||||
hardwareSetByJsonObject.put("SpringSlot21_30", MyApp.getInstance().getHardwareSetStructure().isSpringSlot21_30());
|
||||
hardwareSetByJsonObject.put("SpringSlot31_40", MyApp.getInstance().getHardwareSetStructure().isSpringSlot31_40());
|
||||
hardwareSetByJsonObject.put("SpringSlot41_50", MyApp.getInstance().getHardwareSetStructure().isSpringSlot41_50());
|
||||
hardwareSetByJsonObject.put("SpringSlot51_60", MyApp.getInstance().getHardwareSetStructure().isSpringSlot51_60());
|
||||
|
||||
memoDao.insertOnlyOne(new MemoStructure(
|
||||
MyApp.getInstance().getDevSet().getClass().getSimpleName(),
|
||||
systemSetByJsonObject
|
||||
@ -394,11 +540,62 @@ public class MyApp extends Application {
|
||||
memoDao.mapUpdate();
|
||||
|
||||
new Logs(MyApp.class).info("saveSystemSet count:" + memoDao.getCount(MyApp.getInstance().getCashSetStructure().getClass().getSimpleName()));
|
||||
|
||||
memoDao.insertOnlyOne(new MemoStructure(
|
||||
MyApp.getInstance().getHardwareSetStructure().getClass().getSimpleName(),
|
||||
hardwareSetByJsonObject
|
||||
, new JSONObject()));
|
||||
|
||||
memoDao.mapUpdate();
|
||||
|
||||
new Logs(MyApp.class).info("saveSystemSet count:" + memoDao.getCount(MyApp.getInstance().getHardwareSetStructure().getClass().getSimpleName()));
|
||||
} catch (JSONException e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化商品名稱多語 blob(不動 products 表 schema,存於 MemoDao),供離線重啟 fallback
|
||||
public static void saveProductNameI18n(Context context) {
|
||||
MemoDao memoDao = new MemoDao(context);
|
||||
try {
|
||||
memoDao.insertOnlyOne(new MemoStructure(
|
||||
"ProductNameI18n",
|
||||
MyApp.getInstance().getProductNameI18nBlob(),
|
||||
new JSONObject()));
|
||||
memoDao.mapUpdate();
|
||||
} catch (Exception e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化商品規格多語 blob(不動 products 表 schema,存於 MemoDao),供離線重啟 fallback
|
||||
public static void saveProductSpecI18n(Context context) {
|
||||
MemoDao memoDao = new MemoDao(context);
|
||||
try {
|
||||
memoDao.insertOnlyOne(new MemoStructure(
|
||||
"ProductSpecI18n",
|
||||
MyApp.getInstance().getProductSpecI18nBlob(),
|
||||
new JSONObject()));
|
||||
memoDao.mapUpdate();
|
||||
} catch (Exception e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化商品彈簧補貨上限 blob(不動 products 表 schema,存於 MemoDao),供離線重啟一鍵補貨 fallback
|
||||
public static void saveProductSpringLimit(Context context) {
|
||||
MemoDao memoDao = new MemoDao(context);
|
||||
try {
|
||||
memoDao.insertOnlyOne(new MemoStructure(
|
||||
"ProductSpringLimit",
|
||||
MyApp.getInstance().getProductSpringLimitBlob(),
|
||||
new JSONObject()));
|
||||
memoDao.mapUpdate();
|
||||
} catch (Exception e) {
|
||||
new Logs(MyApp.class).warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void updateLog(Context context) {
|
||||
OneTimeWorkRequest oneTimeWorkRequest = new OneTimeWorkRequest.Builder(UploadLogOneTimeWorker.class)
|
||||
.setInitialDelay(5, TimeUnit.SECONDS)
|
||||
@ -415,6 +612,11 @@ public class MyApp extends Application {
|
||||
boolean isAlive = false;
|
||||
Logs staticLogs = new Logs(MyApp.class);
|
||||
|
||||
// 維修模式:串口已釋放給下位機軟體,不可自動重連(否則會再度搶回串口)。(移植自 StarApp-LockBoard)
|
||||
if (com.unibuy.smartdevice.devices.MaintenanceMode.isSerialReleased()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (devXinYuanController != null) {
|
||||
try {
|
||||
// 發送同步包測試
|
||||
@ -433,10 +635,22 @@ public class MyApp extends Application {
|
||||
// 若不活躍則重連
|
||||
if (!isAlive) {
|
||||
staticLogs.info("下位機失連,重新連線中...");
|
||||
HandlerMain oldHandlerMain = devXinYuanController != null
|
||||
? devXinYuanController.getHandlerMain() : null;
|
||||
if (devXinYuanController != null) {
|
||||
devXinYuanController.shutdown();
|
||||
// ★ 必須用 releasePort()(setRun(false)+closePort()+shutdown())完整關閉舊控制器,
|
||||
// 讓舊的讀取 while(isRun()) 執行緒真正跳出、釋放 ttyS7。
|
||||
// 之前只呼叫 shutdown()(僅停排程器),舊讀取執行緒還活著、舊串口還開著,
|
||||
// 接著又 new DevXinYuanController() 把同一個 ttyS7 再開一次 → 兩條執行緒搶讀同一串口
|
||||
// → 位元組框架被撕裂、VMC_41_POLL 認不出、出貨指令送不出去 → 出貨失敗(VMC_52 洪水即此徵兆)。
|
||||
devXinYuanController.releasePort();
|
||||
}
|
||||
devXinYuanController = new DevXinYuanController(devXinYuanController.getHandlerMain());
|
||||
// 給舊讀取執行緒一點時間因 closePort() 拋例外跳出迴圈、釋放串口,再重開新串口,避免搶佔。
|
||||
try {
|
||||
Thread.sleep(150);
|
||||
} catch (InterruptedException ignore) {
|
||||
}
|
||||
devXinYuanController = new DevXinYuanController(oldHandlerMain);
|
||||
devXinYuanController.start();
|
||||
devXinYuanController.addSendBuffer(
|
||||
devXinYuanController.getDevXinYuan().syncPackNo((byte) 0x01)
|
||||
@ -450,6 +664,85 @@ public class MyApp extends Application {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 維修模式:立即釋放下位機串口(主櫃 XinYuan),讓原廠下位機軟體可讀取。呼叫端須先設好
|
||||
* MaintenanceMode.setSerialReleased(true)。搭配持久化旗標,後續自動重啟/開機自啟也不再佔用串口,
|
||||
* 直到手動退出維修模式。(移植自 StarApp-LockBoard;本機為 XinYuan 單下位機,無副櫃鎖控板)
|
||||
*/
|
||||
public synchronized void releaseAllDeviceSerialPorts() {
|
||||
logs.info("[維修模式] 釋放下位機串口(主櫃 XinYuan)");
|
||||
if (devXinYuanController != null) {
|
||||
devXinYuanController.releasePort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 進支付前用:送同步包確認主櫃(XinYuan)下位機是否真的有回應。
|
||||
* 先清掉舊 buffer 再送,避免讀到先前殘留的回應誤判為已連線。
|
||||
* 注意:會 Thread.sleep,請在背景執行緒呼叫。
|
||||
*/
|
||||
private static boolean pingXinYuanOnce(long waitMs) {
|
||||
if (devXinYuanController == null) return false;
|
||||
try {
|
||||
devXinYuanController.clearReadBuffers();
|
||||
devXinYuanController.addSendBuffer(
|
||||
devXinYuanController.getDevXinYuan().syncPackNo((byte) 0x01));
|
||||
Thread.sleep(waitMs);
|
||||
return !devXinYuanController.getReadBufferByMax().isEmpty();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下位機是否在線。優先採信「近期任何 VMC 通訊」——下位機為輪詢式主從、持續在 POLL,
|
||||
* 只要近 4 秒收過任何 VMC 幀(POLL/ACK/廣播…)即代表在線,直接判定連線,
|
||||
* 避免脆弱的 300ms 主動 ping 在機台忙碌/送出佇列消化時收不到即時回應而誤判「尚未連線」。
|
||||
* 近期完全沒通訊時,才主動 ping 兩次(各 500ms)確認(降低偶發誤判,單次沒回不代表失連)。
|
||||
*/
|
||||
public static boolean isXinYuanAlive() {
|
||||
if (com.unibuy.smartdevice.controller.DevXinYuanController.isRecentlyAlive(4000)) return true;
|
||||
return pingXinYuanOnce(500) || pingXinYuanOnce(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 進支付前用:確認主櫃下位機連線;若失連則主動重連後再確認。
|
||||
* 回傳最終是否連線成功(false=連不上,呼叫端應擋住不進支付)。
|
||||
* 注意:會 Thread.sleep,請在背景執行緒呼叫。
|
||||
*/
|
||||
public static boolean ensureXinYuanConnected() {
|
||||
if (isXinYuanAlive()) return true;
|
||||
checkAndReconnectDev(); // 主動重連
|
||||
return isXinYuanAlive(); // 重連後再確認
|
||||
}
|
||||
|
||||
/**
|
||||
* 出貨當下用:出貨前最後一道保險,確認主櫃(XinYuan)下位機連線;
|
||||
* 失連就「反覆重連並等到活著」直到成功或逾時 maxWaitMs。
|
||||
* 與進支付前的 ensureXinYuanConnected 不同:這裡會多輪重連(每輪等重開串口/重啟讀取迴圈),
|
||||
* 因為下位機常在重啟後短暫失聯,單輪重連可能還沒起來,需持續重試到真的活著才送出貨指令。
|
||||
* 回傳最終是否連線成功。注意:含 Thread.sleep,請在背景執行緒呼叫。
|
||||
*/
|
||||
public static boolean ensureXinYuanConnectedForDispatch(long maxWaitMs) {
|
||||
Logs staticLogs = new Logs(MyApp.class);
|
||||
if (isXinYuanAlive()) return true;
|
||||
long deadline = System.currentTimeMillis() + maxWaitMs;
|
||||
int round = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
round++;
|
||||
staticLogs.info("出貨前下位機失連,第 " + round + " 次自動重連...");
|
||||
checkAndReconnectDev(); // 重建 controller、重開串口、重啟讀取迴圈
|
||||
try { Thread.sleep(800); } catch (InterruptedException ignore) {}
|
||||
if (isXinYuanAlive()) {
|
||||
staticLogs.info("出貨前重連成功(第 " + round + " 輪),下位機已連線");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
boolean alive = isXinYuanAlive();
|
||||
staticLogs.info("出貨前重連結束,逾時=" + maxWaitMs + "ms,最終連線=" + alive);
|
||||
return alive;
|
||||
}
|
||||
|
||||
public void initMqtt() {
|
||||
String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
String apiKey = MyApp.getInstance().getMachine().getApiKey();
|
||||
@ -680,7 +973,7 @@ public class MyApp extends Application {
|
||||
int i = 0;
|
||||
for (SlotStructure slotData: getSlotList(field)) {
|
||||
i += 1;
|
||||
logs.info("AAAAAAAAAAAAAAAAAAAAAAAAAAAA0"+i);
|
||||
logs.debug("slot index:" + i);
|
||||
if (slotData.getProduct().getProductID().equals(String.valueOf(pid))) {
|
||||
if(slotData.getCount() > 0) {
|
||||
return slotData;
|
||||
@ -703,6 +996,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;
|
||||
}
|
||||
@ -872,6 +1237,82 @@ public class MyApp extends Application {
|
||||
return devSet;
|
||||
}
|
||||
|
||||
public String getShoppingMode() {
|
||||
return shoppingMode;
|
||||
}
|
||||
|
||||
public void setShoppingMode(String shoppingMode) {
|
||||
this.shoppingMode = (shoppingMode == null || shoppingMode.isEmpty()) ? "basic" : shoppingMode;
|
||||
}
|
||||
|
||||
public String getLogLevel() {
|
||||
return logLevel;
|
||||
}
|
||||
|
||||
public void setLogLevel(String logLevel) {
|
||||
this.logLevel = (logLevel == null || logLevel.isEmpty()) ? "INFO" : logLevel.toUpperCase(java.util.Locale.US);
|
||||
}
|
||||
|
||||
public java.util.List<String> getMachineLanguages() {
|
||||
return machineLanguages;
|
||||
}
|
||||
|
||||
public void setMachineLanguages(java.util.List<String> machineLanguages) {
|
||||
this.machineLanguages = (machineLanguages == null) ? new java.util.ArrayList<>() : machineLanguages;
|
||||
}
|
||||
|
||||
public String getDefaultLanguage() {
|
||||
return defaultLanguage;
|
||||
}
|
||||
|
||||
public void setDefaultLanguage(String defaultLanguage) {
|
||||
this.defaultLanguage = (defaultLanguage == null) ? "" : defaultLanguage;
|
||||
}
|
||||
|
||||
public void setProductNameI18nBlob(JSONObject blob) {
|
||||
this.productNameI18nBlob = (blob == null) ? new JSONObject() : blob;
|
||||
}
|
||||
|
||||
public JSONObject getProductNameI18nBlob() {
|
||||
return productNameI18nBlob;
|
||||
}
|
||||
|
||||
// 取某商品的名稱多語 JSON 字串(底線代碼 key),無則回 null。供 ProductStructure 離線時 fallback。
|
||||
public String getProductNameI18nJson(String productId) {
|
||||
if (productId == null || productNameI18nBlob == null) return null;
|
||||
JSONObject obj = productNameI18nBlob.optJSONObject(productId);
|
||||
return (obj == null) ? null : obj.toString();
|
||||
}
|
||||
|
||||
public void setProductSpecI18nBlob(JSONObject blob) {
|
||||
this.productSpecI18nBlob = (blob == null) ? new JSONObject() : blob;
|
||||
}
|
||||
|
||||
public JSONObject getProductSpecI18nBlob() {
|
||||
return productSpecI18nBlob;
|
||||
}
|
||||
|
||||
// 取某商品的規格多語 JSON 字串(底線代碼 key),無則回 null。供 ProductStructure 離線時 fallback。
|
||||
public String getProductSpecI18nJson(String productId) {
|
||||
if (productId == null || productSpecI18nBlob == null) return null;
|
||||
JSONObject obj = productSpecI18nBlob.optJSONObject(productId);
|
||||
return (obj == null) ? null : obj.toString();
|
||||
}
|
||||
|
||||
public void setProductSpringLimitBlob(JSONObject blob) {
|
||||
this.productSpringLimitBlob = (blob == null) ? new JSONObject() : blob;
|
||||
}
|
||||
|
||||
public JSONObject getProductSpringLimitBlob() {
|
||||
return productSpringLimitBlob;
|
||||
}
|
||||
|
||||
// 取某商品的彈簧補貨上限;blob 無此商品時回 -1(由 ProductStructure 退回履帶上限)。
|
||||
public int getProductSpringLimit(String productId) {
|
||||
if (productId == null || productSpringLimitBlob == null) return -1;
|
||||
return productSpringLimitBlob.optInt(productId, -1);
|
||||
}
|
||||
|
||||
|
||||
public ReportAccessCodeStructure getReportAccessCodeStructure() {
|
||||
return reportAccessCodeStructure;
|
||||
@ -885,6 +1326,10 @@ public class MyApp extends Application {
|
||||
return cashSetStructure;
|
||||
}
|
||||
|
||||
public HardwareSetStructure getHardwareSetStructure() {
|
||||
return hardwareSetStructure;
|
||||
}
|
||||
|
||||
public AccessCodeStructure getAccessCodeStructure() {
|
||||
return accessCodeStructure;
|
||||
}
|
||||
@ -940,12 +1385,16 @@ public class MyApp extends Application {
|
||||
*/
|
||||
public String getBusyReason() {
|
||||
StringBuilder reason = new StringBuilder();
|
||||
if (isShowDialog()) {
|
||||
reason.append("[Dialog Open] ");
|
||||
}
|
||||
if (!getBuyList().isEmpty()) {
|
||||
reason.append("[Cart not empty: ").append(getBuyList().size()).append(" items] ");
|
||||
// 只計入「忙碌型」對話框(付款/出貨等 isBusyDialog()==true)。
|
||||
// 純瀏覽的商品詳情(isBusyDialog()==false)不算忙碌,否則它一旦被 HID 誤觸卡住,
|
||||
// 就會讓機台長期 [Dialog Open] → 待機看門狗略過、遠端重啟被拒(Machine busy)。
|
||||
if (DialogAbstract.hasBusyDialogShowing()) {
|
||||
reason.append("[Busy Dialog Open] ");
|
||||
}
|
||||
// 注意:購物車非空「不可」計入忙碌。否則待機/重啟會與清車死結——
|
||||
// resetIdleTimer 忙碌即不待機並重新計時,但清空購物車的程式就寫在待機那一支,
|
||||
// 購物車有殘留 → 永遠忙碌 → 永遠不待機 → 永遠清不掉。真正「進行中」狀態
|
||||
// 已由忙碌型對話框(付款/出貨)與 isNfcPaymentActive(感應支付)涵蓋。
|
||||
if (isNfcPaymentActive()) {
|
||||
reason.append("[NFC Payment Active] ");
|
||||
}
|
||||
@ -965,6 +1414,27 @@ public class MyApp extends Application {
|
||||
this.suAccess = suAccess;
|
||||
}
|
||||
|
||||
public String getIdentity() {
|
||||
return identity == null ? "" : identity;
|
||||
}
|
||||
|
||||
public void setIdentity(String identity) {
|
||||
this.identity = identity == null ? "" : identity;
|
||||
}
|
||||
|
||||
/** 是否為系統方(可編輯系統設定並回寫雲端)。 */
|
||||
public boolean isSystemIdentity() {
|
||||
return "system".equals(getIdentity());
|
||||
}
|
||||
|
||||
public String getLoginToken() {
|
||||
return loginToken == null ? "" : loginToken;
|
||||
}
|
||||
|
||||
public void setLoginToken(String loginToken) {
|
||||
this.loginToken = loginToken == null ? "" : loginToken;
|
||||
}
|
||||
|
||||
public boolean isDetectHost() {
|
||||
return isDetectHost;
|
||||
}
|
||||
|
||||
@ -24,9 +24,12 @@ public abstract class DevController extends HandlerMainScheduler {
|
||||
if (this.isRun()) {
|
||||
logs.info("sendBuffers max:" + sendBuffers.size());
|
||||
|
||||
if (sendBuffers.size() > 50) {
|
||||
setRun(false);
|
||||
return;
|
||||
// 過載保護:佇列堆積過多時「丟棄最舊的堆積指令」(load shedding),而非停掉讀取迴圈。
|
||||
// 舊作法 setRun(false) 會把下位機讀取迴圈整個停掉 → 永久失連、且無法自行恢復
|
||||
//(連續查詢後卡「未連線」當掉的主因)。改為丟舊留新:確保剛送的新指令(如 checkSlot)仍能送出,
|
||||
// 讀取迴圈持續運作、過載一緩解即自動恢復。
|
||||
while (sendBuffers.size() > 50) {
|
||||
sendBuffers.remove(0);
|
||||
}
|
||||
|
||||
sendBuffers.add(buffer);
|
||||
|
||||
@ -151,30 +151,17 @@ public class DevNFCPayController extends HandlerMainScheduler {
|
||||
getLogs().info("電文接收:" + readData);
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowRequestInfo(readData);
|
||||
|
||||
while (isRun() && MyApp.getInstance().isShowDialog()) { // ✅ 增加狀態檢查
|
||||
byte[] buffer = comPort2.readListen(30, 200);
|
||||
// 【修復 付款成功卻不出貨】交易結果(含 responseCode)就在這段 404 byte 電文裡,
|
||||
// 直接解析本段即可。先前版本誤改成再用 readListen 等「第二段」電文才解析,
|
||||
// 但刷卡機只送這一段,第二段永遠等不到 → 倒數逾時 → RESPONSE 從未送出 → 不出貨。
|
||||
HashMap<String, String> readMap = devNFCPay.read(readData);
|
||||
String code = readMap.get("code");
|
||||
getLogs().info("交易完成,回傳代碼: " + code);
|
||||
|
||||
if (buffer.length > 0) {
|
||||
getLogs().info("NFC 收到資料: " + PortTools.showHex(buffer));
|
||||
String rxData = new String(buffer, StandardCharsets.UTF_8);
|
||||
|
||||
if (rxData.contains(String.valueOf((char) 0x03))) { // ETX
|
||||
HashMap<String, String> response = devNFCPay.read(rxData);
|
||||
String code = response.get("code");
|
||||
|
||||
if (isRun() && !isShuttingDown && MyApp.getInstance().isShowDialog()) { // ✅ 最後回報前的檢查
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.RESPONSE.getOption(), code);
|
||||
} else {
|
||||
getLogs().info("🚫 控制器已停止或對話框已關閉,捨棄 NFC 回傳結果");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRun() || isShuttingDown || !MyApp.getInstance().isShowDialog()) {
|
||||
getLogs().info("NFC 讀取中斷: 控制器關閉或對話框消失");
|
||||
break;
|
||||
}
|
||||
if (isRun() && !isShuttingDown && MyApp.getInstance().isShowDialog()) { // ✅ 回報前的生命週期檢查
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.RESPONSE.getOption(), code);
|
||||
} else {
|
||||
getLogs().info("🚫 控制器已停止或對話框已關閉,捨棄 NFC 回傳結果");
|
||||
}
|
||||
} catch (LogsParseException | LogsIOException e) {
|
||||
getLogs().warning(e);
|
||||
|
||||
@ -41,6 +41,43 @@ public class DevXinYuanController extends DevController {
|
||||
}
|
||||
|
||||
public static int sllloot;
|
||||
|
||||
// ─── 取貨口狀態廣播快取(移植自 LockBoard-Honlifeng)───
|
||||
/** 最近一次 VMC_02 回報的「貨道/取貨口狀態」位元組與時間。下位機會自發廣播 VMC_02
|
||||
* (取貨口有東西時約每 4 秒一次),controller 背景持續快取;即使 checkSlot 主動查詢的回應
|
||||
* 被 800ms 讀窗錯過、或緩衝被清、或下位機狂送非標準幀,購買前檢查仍可退回此快取判斷
|
||||
* 「取貨口有東西(05)」,避免漏判。 */
|
||||
private static volatile int lastSlotStatus = -1;
|
||||
private static volatile long lastSlotStatusTime = 0;
|
||||
|
||||
/** 最近一次收到「任何」合法 VMC 幀的時間。只要有在收(POLL/ACK/金錢/狀態…)就代表下位機在線,
|
||||
* 不侷限於 VMC_02。供連線判斷:機台可能在忙其他協定而暫不回報取貨口狀態,但仍是連線正常。 */
|
||||
private static volatile long lastVmcCommTime = 0;
|
||||
|
||||
/** 下位機近 withinMs 毫秒內是否有任何 VMC 通訊(=是否在線)。與取貨口狀態無關。 */
|
||||
public static boolean isRecentlyAlive(long withinMs) {
|
||||
return lastVmcCommTime > 0 && System.currentTimeMillis() - lastVmcCommTime < withinMs;
|
||||
}
|
||||
|
||||
/** 取得最近 withinMs 毫秒內 VMC_02 回報的貨道/取貨口狀態位元組;無近期資料回 -1。 */
|
||||
public static int getRecentSlotStatus(long withinMs) {
|
||||
if (lastSlotStatus < 0) return -1;
|
||||
if (System.currentTimeMillis() - lastSlotStatusTime >= withinMs) return -1;
|
||||
return lastSlotStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得「sinceTime(含)之後」才回報的取貨口/貨道狀態位元組;最近一次回報早於 sinceTime 則回 -1。
|
||||
* 供「點選商品」主動查詢用:送 checkSlot 後只採信新到的 VMC_02 回覆,避免採到查詢前的舊快取。
|
||||
* 因下位機為輪詢式主從——送出 checkSlot 後機台會於下一個 POLL 主動回一筆 VMC_02,通常數百毫秒內即到,
|
||||
* 故可搭配早退,收到即判定,不需空等廣播週期。
|
||||
*/
|
||||
public static int getSlotStatusSince(long sinceTime) {
|
||||
if (lastSlotStatus < 0) return -1;
|
||||
if (lastSlotStatusTime < sinceTime) return -1;
|
||||
return lastSlotStatus;
|
||||
}
|
||||
|
||||
public DevXinYuan getDevXinYuan() {
|
||||
return devXinYuan;
|
||||
}
|
||||
@ -53,10 +90,17 @@ public class DevXinYuanController extends DevController {
|
||||
super( 50, handlerMain);
|
||||
this.logs = new Logs(getClass());
|
||||
this.devXinYuan = new DevXinYuan();
|
||||
// 維修模式:不開啟下位機串口,保留給原廠下位機軟體讀取。rs232Dev 維持 null、讀取迴圈不啟動
|
||||
//(MyApp 不會呼叫 start())。(移植自 StarApp-LockBoard)
|
||||
if (com.unibuy.smartdevice.devices.MaintenanceMode.isSerialReleased()) {
|
||||
logs.info("維修模式:跳過開啟下位機串口(釋放給下位機軟體使用)");
|
||||
setRun(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.rs232Dev = new Rs232Dev(MyApp.getInstance().getComPort1ByXinYuan());
|
||||
devXinYuan.setRun(true);
|
||||
logs.debug("下位機連接");
|
||||
logs.info("下位機連接");
|
||||
} catch (LogsUnsupportedOperationException | LogsIOException e) {
|
||||
// getLogs().error(ErrorCode.DEV_ERROR, "@下位機連接錯誤", handlerMain);
|
||||
getLogs().warning(e);
|
||||
@ -67,6 +111,7 @@ public class DevXinYuanController extends DevController {
|
||||
@Override
|
||||
protected void close(HandlerMain handlerMain) {
|
||||
devXinYuan.setRun(false);
|
||||
if (this.rs232Dev == null) return; // 維修模式時未開串口,避免 NPE
|
||||
try {
|
||||
this.rs232Dev.closePort();
|
||||
} catch (LogsIOException e) {
|
||||
@ -74,6 +119,22 @@ public class DevXinYuanController extends DevController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 維修模式:主動停止讀取迴圈並關閉串口,釋放給原廠下位機軟體。(移植自 StarApp-LockBoard)
|
||||
* 先 setRun(false) 讓讀取 while 迴圈準備結束,closePort() 會使阻塞中的 read() 立即拋例外跳出迴圈,
|
||||
* 最後 shutdown() 收掉排程執行緒。
|
||||
*/
|
||||
public void releasePort() {
|
||||
setRun(false);
|
||||
devXinYuan.setRun(false);
|
||||
try {
|
||||
if (rs232Dev != null) rs232Dev.closePort();
|
||||
} catch (Exception e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
shutdown();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
@ -123,19 +184,22 @@ public class DevXinYuanController extends DevController {
|
||||
Byte key = Byte.valueOf(vmcCmd[0]);
|
||||
|
||||
if (!vmcMap.containsKey(key)) {
|
||||
getLogs().info("XinYuan vmcCmd:" + PortTools.showHex(vmcCmd));
|
||||
getLogs().info("XinYuan vmcData:" + PortTools.showHex(vmcData));
|
||||
getLogs().info("XinYuan vmcXor:" + PortTools.showHex(vmcXor));
|
||||
getLogs().debug("XinYuan vmcCmd:" + PortTools.showHex(vmcCmd));
|
||||
getLogs().debug("XinYuan vmcData:" + PortTools.showHex(vmcData));
|
||||
getLogs().debug("XinYuan vmcXor:" + PortTools.showHex(vmcXor));
|
||||
getLogs().info("XinYuan vmc not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
DevXinYuan.VMC vmc = vmcMap.get(key);
|
||||
|
||||
// 收到任何合法 VMC 幀=下位機在線,更新最後通訊時間(供連線判斷,與取貨口狀態脫鉤)。
|
||||
lastVmcCommTime = System.currentTimeMillis();
|
||||
|
||||
if (vmc != DevXinYuan.VMC.VMC_41_POLL) {
|
||||
getLogs().info("XinYuan vmcCmd:" + PortTools.showHex(vmcCmd));
|
||||
getLogs().info("XinYuan vmcData:" + PortTools.showHex(vmcData));
|
||||
getLogs().info("XinYuan vmcXor:" + PortTools.showHex(vmcXor));
|
||||
getLogs().debug("XinYuan vmcCmd:" + PortTools.showHex(vmcCmd));
|
||||
getLogs().debug("XinYuan vmcData:" + PortTools.showHex(vmcData));
|
||||
getLogs().debug("XinYuan vmcXor:" + PortTools.showHex(vmcXor));
|
||||
getLogs().info("XinYuan vmc:" + vmc.name());
|
||||
}
|
||||
|
||||
@ -143,12 +207,12 @@ public class DevXinYuanController extends DevController {
|
||||
case VMC_41_POLL:
|
||||
if (getCountBySendBuffer() > 0) {
|
||||
byte[] cmdBytes = getSendBufferByFirst();
|
||||
getLogs().info("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"+bytesToHex(cmdBytes));
|
||||
getLogs().debug("XinYuan cmd hex:" + bytesToHex(cmdBytes));
|
||||
|
||||
if (cmdBytes.length == (4+cmdBytes[3]+1)) {
|
||||
for (int sendCount=1; sendCount <2; sendCount++) {
|
||||
rs232Dev.send(cmdBytes);
|
||||
getLogs().info("XinYuan Send:" + PortTools.showHex(cmdBytes));
|
||||
getLogs().debug("XinYuan Send:" + PortTools.showHex(cmdBytes));
|
||||
|
||||
byte[] vmcBuffer = rs232Dev.readListen(0, 1);
|
||||
if (vmcBuffer.length > 2) {
|
||||
@ -177,7 +241,15 @@ public class DevXinYuanController extends DevController {
|
||||
}
|
||||
break;
|
||||
case VMC_02_CHECK_SLOT_STATUS:
|
||||
publishReadBuffer(PortTools.concatByteArrays(vmcCmd[0], vmcData[1]));
|
||||
// 下位機串列常錯位:length 位元組被讀成 0/1,vmcData 不足 2 byte。
|
||||
// 必須先判長度,否則 vmcData[1] 會越界拋例外,連這筆狀態都收不到(取貨口漏判主因之一)。
|
||||
if (vmcData.length >= 2) {
|
||||
lastSlotStatus = vmcData[1] & 0xFF; // 取貨口/貨道狀態:05=有東西, 06/09=門, 07=升降梯, 01=正常
|
||||
lastSlotStatusTime = System.currentTimeMillis();
|
||||
publishReadBuffer(PortTools.concatByteArrays(vmcCmd[0], vmcData[1]));
|
||||
} else {
|
||||
getLogs().debug("XinYuan VMC_02 訊框錯位,data 不足 2 byte,略過:" + PortTools.showHex(vmcData));
|
||||
}
|
||||
rs232Dev.send(devXinYuan.sendACK());
|
||||
break;
|
||||
case VMC_04_BUY_STATUS:
|
||||
@ -247,7 +319,7 @@ public class DevXinYuanController extends DevController {
|
||||
break;
|
||||
case VMC_52_CHECK_VMC_STATUS:
|
||||
//溫度:vmcData[7]
|
||||
getLogs().info("下位機訊號"+PortTools.showHex(vmcData));
|
||||
getLogs().debug("下位機訊號"+PortTools.showHex(vmcData));
|
||||
if (PortTools.showHex(vmcData).isEmpty()) {
|
||||
getLogs().info("下位機失連,重啟APP");
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import android.util.Log;
|
||||
|
||||
public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
private static final String DATABASE_NAME = "mydata.db";
|
||||
private static final int DATABASE_VERSION = 1;
|
||||
private static final int DATABASE_VERSION = 2;
|
||||
|
||||
public enum column {
|
||||
COLUMN_ID("INTEGER PRIMARY KEY");
|
||||
@ -164,6 +164,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
COUNT("INTEGER NOT NULL"),
|
||||
LOCK("TINYINT NOT NULL"),
|
||||
OTHER_DATA("TEXT"),
|
||||
// 雙向 LWW 同步序號(per-field)
|
||||
LOCK_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
LOCK_SYNCED_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
EXPIRY_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
EXPIRY_SYNCED_REV("INTEGER NOT NULL DEFAULT 0"),
|
||||
;
|
||||
|
||||
private String type;
|
||||
@ -267,7 +272,11 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
slot.PRODUCT_ID.getCreateSQL(", ") +
|
||||
slot.COUNT.getCreateSQL(", ") +
|
||||
slot.LOCK.getCreateSQL(", ") +
|
||||
slot.OTHER_DATA.getCreateSQL(");")
|
||||
slot.OTHER_DATA.getCreateSQL(", ") +
|
||||
slot.LOCK_REV.getCreateSQL(", ") +
|
||||
slot.LOCK_SYNCED_REV.getCreateSQL(", ") +
|
||||
slot.EXPIRY_REV.getCreateSQL(", ") +
|
||||
slot.EXPIRY_SYNCED_REV.getCreateSQL(");")
|
||||
;
|
||||
|
||||
private static final String CREATE_TABLE_MEMO =
|
||||
@ -299,12 +308,31 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
exec(db, "DROP TABLE IF EXISTS " + settinglogs.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + settings.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + products.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + slot.TABLE.getName());
|
||||
exec(db, "DROP TABLE IF EXISTS " + memo.TABLE.getName());
|
||||
onCreate(db);
|
||||
// v1→v2:雙向 LWW 同步序號。採「ALTER ADD COLUMN」增量升級,保留既有 slot/lock/效期/庫存資料,
|
||||
// 不可 DROP 全表(否則本機補貨設定、現場鎖定狀態會全部遺失)。
|
||||
if (oldVersion < 2) {
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.LOCK_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.LOCK_SYNCED_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.EXPIRY_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
tryAddColumn(db, slot.TABLE.getName(), slot.EXPIRY_SYNCED_REV.getName(), "INTEGER NOT NULL DEFAULT 0");
|
||||
// 既有「現場已鎖定」貨道:lock_rev 設為 1(synced 仍為 0 = 未上報),
|
||||
// 升級後首次 B009 即以 1>0 透過常態 LWW 把現場鎖定遷移上雲,無需 app_uuid/重灌機制。
|
||||
try {
|
||||
exec(db, "UPDATE " + slot.TABLE.getName() + " SET " + slot.LOCK_REV.getName()
|
||||
+ "=1 WHERE " + slot.LOCK.getName() + "=1");
|
||||
} catch (Exception e) {
|
||||
Log.i("DatabaseHelper", "seed lock_rev on upgrade skipped: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 安全新增欄位:欄位已存在時忽略例外,避免升級重入時報錯。 */
|
||||
private void tryAddColumn(SQLiteDatabase db, String table, String column, String type) {
|
||||
try {
|
||||
exec(db, "ALTER TABLE " + table + " ADD COLUMN " + column + " " + type);
|
||||
} catch (Exception e) {
|
||||
Log.i("DatabaseHelper", "add column skipped (" + table + "." + column + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,10 +38,34 @@ public class SlotsDao {
|
||||
values.put(DatabaseHelper.slot.COUNT.getName(), data.getCount());
|
||||
values.put(DatabaseHelper.slot.LOCK.getName(), data.isLock());
|
||||
values.put(DatabaseHelper.slot.OTHER_DATA.getName(), data.getOtherData().toString());
|
||||
values.put(DatabaseHelper.slot.LOCK_REV.getName(), data.getLockRev());
|
||||
values.put(DatabaseHelper.slot.LOCK_SYNCED_REV.getName(), data.getLockSyncedRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_REV.getName(), data.getExpiryRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName(), data.getExpirySyncedRev());
|
||||
|
||||
database.insert(DatabaseHelper.slot.TABLE.getName(), null, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 單行更新一個貨道(count/lock/otherData/4 個 rev),依 field+slot 定位。
|
||||
* 取代 MQTT 下行/局部更新時 deleteAll()+insertAll() 全表重寫,避免與 UI 存檔互相 race。
|
||||
*/
|
||||
public void updateSlot(SlotStructure data) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DatabaseHelper.slot.PRODUCT_ID.getName(), data.getProduct().getProductID());
|
||||
values.put(DatabaseHelper.slot.COUNT.getName(), data.getCount());
|
||||
values.put(DatabaseHelper.slot.LOCK.getName(), data.isLock());
|
||||
values.put(DatabaseHelper.slot.OTHER_DATA.getName(), data.getOtherData() != null ? data.getOtherData().toString() : "{}");
|
||||
values.put(DatabaseHelper.slot.LOCK_REV.getName(), data.getLockRev());
|
||||
values.put(DatabaseHelper.slot.LOCK_SYNCED_REV.getName(), data.getLockSyncedRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_REV.getName(), data.getExpiryRev());
|
||||
values.put(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName(), data.getExpirySyncedRev());
|
||||
|
||||
String whereClause = DatabaseHelper.slot.FIELD.getName() + "=? AND " + DatabaseHelper.slot.SLOT.getName() + "=?";
|
||||
String[] whereArgs = { String.valueOf(data.getField()), String.valueOf(data.getSlot()) };
|
||||
database.update(DatabaseHelper.slot.TABLE.getName(), values, whereClause, whereArgs);
|
||||
}
|
||||
|
||||
// 更新指定 field 和 slot 的數量(count)
|
||||
public void updateCount(int field, int slot, int newCount) {
|
||||
ContentValues values = new ContentValues();
|
||||
@ -77,6 +101,29 @@ public class SlotsDao {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以單一交易「清空後重建」整個 slot 表,避免 deleteAll() 與 insertAll() 之間的空窗被其他連線讀到空表,
|
||||
* 或與背景下行寫入互相覆蓋。供 UI 存檔使用。
|
||||
*/
|
||||
public void replaceAll(Map<Integer, List<SlotStructure>> slotMap) {
|
||||
database.beginTransaction();
|
||||
try {
|
||||
database.delete(DatabaseHelper.slot.TABLE.getName(), null, null);
|
||||
for (SlotField slotField : SlotField.values()) {
|
||||
if (slotMap.containsKey(slotField.getField())) {
|
||||
List<SlotStructure> slotList = slotMap.get(slotField.getField());
|
||||
if (slotList == null) continue;
|
||||
for (SlotStructure data : slotList) {
|
||||
insertOne(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取所有 Products
|
||||
public Map<Integer, List<SlotStructure>> getAll() {
|
||||
Map<Integer, List<SlotStructure>> slotMap = new HashMap<>();
|
||||
@ -99,6 +146,10 @@ public class SlotsDao {
|
||||
int count = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.COUNT.getName()));
|
||||
boolean lock = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK.getName())) == 1;
|
||||
String otherData = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.OTHER_DATA.getName()));
|
||||
int lockRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK_REV.getName()));
|
||||
int lockSyncedRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.LOCK_SYNCED_REV.getName()));
|
||||
int expiryRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.EXPIRY_REV.getName()));
|
||||
int expirySyncedRev = cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseHelper.slot.EXPIRY_SYNCED_REV.getName()));
|
||||
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
try {
|
||||
@ -117,7 +168,12 @@ public class SlotsDao {
|
||||
slotMap.put(field, new ArrayList<>());
|
||||
}
|
||||
|
||||
slotMap.get(field).add(new SlotStructure(field, slot, product, count, lock, jsonObject));
|
||||
SlotStructure slotData = new SlotStructure(field, slot, product, count, lock, jsonObject);
|
||||
slotData.setLockRev(lockRev);
|
||||
slotData.setLockSyncedRev(lockSyncedRev);
|
||||
slotData.setExpiryRev(expiryRev);
|
||||
slotData.setExpirySyncedRev(expirySyncedRev);
|
||||
slotMap.get(field).add(slotData);
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
@ -46,17 +46,17 @@ public enum ComPort {
|
||||
}
|
||||
|
||||
public String getFilepath() {
|
||||
// 下位機(XinYuan)/刷卡機(NFCPay)串口依「串口方案」(本機設定,見 PortProfile)決定,
|
||||
// 以因應不同 IPC 主板的串口位置不一致:
|
||||
// - 舊版本 (LEGACY ):下位機 ttyS1、刷卡機 ttyS2
|
||||
// - 星科技 (STARTECH):下位機 ttyS7、刷卡機 ttyS6
|
||||
// 於後台「系統設定」切換;切換後需重啟 app 套用。預設 LEGACY(ttyS1/ttyS2)。
|
||||
boolean starTech = PortProfile.isStarTech();
|
||||
if (this == COM_PORT_S1) {
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
return "/dev/ttyS1";
|
||||
}
|
||||
return "/dev/ttyS7";
|
||||
return starTech ? "/dev/ttyS7" : "/dev/ttyS1";
|
||||
}
|
||||
if (this == COM_PORT_S2) {
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
return "/dev/ttyS2";
|
||||
}
|
||||
return "/dev/ttyS6";
|
||||
return starTech ? "/dev/ttyS6" : "/dev/ttyS2";
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
|
||||
@ -267,7 +267,7 @@ public class DevXinYuan {
|
||||
proBytes = PortTools.concatByteArrays(proBytes, XORByte);
|
||||
|
||||
if (isShow) {
|
||||
logs.info("XinYuan ipcCmd:" + cmd.name() + " " + PortTools.showHex(proBytes));
|
||||
logs.debug("XinYuan ipcCmd:" + cmd.name() + " " + PortTools.showHex(proBytes));
|
||||
}
|
||||
|
||||
return proBytes;
|
||||
@ -356,6 +356,39 @@ public class DevXinYuan {
|
||||
getPackNo((byte)0x01), buffer[0], buffer[1], buffer2[0]});
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定指定機櫃的「取貨口關閉時間」(VMC 韌體菜單指令 4.5.11)。
|
||||
* 取貨口開啟後,韌體會在此秒數後自動關閉取貨口門。
|
||||
*
|
||||
* 菜單指令格式:STX(FA FB) + Command(0x70) + Length + 通訊號 + 命令類型(0x11) + 操作類型(0x01:寫) + 機櫃號 + 關閉時間(秒) + XOR
|
||||
* 因 CMD enum 把 Command/Length 綁在一起、長度固定,菜單指令長度可變,故此處比照 send() 手動組幀。
|
||||
*
|
||||
* @param cabinet 機櫃號:0=主機,1-9=副櫃1~9
|
||||
* @param seconds 取貨口關閉時間,設定範圍 3-250 秒(超出會自動夾在範圍內)
|
||||
*/
|
||||
public byte[] setPickupDoorCloseTime(int cabinet, int seconds) {
|
||||
if (seconds < 3) seconds = 3;
|
||||
if (seconds > 250) seconds = 250;
|
||||
|
||||
byte[] text = new byte[] {
|
||||
getPackNo((byte) 0x01), // 通訊號
|
||||
0x11, // 命令類型:取貨口關閉時間
|
||||
0x01, // 操作類型:0x01 設定(寫入)
|
||||
(byte) cabinet, // 機櫃號
|
||||
(byte) seconds // 關閉時間(秒)
|
||||
};
|
||||
|
||||
byte[] proBytes = CMD.CMD_FAFB_STX.getBytes();
|
||||
proBytes = PortTools.concatByteArrays(proBytes, new byte[] {0x70, (byte) text.length}); // Command(0x70)+Length
|
||||
proBytes = PortTools.concatByteArrays(proBytes, text);
|
||||
byte[] XORByte = new byte[] {getXOR(proBytes)};
|
||||
proBytes = PortTools.concatByteArrays(proBytes, XORByte);
|
||||
|
||||
logs.debug("XinYuan setPickupDoorCloseTime cabinet:" + cabinet + " seconds:" + seconds
|
||||
+ " " + PortTools.showHex(proBytes));
|
||||
return proBytes;
|
||||
}
|
||||
|
||||
public byte[] giveChangeMode(boolean isIPC) {
|
||||
return send(CMD.CMD_37_GIVE_CHANGE_MODE, new byte[] {
|
||||
getPackNo((byte)0x01), isIPC ? (byte)0x01: (byte)0x02
|
||||
@ -371,18 +404,18 @@ public class DevXinYuan {
|
||||
}
|
||||
|
||||
public CMD readCMD(byte[] buffer) throws LogsEmptyException {
|
||||
logs.info(PortTools.showHex(buffer));
|
||||
logs.debug(PortTools.showHex(buffer));
|
||||
|
||||
for (CMD cmd : CMD.values()) {
|
||||
if (cmd.getBytes().length >= 2 && buffer.length >= 2) {
|
||||
if (cmd.getBytes()[0] == buffer[0]) {
|
||||
if (cmd.equals(CMD.CMD_FAFB_STX)) {
|
||||
if (cmd.getBytes()[1] == buffer[1]) {
|
||||
logs.info("CMD:" + cmd.name() + " " + PortTools.showHex(cmd.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug("CMD:" + cmd.name() + " " + PortTools.showHex(cmd.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return cmd;
|
||||
}
|
||||
} else {
|
||||
logs.info("CMD:" + cmd.name() + " " + PortTools.showHex(cmd.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug("CMD:" + cmd.name() + " " + PortTools.showHex(cmd.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@ -393,18 +426,18 @@ public class DevXinYuan {
|
||||
}
|
||||
|
||||
public VMC readVMC(byte[] buffer) throws LogsEmptyException {
|
||||
logs.info(PortTools.showHex(buffer));
|
||||
logs.debug(PortTools.showHex(buffer));
|
||||
|
||||
for (VMC vmc : VMC.values()) {
|
||||
if (vmc.getBytes().length >= 2 && buffer.length >= 2) {
|
||||
if (vmc.getBytes()[0] == buffer[0]) {
|
||||
if (vmc.equals(VMC.VMC_FAFB_STX)) {
|
||||
if (vmc.getBytes()[1] == buffer[1]) {
|
||||
logs.info("VMC:" + vmc.name() + " " + PortTools.showHex(vmc.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug("VMC:" + vmc.name() + " " + PortTools.showHex(vmc.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return vmc;
|
||||
}
|
||||
} else {
|
||||
logs.info("VMC:" + vmc.name() + " " + PortTools.showHex(vmc.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug("VMC:" + vmc.name() + " " + PortTools.showHex(vmc.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return vmc;
|
||||
}
|
||||
}
|
||||
@ -416,7 +449,7 @@ public class DevXinYuan {
|
||||
|
||||
public byte readPackNo(byte[] buffer) {
|
||||
byte packNo = buffer[0];
|
||||
logs.info("Read packNo:" + PortTools.showHex(packNo));
|
||||
logs.debug("Read packNo:" + PortTools.showHex(packNo));
|
||||
return packNo;
|
||||
}
|
||||
|
||||
@ -424,7 +457,7 @@ public class DevXinYuan {
|
||||
for (BUY_STATUS_04 buyStatus : BUY_STATUS_04.values()) {
|
||||
if (buyStatus.getBytes().length == 1 && buffer.length > 0) {
|
||||
if (buyStatus.getBytes()[0] == buffer[1]) {
|
||||
logs.info(BUY_STATUS_04.class.getSimpleName()+":" + buyStatus.name() + " " + PortTools.showHex(buyStatus.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug(BUY_STATUS_04.class.getSimpleName()+":" + buyStatus.name() + " " + PortTools.showHex(buyStatus.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return buyStatus;
|
||||
}
|
||||
}
|
||||
@ -437,7 +470,7 @@ public class DevXinYuan {
|
||||
for (SLOT_STATUS_02 slotStatus : SLOT_STATUS_02.values()) {
|
||||
if (slotStatus.getBytes().length == 1 && buffer.length > 0) {
|
||||
if (slotStatus.getBytes()[0] == buffer[1]) {
|
||||
logs.info(SLOT_STATUS_02.class.getSimpleName()+":" + slotStatus.name() + " " + PortTools.showHex(slotStatus.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug(SLOT_STATUS_02.class.getSimpleName()+":" + slotStatus.name() + " " + PortTools.showHex(slotStatus.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return slotStatus;
|
||||
}
|
||||
}
|
||||
@ -451,7 +484,7 @@ public class DevXinYuan {
|
||||
for (GIVE_MONEY_26 giveMoney : GIVE_MONEY_26.values()) {
|
||||
if (giveMoney.getBytes().length == 1 && buffer.length > 0) {
|
||||
if (giveMoney.getBytes()[0] == buffer[1]) {
|
||||
logs.info(GIVE_MONEY_26.class.getSimpleName()+":" + giveMoney.name() + " " + PortTools.showHex(giveMoney.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
logs.debug(GIVE_MONEY_26.class.getSimpleName()+":" + giveMoney.name() + " " + PortTools.showHex(giveMoney.getBytes()) + " buffer:" + PortTools.showHex(new byte[]{buffer[0], buffer[1]}));
|
||||
return giveMoney;
|
||||
}
|
||||
}
|
||||
@ -475,10 +508,10 @@ public class DevXinYuan {
|
||||
byte XORByte = buffer[buffer.length -1];
|
||||
proBytes = PortTools.concatByteArrays(proBytes, cmdLenData);
|
||||
byte XORByte2 = getXOR(proBytes);
|
||||
logs.info("Length:"+length+" XOR1:" + PortTools.showHex(new byte[] {XORByte}) + " XOR2:" + PortTools.showHex(new byte[] {XORByte2}));
|
||||
logs.debug("Length:"+length+" XOR1:" + PortTools.showHex(new byte[] {XORByte}) + " XOR2:" + PortTools.showHex(new byte[] {XORByte2}));
|
||||
if (buffer.length > (2+length)) {
|
||||
byte[] dataBytes = PortTools.catByteArrays(buffer, 2, length);
|
||||
logs.info("data:"+PortTools.showHex(dataBytes));
|
||||
logs.debug("data:"+PortTools.showHex(dataBytes));
|
||||
return dataBytes;
|
||||
} else {
|
||||
throw new LogsEmptyException(logs, "data array length insufficient");
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package com.unibuy.smartdevice.devices;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
|
||||
/**
|
||||
* 維修模式(釋放下位機串口)。移植自 StarApp-LockBoard。
|
||||
*
|
||||
* 本 app 為 kiosk:開機自啟+內建自動重啟排程,一啟動就獨佔下位機串口(主櫃 XinYuan /dev/ttyS7)
|
||||
* 並持續讀寫。原廠「下位機軟體」(興元 com.xyshj.machine)要讀取同一串口時會與本 app 互搶位元組而
|
||||
* 無法載入資料 —— 過去按「下位機軟體」只是開原廠 app 並 killProcess,但 kiosk 自動重啟後又搶回串口。
|
||||
*
|
||||
* serialReleased=true 時:
|
||||
* - MyApp 啟動不開啟下位機串口、不送初始化指令;
|
||||
* - checkAndReconnectDev() 不再自動重連;
|
||||
* - 進入 MaintenanceSerialActivity 維修畫面。
|
||||
* 因為以 SharedPreferences 持久化,即使中途自動重啟/開機自啟,仍維持釋放狀態,
|
||||
* 直到於維修畫面手動「退出維修模式」才恢復佔用。
|
||||
*/
|
||||
public final class MaintenanceMode {
|
||||
|
||||
private static final String PREFS = "device_maintenance";
|
||||
private static final String KEY_SERIAL_RELEASED = "serial_released";
|
||||
|
||||
private MaintenanceMode() {}
|
||||
|
||||
private static SharedPreferences sp(Context ctx) {
|
||||
return ctx.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
/** 是否處於「已釋放下位機串口」的維修模式;預設 false。無 Context 版供 controller/背景使用。 */
|
||||
public static boolean isSerialReleased() {
|
||||
try {
|
||||
return sp(MyApp.getInstance()).getBoolean(KEY_SERIAL_RELEASED, false);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isSerialReleased(Context ctx) {
|
||||
return sp(ctx).getBoolean(KEY_SERIAL_RELEASED, false);
|
||||
}
|
||||
|
||||
/** 用 commit(同步落地)確保「進維修模式後就算立刻重啟」也讀得到旗標。 */
|
||||
public static void setSerialReleased(Context ctx, boolean released) {
|
||||
sp(ctx).edit().putBoolean(KEY_SERIAL_RELEASED, released).commit();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.unibuy.smartdevice.devices;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
|
||||
/**
|
||||
* 串口方案(本機硬體設定,不回寫雲端)。
|
||||
*
|
||||
* 不同 IPC 主板的下位機(主櫃 XinYuan)與刷卡機(NFCPay)串口位置不一致,需可現場切換:
|
||||
* - LEGACY (舊版本 ):下位機 /dev/ttyS1、刷卡機 /dev/ttyS2
|
||||
* - STARTECH(星科技版本):下位機 /dev/ttyS7、刷卡機 /dev/ttyS6
|
||||
*
|
||||
* 以 SharedPreferences 持久化;ComPort.getFilepath() 依此回傳實際 tty 路徑。
|
||||
* 串口在 app 啟動時就綁定,故切換方案後需重啟 app 才會套用。
|
||||
* 預設 LEGACY,與切換功能加入前的行為(ttyS1/ttyS2)相同。
|
||||
*/
|
||||
public final class PortProfile {
|
||||
|
||||
private static final String PREFS = "device_port_profile";
|
||||
private static final String KEY_PROFILE = "profile";
|
||||
|
||||
public static final String LEGACY = "legacy"; // 舊版本 :ttyS1 / ttyS2
|
||||
public static final String STARTECH = "startech"; // 星科技版本:ttyS7 / ttyS6
|
||||
|
||||
// 未設定時的預設方案。通用版本為 LEGACY(ttyS1/ttyS2)。
|
||||
// ⚠️代樂美等星科技主板專用建置需改為 STARTECH(ttyS7/ttyS6,裝上即用免手動切),該改動勿進通用主線。
|
||||
public static final String DEFAULT_PROFILE = LEGACY;
|
||||
|
||||
private PortProfile() {}
|
||||
|
||||
private static SharedPreferences sp(Context ctx) {
|
||||
return ctx.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
/** 目前串口方案;未設定時採 DEFAULT_PROFILE。無 Context 版供 enum/背景使用。 */
|
||||
public static String getProfile() {
|
||||
try {
|
||||
return sp(MyApp.getInstance()).getString(KEY_PROFILE, DEFAULT_PROFILE);
|
||||
} catch (Exception e) {
|
||||
return DEFAULT_PROFILE;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getProfile(Context ctx) {
|
||||
return sp(ctx).getString(KEY_PROFILE, DEFAULT_PROFILE);
|
||||
}
|
||||
|
||||
/** 是否為星科技方案(ttyS7/ttyS6)。 */
|
||||
public static boolean isStarTech() {
|
||||
return STARTECH.equals(getProfile());
|
||||
}
|
||||
|
||||
/** 用 commit(同步落地)確保切換後即使立刻重啟也讀得到。 */
|
||||
public static void setProfile(Context ctx, String profile) {
|
||||
String v = STARTECH.equals(profile) ? STARTECH : LEGACY;
|
||||
sp(ctx).edit().putString(KEY_PROFILE, v).commit();
|
||||
}
|
||||
|
||||
/** 方案的中文顯示名稱。 */
|
||||
public static String displayName(String profile) {
|
||||
return STARTECH.equals(profile) ? "星科技(下位機 ttyS7/刷卡機 ttyS6)"
|
||||
: "舊版本(下位機 ttyS1/刷卡機 ttyS2)";
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,11 @@ import java.util.List;
|
||||
public class Rs232Dev {
|
||||
private Logs logs;
|
||||
private boolean showLogs = false;
|
||||
private static boolean isMockMode = false;
|
||||
// su 權限缺失=整機都無法操作序列埠,屬全域事實,維持 static。
|
||||
private static boolean suMissing = false;
|
||||
// 本連線是否進入 Mock:連線失敗只標記「這一個實例」,不再用 static 污染其他實例
|
||||
//(修正:先前 static 導致遠端指令撞埠失敗一次就讓全 App 的 send 全部空轉,須重啟才復原)。
|
||||
private boolean isMockMode = false;
|
||||
private static List<String> rs232DeviceList;
|
||||
private SerialPort serialPort;
|
||||
private InputStream inputStream;
|
||||
@ -57,11 +61,11 @@ public class Rs232Dev {
|
||||
try {
|
||||
suPath = searchSuPath();
|
||||
SerialPort.setSuPath(suPath);
|
||||
isMockMode = false;
|
||||
suMissing = false;
|
||||
} catch (LogsSecurityException e) {
|
||||
Log.e("Rs232Dev", "Root permission (su) not found, entering Mock Mode: " + e.getMessage());
|
||||
suPath = null;
|
||||
isMockMode = true;
|
||||
suMissing = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,6 +127,14 @@ public class Rs232Dev {
|
||||
}
|
||||
|
||||
try {
|
||||
// 防呆:未取得 root 的機台(如未 root 的 rk3288 Android 7)searchSuPath 找不到 su → suPath 為 null。
|
||||
// 若直接 Runtime.exec(null) 會 NPE(String.length())→ App 在 MyApp.onCreate 建立 DevXinYuanController
|
||||
// 時整個啟動崩潰、屢次停止運作。這裡先擋掉:無 su 就略過 chmod、回報此埠不可用,改由上層進 Mock 模式,
|
||||
// 至少讓 App 能開起來(串口需 root 或該埠本身可讀寫才會真正運作)。
|
||||
if (suPath == null || suPath.trim().isEmpty()) {
|
||||
if (showLogs) this.logs.info("suPath 為 null(未取得 root/su),略過 chmod:" + fileComPort.getAbsolutePath());
|
||||
return false;
|
||||
}
|
||||
if (showLogs) this.logs.info("suPath:" + suPath);
|
||||
|
||||
Process su = Runtime.getRuntime().exec(suPath);
|
||||
@ -175,6 +187,9 @@ public class Rs232Dev {
|
||||
}
|
||||
|
||||
public void connectPort(ComPort comPort) throws LogsUnsupportedOperationException, LogsIOException {
|
||||
// 不再因「沒 root(suMissing)」就整個假連線。只要埠本身可存取(chmod666File 已回 true、進了
|
||||
// rs232DeviceList)就照開——比照有 root 機台(Android 9)的行為,讓未 root 但串口權限已開放的
|
||||
// 機台(如 rk3288 Android 7,ttyS1=crwxrwxrwx)也能真正連下位機。真正不可用的埠仍走 isMockMode。
|
||||
if (isMockMode) {
|
||||
Log.i("Rs232Dev", "[MockMode] Faking connection to: " + comPort.getFilepath());
|
||||
return;
|
||||
@ -208,6 +223,7 @@ public class Rs232Dev {
|
||||
|
||||
public byte[] readToMatch(byte[] stx, int moveLimit) throws LogsIOException {
|
||||
byte[] buffer = new byte[0];
|
||||
if (isMockMode || this.serialPort == null) return buffer; // 防呆:Mock/未開串口時不讀,避免 NPE
|
||||
try {
|
||||
InputStream inputStream = this.serialPort.getInputStream();
|
||||
int moveCount = 0;
|
||||
@ -230,6 +246,7 @@ public class Rs232Dev {
|
||||
}
|
||||
|
||||
public byte[] readListen(int readMax, long delayMillis) throws LogsIOException {
|
||||
if (isMockMode || this.serialPort == null) return new byte[0]; // 防呆:Mock/未開串口時不讀,避免 NPE
|
||||
InputStream inputStream = this.serialPort.getInputStream();
|
||||
byte[] buffer = new byte[0];
|
||||
int readCount = 0;
|
||||
|
||||
@ -18,6 +18,10 @@ import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
public class Logs {
|
||||
// 單筆寫檔上限:超過即截斷,避免整包 JSON/payload dump 把 debug.txt 撐爆、淹沒有用事件。
|
||||
// 只影響「寫入 debug.txt」那份;logcat(Log.d/i/...)維持完整不截斷。
|
||||
private static final int MAX_LINE = 1000;
|
||||
|
||||
private String message;
|
||||
private String className;
|
||||
private String methodName;
|
||||
@ -46,6 +50,10 @@ public class Logs {
|
||||
}
|
||||
|
||||
String dateTime = Tools.getTimeStampByNow("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
// 招 1:超長行截斷(止血整包 JSON/payload dump),保留事件本身與原始長度資訊。
|
||||
if (data.length() > MAX_LINE) {
|
||||
data = data.substring(0, MAX_LINE) + " …[truncated " + data.length() + " chars]";
|
||||
}
|
||||
data = dateTime + " " + data;
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(txtDebug, true)) { // try-with-resources 自動 close()
|
||||
@ -61,10 +69,20 @@ public class Logs {
|
||||
return this.className+"."+this.methodName+"("+this.lineNumber+")";
|
||||
}
|
||||
|
||||
// 招 2:是否把 DEBUG/IGNORE 寫進 debug.txt。預設(門檻 INFO)不寫,過濾掉開發雜訊;
|
||||
// 雲端/現場把 LogLevel 設為 DEBUG 時才寫檔。MyApp 尚未就緒時一律不寫,維持乾淨預設。
|
||||
private static boolean writeDebugToFile() {
|
||||
try {
|
||||
return "DEBUG".equals(com.unibuy.smartdevice.MyApp.getInstance().getLogLevel());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void debug(String message) {
|
||||
this.message = message;
|
||||
Log.d(this.className, "DEBUG ["+getClassName()+"]"+this.message);
|
||||
writeFile("DEBUG ["+getClassName()+"]"+this.message);
|
||||
if (writeDebugToFile()) writeFile("DEBUG ["+getClassName()+"]"+this.message);
|
||||
}
|
||||
|
||||
public void info(String message) {
|
||||
@ -87,7 +105,7 @@ public class Logs {
|
||||
public void ignore(String message) {
|
||||
this.message = message;
|
||||
Log.w(this.className, "IGNORE ["+getClassName()+"/"+getInfo()+"]"+this.message);
|
||||
writeFile("IGNORE ["+getClassName()+"/"+getInfo()+"]"+this.message);
|
||||
if (writeDebugToFile()) writeFile("IGNORE ["+getClassName()+"/"+getInfo()+"]"+this.message);
|
||||
}
|
||||
|
||||
public void warning(Exception e) {
|
||||
|
||||
@ -133,6 +133,19 @@ public class HttpAPI {
|
||||
private Logs logs;
|
||||
private static String domainName = "https://demo-cloud.setdomains.work/";
|
||||
private String domainNameByUnibuy = domainName + "api/v1/app/";
|
||||
|
||||
/** 依綁定環境(mqttEnv) 取得 base host:prod / local(130測試) / demo。 */
|
||||
private String baseHost() {
|
||||
String env = MyApp.getInstance().getMachine().getMqttEnv();
|
||||
if ("prod".equals(env)) return "https://starcloud.taiwan-star.com.tw/";
|
||||
if ("local".equals(env)) return "http://192.168.0.130:8090/";
|
||||
return "https://demo-cloud.setdomains.work/";
|
||||
}
|
||||
|
||||
/** 依環境的 api/v1/app/ 前綴(取代固定的 domainNameByUnibuy)。 */
|
||||
private String apiBase() {
|
||||
return baseHost() + "api/v1/app/";
|
||||
}
|
||||
// private String domainNameByUnibuy = domainName + "Unibuy/api/app/";
|
||||
private String domainNameByAAT = domainName + "AAT/api/app/";
|
||||
// private String domainNameByAAT = domainName + "chi-hai-API/api/app/";
|
||||
@ -345,7 +358,7 @@ public class HttpAPI {
|
||||
jsonObject.put("M_Stus2", "1");
|
||||
jsonObject.put("M_Ver", DevXinYuanController.getVersion());
|
||||
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/status/B010", jsonObject.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "machine/status/B010", jsonObject.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -395,7 +408,7 @@ public class HttpAPI {
|
||||
jsonObject.put("Su_Password", password);
|
||||
jsonObject.put("ip", "192.168.1.100");
|
||||
jsonObject.put("type", "2");
|
||||
formPostRunMain.start(domainNameByUnibuy + "admin/login/B000", jsonObject.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "admin/login/B000", jsonObject.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -413,7 +426,7 @@ public class HttpAPI {
|
||||
// json.put("tid", String.valueOf(SlotID));
|
||||
// json.put("type", message);
|
||||
// json.put("time", System.currentTimeMillis());
|
||||
// formPostRunMain.start(domainNameByUnibuy + "machine/error_msg/B013",
|
||||
// formPostRunMain.start(apiBase() + "machine/error_msg/B013",
|
||||
// json.toString(), null, null, HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
// } catch (JSONException e) {
|
||||
// throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -467,7 +480,7 @@ public class HttpAPI {
|
||||
json.put("type", message);
|
||||
|
||||
formPostRunMain.start(
|
||||
domainNameByUnibuy + "machine/error_msg/B013",
|
||||
apiBase() + "machine/error_msg/B013",
|
||||
json.toString(),
|
||||
null,
|
||||
null,
|
||||
@ -493,7 +506,6 @@ public class HttpAPI {
|
||||
public void response(int responseCode, String receive, HandlerMain handlerMain) {
|
||||
if (responseCode == 200) {
|
||||
try {
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
MyApp.getInstance().getMachine().setMachineID("");
|
||||
|
||||
MyApp.getInstance().getEsunpay().setStoreID("");
|
||||
@ -656,9 +668,7 @@ public class HttpAPI {
|
||||
MyApp.getInstance().getGreenpayMerchant().getAtomeMerchantId()
|
||||
}));
|
||||
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa0");
|
||||
SettingActivity.updateProgressUI(10, "下載中...");
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa10");
|
||||
settingsDao.close();
|
||||
} catch (JSONException e) {
|
||||
getLogs().warning(e);
|
||||
@ -681,7 +691,7 @@ public class HttpAPI {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("key", MyApp.getInstance().getMachine().getApiKey());
|
||||
jsonObject.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/setting/B014", jsonObject.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "machine/setting/B014", jsonObject.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -720,7 +730,7 @@ public class HttpAPI {
|
||||
* t060v06 "https://unibuy.com.tw/Unibuy/dist/img/products/53317506.png",
|
||||
*/
|
||||
|
||||
getLogs().info("AAAAAAAAAAAAAAAAAAAAAAA" + productData);
|
||||
getLogs().debug("productData:" + productData);
|
||||
|
||||
if (productData.has("t060v00") &&
|
||||
productData.has("t060v06") &&
|
||||
@ -783,7 +793,6 @@ public class HttpAPI {
|
||||
productsDao.close();
|
||||
|
||||
SettingActivity.updateProgressUI(10, "下載中...");
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa30");
|
||||
|
||||
handlerMain.start(getClass().getSimpleName(), Option.DOWNLOAD_PRODUCT_RESPONSE.getOption(),
|
||||
"成功載入" + MyApp.getInstance().getProductList().size() + "筆商品資料");
|
||||
@ -905,7 +914,7 @@ public class HttpAPI {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("key", MyApp.getInstance().getMachine().getApiKey());
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/do_sell_new/B012_new", json.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "machine/do_sell_new/B012_new", json.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -920,7 +929,7 @@ public class HttpAPI {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("key", MyApp.getInstance().getMachine().getApiKey());
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/do_sell_update/B012_update", json.toString(), null,
|
||||
formPostRunMain.start(apiBase() + "machine/do_sell_update/B012_update", json.toString(), null,
|
||||
null, HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -1026,7 +1035,7 @@ public class HttpAPI {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("key", MyApp.getInstance().getMachine().getApiKey());
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/ad/B005", json.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "machine/ad/B005", json.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -1053,9 +1062,8 @@ public class HttpAPI {
|
||||
JSONObject receiveByJsonObject = new JSONObject(receive);
|
||||
String workApi = receiveByJsonObject.getString("workApi");
|
||||
int status = receiveByJsonObject.getInt("status");
|
||||
getLogs().info("bbbbbbbbbbbbbbbbbbbbbb status" + status);
|
||||
String message = receiveByJsonObject.getString("message");
|
||||
getLogs().info("bbbbbbbbbbbbbbbbbbbbbb message" + message);
|
||||
getLogs().debug("reportFlow status:" + status + " message:" + message);
|
||||
|
||||
if (status == 0) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.REPORT_FLOW_RESPONSE.getOption(),
|
||||
@ -1260,7 +1268,7 @@ public class HttpAPI {
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
json.put("passCode", passCode);
|
||||
|
||||
formPostRunMain.start(domainNameByUnibuy + "sell/PassCode/B024", json.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "sell/PassCode/B024", json.toString(), null, null,
|
||||
HttpConnect.ContentType.JSON);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -1352,7 +1360,7 @@ public class HttpAPI {
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
json.put("passCode", passCode);
|
||||
|
||||
formPostRunMain.start(domainNameByUnibuy + "sell/Freebie/B027", json.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "sell/Freebie/B027", json.toString(), null, null,
|
||||
HttpConnect.ContentType.JSON);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -1404,7 +1412,7 @@ public class HttpAPI {
|
||||
|
||||
getLogs().info("sendData:" + json.toString());
|
||||
|
||||
formPutRunMain.start(domainNameByUnibuy + "sell/PassCode/B024", json.toString(), null, null,
|
||||
formPutRunMain.start(apiBase() + "sell/PassCode/B024", json.toString(), null, null,
|
||||
HttpConnect.ContentType.JSON);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -1461,7 +1469,7 @@ public class HttpAPI {
|
||||
|
||||
getLogs().info("sendData:" + json.toString());
|
||||
|
||||
formPutRunMain.start(domainNameByUnibuy + "sell/Freebie/B027", json.toString(), null, null,
|
||||
formPutRunMain.start(apiBase() + "sell/Freebie/B027", json.toString(), null, null,
|
||||
HttpConnect.ContentType.JSON);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -2142,9 +2150,9 @@ public class HttpAPI {
|
||||
*/
|
||||
jsonObject2.put("data", jsonArray);
|
||||
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=" + jsonObject2.toString());
|
||||
getLogs().debug("B009 supplementary:" + jsonObject2.toString());
|
||||
|
||||
formPutRunMain.start(domainNameByUnibuy + "products/supplementary/B009", jsonObject2.toString(), null, null,
|
||||
formPutRunMain.start(apiBase() + "products/supplementary/B009", jsonObject2.toString(), null, null,
|
||||
HttpConnect.ContentType.JSON);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
@ -2190,7 +2198,7 @@ public class HttpAPI {
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
|
||||
formPostRunMain.start(
|
||||
domainNameByUnibuy + "machine/device_status/B022",
|
||||
apiBase() + "machine/device_status/B022",
|
||||
json.toString(),
|
||||
null,
|
||||
null,
|
||||
@ -2228,7 +2236,7 @@ public class HttpAPI {
|
||||
int slotid = Integer.parseInt(deliveryData.getString("tid"));
|
||||
int slotnum = Integer.parseInt(deliveryData.getString("num"));
|
||||
|
||||
getLogs().info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + slotid + " " + slotnum);
|
||||
getLogs().info("dispatch slotid:" + slotid + " num:" + slotnum);
|
||||
|
||||
slotid = slotid;
|
||||
|
||||
@ -2254,7 +2262,7 @@ public class HttpAPI {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("key", MyApp.getInstance().getMachine().getApiKey());
|
||||
json.put("machine", MyApp.getInstance().getMachine().getMachineID());
|
||||
formPostRunMain.start(domainNameByUnibuy + "machine/reload_msg/B017", json.toString(), null, null,
|
||||
formPostRunMain.start(apiBase() + "machine/reload_msg/B017", json.toString(), null, null,
|
||||
HttpConnect.ContentType.X_WWW_FORM_URLENCODEED);
|
||||
} catch (JSONException e) {
|
||||
throw new LogsParseException(logs, ErrorCode.JSON_FORMAT_ERROR, e);
|
||||
|
||||
@ -174,11 +174,11 @@ public class HttpTapPay {
|
||||
int payType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
GreenpaySettingStructure greenpaySetting = MyApp.getInstance().getGreenpaySetting();
|
||||
String greenpayMerchantId = MyApp.getInstance().getMachine().getMachineID();
|
||||
logs.info("aaaaaaaaaaaaaaaaaaaaaa1"+ MyApp.getInstance().getGreenpayMerchant().getLineMerchantId());
|
||||
logs.info("aaaaaaaaaaaaaaaaaaaaaa2"+ MyApp.getInstance().getGreenpayMerchant().getJkoMerchantId());
|
||||
logs.info("aaaaaaaaaaaaaaaaaaaaaa3"+ MyApp.getInstance().getGreenpayMerchant().getPiMerchantId());
|
||||
logs.info("aaaaaaaaaaaaaaaaaaaaaa4"+ MyApp.getInstance().getGreenpayMerchant().getPsMerchantId());
|
||||
logs.info("aaaaaaaaaaaaaaaaaaaaaa5"+ MyApp.getInstance().getGreenpayMerchant().getEasyMerchantId());
|
||||
logs.debug("merchantId line:" + MyApp.getInstance().getGreenpayMerchant().getLineMerchantId()
|
||||
+ " jko:" + MyApp.getInstance().getGreenpayMerchant().getJkoMerchantId()
|
||||
+ " pi:" + MyApp.getInstance().getGreenpayMerchant().getPiMerchantId()
|
||||
+ " ps:" + MyApp.getInstance().getGreenpayMerchant().getPsMerchantId()
|
||||
+ " easy:" + MyApp.getInstance().getGreenpayMerchant().getEasyMerchantId());
|
||||
switch (payType) {
|
||||
case 30:
|
||||
if (MyApp.getInstance().getGreenpayMerchant() != null && !MyApp.getInstance().getGreenpayMerchant().getLineMerchantId().isEmpty()) {
|
||||
|
||||
@ -15,6 +15,8 @@ import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.database.ProductsDao;
|
||||
import com.unibuy.smartdevice.database.SettingsDao;
|
||||
import com.unibuy.smartdevice.structure.CashSetStructure;
|
||||
import com.unibuy.smartdevice.structure.DevSetStructure;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportSlotListStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
@ -40,8 +42,12 @@ public class StarCloudAPI {
|
||||
+ MyApp.getInstance().getMachine().getMachineID() + "/log/update";
|
||||
|
||||
private String getBaseUrl() {
|
||||
if ("prod".equals(MyApp.getInstance().getMachine().getMqttEnv())) {
|
||||
String env = MyApp.getInstance().getMachine().getMqttEnv();
|
||||
if ("prod".equals(env)) {
|
||||
return "https://starcloud.taiwan-star.com.tw/api/v1/app/";
|
||||
} else if ("local".equals(env)) {
|
||||
// 130 測試站(領藥單測試用,綁定畫面選「130測試」)
|
||||
return "http://192.168.0.130:8090/api/v1/app/";
|
||||
} else {
|
||||
return "https://demo-cloud.setdomains.work/api/v1/app/";
|
||||
}
|
||||
@ -155,6 +161,9 @@ public class StarCloudAPI {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
MyApp.getInstance().setSuAccess(res.optString("Su_Account", account));
|
||||
// 身分與登入 Token:identity 決定系統設定是否可編輯;loginToken 供回寫 B016 使用。
|
||||
MyApp.getInstance().setIdentity(res.optString("identity", ""));
|
||||
MyApp.getInstance().setLoginToken(res.optString("token", ""));
|
||||
// 移除覆蓋 ApiKey 的邏輯,因為 B000 回傳的可能是 Admin Token,但我們需要保留 Machine Token
|
||||
Log.i(TAG, "Login success for user: " + account);
|
||||
traceLogin("[" + traceId + "] b000_parse_success parseMs="
|
||||
@ -194,6 +203,17 @@ public class StarCloudAPI {
|
||||
* 不需要 ApiKey 即可呼叫,僅需機器序號。
|
||||
*/
|
||||
public void downloadSetting() {
|
||||
downloadSetting(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* B014: 重新下載系統設定。
|
||||
*
|
||||
* @param reconnectMqtt 設定落地後是否立即重連 MQTT。
|
||||
* 綁定 UI 流程傳 true;遠端 update_settings 指令傳 false,
|
||||
* 改由呼叫端在發出 ACK 之後再重連,避免重連把送指令的連線斷掉導致 ACK 發不出去。
|
||||
*/
|
||||
public void downloadSetting(final boolean reconnectMqtt) {
|
||||
final String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
if (machineId.isEmpty()) {
|
||||
Log.e(TAG, "Machine ID is empty, cannot bind.");
|
||||
@ -255,7 +275,7 @@ public class StarCloudAPI {
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONObject config = res.optJSONObject("data");
|
||||
if (config != null) {
|
||||
saveSettings(config);
|
||||
saveSettings(config, reconnectMqtt);
|
||||
handlerMain.start("StarCloudAPI", 200, "綁定成功");
|
||||
} else {
|
||||
handlerMain.start("StarCloudAPI", 500, "設定內容為空,原始資料:" + finalReceive);
|
||||
@ -391,17 +411,33 @@ public class StarCloudAPI {
|
||||
String receive = "";
|
||||
try {
|
||||
JSONArray jsonArray = new JSONArray();
|
||||
// 記錄本次快照上報的 rev,成功後回填 synced_rev(用快照值而非當下值,避免吃掉上報期間的新編輯)
|
||||
final java.util.List<int[]> reportedRevs = new java.util.ArrayList<>();
|
||||
for (SlotField slotField : SlotField.values()) {
|
||||
for (SlotStructure slotData : MyApp.getInstance().getSlotList(slotField.getField())) {
|
||||
// 判斷是否有商品(若無商品 ID 為 "?",則不傳送,觸發後台全量同步移除邏輯)
|
||||
if (slotData.getProduct() != null && !slotData.getProduct().getProductID().equals("?")) {
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
|
||||
item.put("t060v00", slotData.getProduct().getProductID()); // 商品 ID
|
||||
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
|
||||
// 不送 status 參數(依據新文件,只送 tid, t060v00, num)
|
||||
jsonArray.put(item);
|
||||
boolean hasProduct = slotData.getProduct() != null
|
||||
&& !slotData.getProduct().getProductID().equals("?");
|
||||
// 即使無商品,只要有鎖定或 rev>0(現場改過)也要上報,否則被鎖的空貨道狀態無法同步
|
||||
boolean hasState = slotData.isLock()
|
||||
|| slotData.getLockRev() > 0 || slotData.getExpiryRev() > 0;
|
||||
if (!hasProduct && !hasState) {
|
||||
continue; // 無商品又無狀態 → 不送,維持後台全量同步移除語意
|
||||
}
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("tid", String.valueOf(slotData.getSlot())); // 貨道編號轉字串
|
||||
item.put("t060v00", hasProduct ? slotData.getProduct().getProductID() : ""); // 商品 ID
|
||||
item.put("num", String.valueOf(slotData.getCount())); // 實體庫存轉字串
|
||||
// 雙向 LWW:鎖定 + 效期/批號 + 各自的編輯序號
|
||||
item.put("is_locked", slotData.isLock());
|
||||
item.put("lock_rev", slotData.getLockRev());
|
||||
String exp = slotData.getExpiryDate();
|
||||
item.put("expiry_date", (exp == null || exp.isEmpty()) ? JSONObject.NULL : exp);
|
||||
String batch = slotData.getBatchNo();
|
||||
item.put("batch_no", (batch == null || batch.isEmpty()) ? JSONObject.NULL : batch);
|
||||
item.put("expiry_rev", slotData.getExpiryRev());
|
||||
jsonArray.put(item);
|
||||
reportedRevs.add(new int[]{ slotField.getField(), slotData.getSlot(),
|
||||
slotData.getLockRev(), slotData.getExpiryRev() });
|
||||
}
|
||||
}
|
||||
|
||||
@ -449,6 +485,23 @@ public class StarCloudAPI {
|
||||
|
||||
if (responseCode >= 200 && responseCode < 300) {
|
||||
Log.i(TAG, "B009 Success: " + receive);
|
||||
// 回填 synced_rev = 本次快照上報的 rev(用快照值,期間若有新編輯則 rev>synced 仍視為未同步)
|
||||
try {
|
||||
com.unibuy.smartdevice.database.SlotsDao revDao =
|
||||
new com.unibuy.smartdevice.database.SlotsDao(handlerMain.getContext());
|
||||
for (int[] r : reportedRevs) {
|
||||
try {
|
||||
SlotStructure s = MyApp.getInstance().getSlotData(r[0], r[1]);
|
||||
if (s == null) continue;
|
||||
if (s.getLockRev() == r[2]) s.setLockSyncedRev(r[2]);
|
||||
if (s.getExpiryRev() == r[3]) s.setExpirySyncedRev(r[3]);
|
||||
revDao.updateSlot(s);
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
revDao.close();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "B009 synced_rev backfill error: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "B009 Failed (" + responseCode + "): " + receive);
|
||||
}
|
||||
@ -593,7 +646,528 @@ public class StarCloudAPI {
|
||||
void onFailure(String message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取貨碼驗證回呼:成功時回傳 code_id。
|
||||
* 綁商品碼 → productId 有值(App 依即時庫存自挑可出貨道),slotNo 為 -1;
|
||||
* 綁貨道碼(舊) → slotNo 有值、productId 為空字串。
|
||||
*/
|
||||
public interface PickupVerificationCallback {
|
||||
void onSuccess(String codeId, int slotNo, String productId);
|
||||
void onFailure(String message);
|
||||
}
|
||||
|
||||
/** 領藥單:單筆出貨明細(一商品可跨多貨道,每筆對應一個貨道與數量)。 */
|
||||
public static class PharmacyPickupItem {
|
||||
public String slotNo;
|
||||
public int productId;
|
||||
public String productName;
|
||||
public int qty;
|
||||
}
|
||||
|
||||
/** 領藥單(雲端)驗證回呼:成功時回傳 order_no(=後端領藥單號/flow_id)、code_id、批價單號與多貨道出貨清單。 */
|
||||
public interface PharmacyPickupCallback {
|
||||
void onSuccess(String orderNo, String codeId, String pricingSlipNo, java.util.List<PharmacyPickupItem> items);
|
||||
void onFailure(String message);
|
||||
}
|
||||
|
||||
/** 系統設定回寫回呼(B016)。 */
|
||||
public interface SettingsUploadCallback {
|
||||
void onSuccess();
|
||||
void onFailure(String message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 來店禮驗證回呼(B690):成功回傳折扣資訊。
|
||||
* discountType:amount(折抵金額)/ percentage(折扣百分比,discountValue 為折扣趴數,八折=20)。
|
||||
*/
|
||||
public interface WelcomeGiftCallback {
|
||||
void onSuccess(String codeId, String discountType, int discountValue, String discountLabel, String name);
|
||||
void onFailure(String message);
|
||||
}
|
||||
|
||||
/**
|
||||
* B016:系統方(identity=system)回寫機台系統設定到雲端 star-cloud。
|
||||
* 使用 B000 登入核發的使用者 Token(auth:sanctum),後端再驗一次系統方權限。
|
||||
* @param settings 後端鍵名的開關 JSON(鍵名對照見 SystemSetActivity#buildBackendSettings)。
|
||||
*/
|
||||
public void uploadSystemSettings(final JSONObject settings, final SettingsUploadCallback callback) {
|
||||
final String url = getBaseUrl() + "machine/setting/B016";
|
||||
final String machineId = MyApp.getInstance().getMachine().getMachineID();
|
||||
final String loginToken = MyApp.getInstance().getLoginToken();
|
||||
Log.i(TAG, "Starting B016 (update settings) for machine: " + machineId);
|
||||
|
||||
if (loginToken == null || loginToken.isEmpty()) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(
|
||||
() -> callback.onFailure("尚未登入或登入逾時,請重新登入後台"));
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("machine", machineId);
|
||||
json.put("settings", settings);
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("PATCH");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + loginToken);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
conn.setDoOutput(true);
|
||||
conn.getOutputStream().write(json.toString().getBytes("UTF-8"));
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
Log.d(TAG, "B016 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B016 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(
|
||||
() -> callback.onFailure("連線失敗: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
callback.onSuccess();
|
||||
} else {
|
||||
callback.onFailure(res.optString("message", "設定儲存失敗"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("解析錯誤 (" + finalCode + ")");
|
||||
}
|
||||
} else if (finalCode == 403) {
|
||||
callback.onFailure("權限不足:僅系統方可修改系統設定");
|
||||
} else if (finalCode == 401) {
|
||||
callback.onFailure("登入逾時,請重新登入後台");
|
||||
} else if (finalCode == 404) {
|
||||
callback.onFailure("查無此機台");
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* B660: 取貨碼驗證(邏輯比照 B680 員工卡,驗證通道不同)。
|
||||
* 成功回傳 data.code_id 與 data.slot_no(要出貨的貨道)。
|
||||
*/
|
||||
public void verifyPickupCode(final String code, final PickupVerificationCallback callback) {
|
||||
final String url = getBaseUrl() + "machine/pickup/verify/B660";
|
||||
Log.i(TAG, "Starting B660 request for code: " + code);
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("code", code);
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
}
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
conn.setDoOutput(true);
|
||||
conn.getOutputStream().write(json.toString().getBytes("UTF-8"));
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
Log.d(TAG, "B660 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B660 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> callback.onFailure("連線失敗: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONObject data = res.optJSONObject("data");
|
||||
String codeId = data != null ? data.optString("code_id", "") : "";
|
||||
int slotNo = data != null ? data.optInt("slot_no", -1) : -1;
|
||||
// product_id 為數字且可能為 JSON null;用 optInt 避免 optString 把 null 轉成 "null" 字串
|
||||
int pid = data != null ? data.optInt("product_id", 0) : 0;
|
||||
String productId = pid > 0 ? String.valueOf(pid) : "";
|
||||
callback.onSuccess(codeId, slotNo, productId);
|
||||
} else {
|
||||
callback.onFailure(res.optString("message", "取貨碼驗證失敗"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("解析錯誤: " + finalCode);
|
||||
}
|
||||
} else if (finalCode == 404) {
|
||||
callback.onFailure("查無此取貨碼或已使用");
|
||||
} else if (finalCode == 429) {
|
||||
callback.onFailure("錯誤次數過多,請稍後再試");
|
||||
} else if (finalCode == 401) {
|
||||
callback.onFailure("系統認證失效,請聯繫管理員");
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 領藥單(雲端)驗證 B660:與 verifyPickupCode 同一端點,但解析回應的 order_no 與多貨道 items[]。
|
||||
* 後端回應 data:{ slot_no(可能 null), code_id, order_no, order_type:"pharmacy_pickup",
|
||||
* items:[{slot_no, product_id, product_name, qty}, ...] }
|
||||
*/
|
||||
public void verifyPharmacyPickupCode(final String code, final PharmacyPickupCallback callback) {
|
||||
final String url = getBaseUrl() + "machine/pickup/verify/B660";
|
||||
Log.i(TAG, "Starting B660 (pharmacy) request for code: " + code);
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("code", code);
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
}
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
conn.setDoOutput(true);
|
||||
conn.getOutputStream().write(json.toString().getBytes("UTF-8"));
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
Log.d(TAG, "B660(pharmacy) Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B660(pharmacy) Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> callback.onFailure("連線失敗: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
// 只把「JSON 解析」放進 try;onSuccess/onFailure 移到 try 之外,
|
||||
// 避免 onSuccess 內的後續處理(組購物車/開對話框)丟例外時被誤判成「解析錯誤」。
|
||||
String pOrderNo = "";
|
||||
String pCodeId = "";
|
||||
String pPricingSlipNo = "";
|
||||
java.util.List<PharmacyPickupItem> pItems = null;
|
||||
String pFail = null;
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONObject data = res.optJSONObject("data");
|
||||
pOrderNo = data != null ? data.optString("order_no", "") : "";
|
||||
pCodeId = data != null ? data.optString("code_id", "") : "";
|
||||
pPricingSlipNo = data != null ? data.optString("pricing_slip_no", "") : "";
|
||||
pItems = new java.util.ArrayList<>();
|
||||
org.json.JSONArray arr = data != null ? data.optJSONArray("items") : null;
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject it = arr.optJSONObject(i);
|
||||
if (it == null) continue;
|
||||
PharmacyPickupItem item = new PharmacyPickupItem();
|
||||
item.slotNo = it.optString("slot_no", "");
|
||||
item.productId = it.optInt("product_id", 0);
|
||||
item.productName = it.optString("product_name", "");
|
||||
item.qty = it.optInt("qty", 0);
|
||||
pItems.add(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pFail = res.optString("message", "領藥碼驗證失敗");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
pFail = "解析錯誤: " + finalCode;
|
||||
}
|
||||
|
||||
if (pFail != null) {
|
||||
callback.onFailure(pFail);
|
||||
} else if (pOrderNo.isEmpty() || pItems == null || pItems.isEmpty()) {
|
||||
callback.onFailure("領藥單回應不完整(缺 order_no 或 items)");
|
||||
} else {
|
||||
callback.onSuccess(pOrderNo, pCodeId, pPricingSlipNo, pItems);
|
||||
}
|
||||
} else if (finalCode == 404) {
|
||||
callback.onFailure("查無此領藥碼或已領取");
|
||||
} else if (finalCode == 429) {
|
||||
callback.onFailure("錯誤次數過多,請稍後再試");
|
||||
} else if (finalCode == 403) {
|
||||
callback.onFailure("此機台未啟用領藥單");
|
||||
} else if (finalCode == 401) {
|
||||
callback.onFailure("系統認證失效,請聯繫管理員");
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* B670: 通行碼驗證(技師用,邏輯比照 B680,驗證通道不同)。
|
||||
* 成功回傳 data.code_id。
|
||||
*/
|
||||
public void verifyPassCode(final String code, final VerificationCallback callback) {
|
||||
final String url = getBaseUrl() + "machine/passcode/verify/B670";
|
||||
Log.i(TAG, "Starting B670 request for code: " + code);
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("code", code);
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
}
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
conn.setDoOutput(true);
|
||||
conn.getOutputStream().write(json.toString().getBytes("UTF-8"));
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
Log.d(TAG, "B670 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B670 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> callback.onFailure("連線失敗: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
String codeId = "";
|
||||
if (res.has("data")) {
|
||||
codeId = res.optJSONObject("data").optString("code_id", "");
|
||||
} else {
|
||||
codeId = res.optString("code_id", "");
|
||||
}
|
||||
callback.onSuccess(codeId);
|
||||
} else {
|
||||
callback.onFailure(res.optString("message", "通行碼驗證失敗"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("解析錯誤: " + finalCode);
|
||||
}
|
||||
} else if (finalCode == 404) {
|
||||
callback.onFailure("查無此通行碼或已失效");
|
||||
} else if (finalCode == 429) {
|
||||
callback.onFailure("錯誤次數過多,請稍後再試");
|
||||
} else if (finalCode == 401) {
|
||||
callback.onFailure("系統認證失效,請聯繫管理員");
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* B690: 來店禮驗證(邏輯比照 B660/B670,驗證通道不同)。
|
||||
* 成功回傳 data.code_id 與折扣資訊(discount_type/discount_value/discount_label/name)。
|
||||
*/
|
||||
public void verifyWelcomeGift(final String code, final WelcomeGiftCallback callback) {
|
||||
final String url = getBaseUrl() + "machine/welcome-gift/verify/B690";
|
||||
Log.i(TAG, "Starting B690 request for code: " + code);
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int responseCode = -1;
|
||||
String receive = "";
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("code", code);
|
||||
|
||||
java.net.URL reqUrl = new java.net.URL(url);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) reqUrl.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
String machineToken = MyApp.getInstance().getMachine().getApiKey();
|
||||
if (machineToken != null && !machineToken.isEmpty()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + machineToken);
|
||||
}
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
conn.setDoOutput(true);
|
||||
conn.getOutputStream().write(json.toString().getBytes("UTF-8"));
|
||||
conn.getOutputStream().flush();
|
||||
|
||||
responseCode = conn.getResponseCode();
|
||||
java.io.InputStream is = (responseCode >= 200 && responseCode < 300)
|
||||
? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is != null) {
|
||||
java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
receive = sb.toString();
|
||||
}
|
||||
conn.disconnect();
|
||||
Log.d(TAG, "B690 Response (" + responseCode + "): " + receive);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "B690 Network Error", e);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> callback.onFailure("連線失敗: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
final int finalCode = responseCode;
|
||||
final String finalReceive = receive;
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
if (finalCode >= 200 && finalCode < 300) {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
if (res.optBoolean("success", false)) {
|
||||
JSONObject data = res.optJSONObject("data");
|
||||
String codeId = data != null ? data.optString("code_id", "") : "";
|
||||
String discountType = data != null ? data.optString("discount_type", "") : "";
|
||||
int discountValue = data != null ? data.optInt("discount_value", 0) : 0;
|
||||
String discountLabel = data != null ? data.optString("discount_label", "") : "";
|
||||
String name = data != null ? data.optString("name", "") : "";
|
||||
callback.onSuccess(codeId, discountType, discountValue, discountLabel, name);
|
||||
} else {
|
||||
callback.onFailure(res.optString("message", "來店禮驗證失敗"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("解析錯誤: " + finalCode);
|
||||
}
|
||||
} else if (finalCode == 404) {
|
||||
callback.onFailure("查無此來店禮或已失效");
|
||||
} else if (finalCode == 429) {
|
||||
callback.onFailure("錯誤次數過多,請稍後再試");
|
||||
} else if (finalCode == 401) {
|
||||
callback.onFailure("系統認證失效,請聯繫管理員");
|
||||
} else if (finalCode == 400) {
|
||||
callback.onFailure("來店禮格式錯誤(需 8 碼)");
|
||||
} else {
|
||||
try {
|
||||
JSONObject res = new JSONObject(finalReceive);
|
||||
callback.onFailure(res.optString("message", "系統連線錯誤 (" + finalCode + ")"));
|
||||
} catch (Exception e) {
|
||||
callback.onFailure("系統連線錯誤 (" + finalCode + ")");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void saveSettings(JSONObject config) {
|
||||
saveSettings(config, true);
|
||||
}
|
||||
|
||||
private void saveSettings(JSONObject config, boolean reconnectMqtt) {
|
||||
// 使用 optString 避免欄位缺失導致 JSONException
|
||||
|
||||
// 1. 儲存 ApiToken 與 序號
|
||||
@ -623,9 +1197,113 @@ public class StarCloudAPI {
|
||||
MyApp.getInstance().getGreenInvoice().setGreenHashIV(config.optString("t050v36", ""));
|
||||
MyApp.getInstance().getGreenInvoice().setGreenEmail(config.optString("t050v38", ""));
|
||||
|
||||
// 5. 重要:將所有設定同步回資料庫,防止 App 重啟後消失
|
||||
// 5. 解析雲端功能開關(缺漏「欄位」→ false;缺漏「整個物件」→ 不動本地,避免連線異常把現場全關)
|
||||
JSONObject devSet = config.optJSONObject("DevSet");
|
||||
if (devSet != null) {
|
||||
DevSetStructure d = MyApp.getInstance().getDevSet();
|
||||
d.setShoppingCar(devSet.optBoolean("ShoppingCar", false));
|
||||
d.setInvoice(devSet.optBoolean("Invoice", false));
|
||||
d.setDevNFCPay(devSet.optBoolean("DevNFCPay", false));
|
||||
d.setDevEsunPay(devSet.optBoolean("DevEsunPay", false));
|
||||
d.setDevTapPay(devSet.optBoolean("DevTapPay", false));
|
||||
d.setDevCash(devSet.optBoolean("DevCash", false));
|
||||
d.setDevLinePay(devSet.optBoolean("DevLinePay", false));
|
||||
d.setTapPay30(devSet.optBoolean("TapPay30", false));
|
||||
d.setTapPay31(devSet.optBoolean("TapPay31", false));
|
||||
d.setTapPay32(devSet.optBoolean("TapPay32", false));
|
||||
d.setTapPay33(devSet.optBoolean("TapPay33", false));
|
||||
d.setTapPay34(devSet.optBoolean("TapPay34", false));
|
||||
// B014 新增支付細項(刷卡機/掃碼子開關)
|
||||
d.setDevCreditCard(devSet.optBoolean("DevCreditCard", false));
|
||||
d.setDevMobilePay(devSet.optBoolean("DevMobilePay", false));
|
||||
d.setDevCardPay(devSet.optBoolean("DevCardPay", false));
|
||||
d.setDevScanPay(devSet.optBoolean("DevScanPay", false));
|
||||
// VMC/Electic 雲端不下發 → 不碰,保留本地值
|
||||
}
|
||||
JSONObject cashSet = config.optJSONObject("CashSet");
|
||||
if (cashSet != null) {
|
||||
CashSetStructure c = MyApp.getInstance().getCashSetStructure();
|
||||
c.setBillF1000(cashSet.optBoolean("BillF1000", false));
|
||||
c.setBillE500(cashSet.optBoolean("BillE500", false));
|
||||
c.setBillD100(cashSet.optBoolean("BillD100", false));
|
||||
c.setCoinF50(cashSet.optBoolean("CoinF50", false));
|
||||
c.setCoinE10(cashSet.optBoolean("CoinE10", false));
|
||||
c.setCoinD5(cashSet.optBoolean("CoinD5", false));
|
||||
c.setCoinC1(cashSet.optBoolean("CoinC1", false));
|
||||
}
|
||||
// 6. FunctionSet:取貨模組(母)/取貨碼/通行碼/來店禮/會員/環溫
|
||||
// 取貨碼、通行碼從屬取貨模組(PickupModule);來店禮獨立判斷。
|
||||
JSONObject funcSet = config.optJSONObject("FunctionSet");
|
||||
if (funcSet != null) {
|
||||
DevSetStructure d = MyApp.getInstance().getDevSet();
|
||||
d.setPickupModule(funcSet.optBoolean("PickupModule", false));
|
||||
d.setPharmacyPickup(funcSet.optBoolean("PharmacyPickup", false));
|
||||
d.setPickupCode(funcSet.optBoolean("PickupCode", false));
|
||||
d.setPassCode(funcSet.optBoolean("PassCode", false));
|
||||
d.setWelcomeGift(funcSet.optBoolean("WelcomeGift", false));
|
||||
d.setMemberSystem(funcSet.optBoolean("MemberSystem", false));
|
||||
d.setAmbientTemp(funcSet.optBoolean("AmbientTemp", false));
|
||||
}
|
||||
// 6c. HardwareSet 貨道型式(缺整個物件→不動本地,比照 DevSet/CashSet 防呆)
|
||||
JSONObject hardwareSet = config.optJSONObject("HardwareSet");
|
||||
if (hardwareSet != null) {
|
||||
com.unibuy.smartdevice.structure.HardwareSetStructure h = MyApp.getInstance().getHardwareSetStructure();
|
||||
h.setSpringSlot1_10(hardwareSet.optBoolean("SpringSlot1_10", false));
|
||||
h.setSpringSlot11_20(hardwareSet.optBoolean("SpringSlot11_20", false));
|
||||
h.setSpringSlot21_30(hardwareSet.optBoolean("SpringSlot21_30", false));
|
||||
h.setSpringSlot31_40(hardwareSet.optBoolean("SpringSlot31_40", false));
|
||||
h.setSpringSlot41_50(hardwareSet.optBoolean("SpringSlot41_50", false));
|
||||
h.setSpringSlot51_60(hardwareSet.optBoolean("SpringSlot51_60", false));
|
||||
}
|
||||
// 6b. ShoppingMode:購買流程模式(basic/employee_card/pickup_sheet),取代舊 project flavor
|
||||
boolean hasShoppingMode = config.has("ShoppingMode");
|
||||
if (hasShoppingMode) {
|
||||
MyApp.getInstance().setShoppingMode(config.optString("ShoppingMode", "basic"));
|
||||
}
|
||||
|
||||
// 6d. LangSet:機台啟用語系(最多 5 種,有序,第 0 個為預設)。後台單向下發,機台僅顯示與切換。
|
||||
JSONObject langSet = config.optJSONObject("LangSet");
|
||||
if (langSet != null) {
|
||||
java.util.List<String> langs = new java.util.ArrayList<>();
|
||||
org.json.JSONArray arr = langSet.optJSONArray("Languages");
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
String code = arr.optString(i, "");
|
||||
if (code != null && !code.isEmpty()) {
|
||||
langs.add(com.unibuy.smartdevice.utils.LanguageHelper.canonicalize(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
MyApp.getInstance().setMachineLanguages(langs);
|
||||
String def = langSet.optString("Default", "");
|
||||
String canonicalDef = (def == null || def.isEmpty())
|
||||
? (langs.isEmpty() ? "" : langs.get(0))
|
||||
: com.unibuy.smartdevice.utils.LanguageHelper.canonicalize(def);
|
||||
MyApp.getInstance().setDefaultLanguage(canonicalDef);
|
||||
|
||||
// 後台優先:每次下發都以機台預設語系為準,立即套用(覆蓋使用者先前手動選擇)。
|
||||
// 設定下發頻率極低且皆在無人時段,不會打斷使用者操作。
|
||||
if (!canonicalDef.isEmpty()) {
|
||||
com.unibuy.smartdevice.utils.LanguageHelper.changeLanguage(handlerMain.getContext(), canonicalDef);
|
||||
}
|
||||
}
|
||||
|
||||
// 6e. UISet:後台「APP UI 元素設定」下發(部位 part_key → 圖片 URL)。
|
||||
// 落地供各部位 UiSkin.apply 套用;缺整個物件→不動本地既有換膚。
|
||||
JSONObject uiSet = config.optJSONObject("UISet");
|
||||
if (uiSet != null) {
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.save(handlerMain.getContext(), uiSet);
|
||||
}
|
||||
|
||||
if (devSet != null || cashSet != null || funcSet != null || hardwareSet != null || hasShoppingMode || langSet != null) {
|
||||
MyApp.saveSystemSet(handlerMain.getContext());
|
||||
}
|
||||
|
||||
// 7. 重要:將所有設定同步回資料庫,防止 App 重啟後消失
|
||||
persistCurrentSettings();
|
||||
MyApp.getInstance().initMqtt();
|
||||
if (reconnectMqtt) {
|
||||
MyApp.getInstance().initMqtt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -635,7 +1313,12 @@ public class StarCloudAPI {
|
||||
Log.i(TAG, "persistCurrentSettings() - Saving to DB: machineID=" + MyApp.getInstance().getMachine().getMachineID()
|
||||
+ ", token=" + MyApp.getInstance().getMachine().getApiKey());
|
||||
SettingsDao settingsDao = new SettingsDao(handlerMain.getContext());
|
||||
settingsDao.deleteAll();
|
||||
// 只刪除本方法接下來要重寫的這 4 個設定 key,避免 deleteAll() 連坐刪掉廣告(VideoEoxPlayer)等其他持久資料。
|
||||
// 否則「遠端更新設定→重啟」會因廣告列被清空且啟動不會主動抓 B005,導致廣告消失。
|
||||
settingsDao.deleteWhere(MyApp.getInstance().getMachine().getClass().getSimpleName());
|
||||
settingsDao.deleteWhere(MyApp.getInstance().getEsunpay().getClass().getSimpleName());
|
||||
settingsDao.deleteWhere(MyApp.getInstance().getGreenInvoice().getClass().getSimpleName());
|
||||
settingsDao.deleteWhere(MyApp.getInstance().getGreenpaySetting().getClass().getSimpleName());
|
||||
|
||||
settingsDao.insertOne(new SettingStructure(MyApp.getInstance().getMachine().getClass().getSimpleName(),
|
||||
new String[] {
|
||||
@ -672,6 +1355,12 @@ public class StarCloudAPI {
|
||||
|
||||
private void saveProducts(JSONArray products) throws JSONException {
|
||||
MyApp.getInstance().getProductList().clear();
|
||||
// 商品名稱多語 blob:商品ID → t060v01_i18n(底線代碼 key),存於 MemoDao 持久化(不動 products 表)
|
||||
JSONObject nameI18nBlob = new JSONObject();
|
||||
// 商品規格多語 blob:商品ID → t060v03_i18n(純文字含換行)
|
||||
JSONObject specI18nBlob = new JSONObject();
|
||||
// 商品彈簧補貨上限 blob:商品ID → spring_limit。履帶上限沿用 t060v11(=track_limit) 進 products 表,故不另存。
|
||||
JSONObject springLimitBlob = new JSONObject();
|
||||
for (int i = 0; i < products.length(); i++) {
|
||||
JSONObject p = products.getJSONObject(i);
|
||||
|
||||
@ -688,8 +1377,47 @@ public class StarCloudAPI {
|
||||
p.optString("t060v41", ""), // Material
|
||||
p.optString("t060v01_en", ""),
|
||||
p.optString("t060v01_jp", ""));
|
||||
|
||||
// 新增:商品名稱多語 map(B012 t060v01_i18n),支援後台啟用的所有語系
|
||||
JSONObject nameI18n = p.optJSONObject("t060v01_i18n");
|
||||
if (nameI18n != null) {
|
||||
product.setNameI18nJson(nameI18n.toString());
|
||||
String pid = p.optString("t060v00", "");
|
||||
if (!pid.isEmpty()) {
|
||||
try { nameI18nBlob.put(pid, nameI18n); } catch (JSONException ignore) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:商品規格多語 map(B012 t060v03_i18n,純文字含換行)
|
||||
JSONObject specI18n = p.optJSONObject("t060v03_i18n");
|
||||
if (specI18n != null) {
|
||||
product.setSpecI18nJson(specI18n.toString());
|
||||
String pid = p.optString("t060v00", "");
|
||||
if (!pid.isEmpty()) {
|
||||
try { specI18nBlob.put(pid, specI18n); } catch (JSONException ignore) {}
|
||||
}
|
||||
}
|
||||
// 履帶/彈簧 補貨上限(B012 spring_limit / track_limit)。track 沿用 slotUpperLimit(t060v11);
|
||||
// spring 另存物件欄位 + blob(不動 products 表 schema,避免升 DB 版本洗掉貨道綁定)。
|
||||
int springLimit = p.optInt("spring_limit", -1);
|
||||
if (springLimit >= 0) {
|
||||
product.setSpringLimit(springLimit);
|
||||
String pid = p.optString("t060v00", "");
|
||||
if (!pid.isEmpty()) {
|
||||
try { springLimitBlob.put(pid, springLimit); } catch (JSONException ignore) {}
|
||||
}
|
||||
}
|
||||
|
||||
MyApp.getInstance().getProductList().add(product);
|
||||
}
|
||||
// 持久化商品多語 blob,供離線重啟時 fallback
|
||||
MyApp.getInstance().setProductNameI18nBlob(nameI18nBlob);
|
||||
MyApp.saveProductNameI18n(handlerMain.getContext());
|
||||
MyApp.getInstance().setProductSpecI18nBlob(specI18nBlob);
|
||||
MyApp.saveProductSpecI18n(handlerMain.getContext());
|
||||
// 持久化彈簧補貨上限 blob,供離線重啟時一鍵補貨 fallback
|
||||
MyApp.getInstance().setProductSpringLimitBlob(springLimitBlob);
|
||||
MyApp.saveProductSpringLimit(handlerMain.getContext());
|
||||
|
||||
// 將商品同步至資料庫
|
||||
ProductsDao productsDao = new ProductsDao(handlerMain.getContext());
|
||||
|
||||
@ -27,6 +27,10 @@ public class MqttManager {
|
||||
private static final String TAG = "MqttManager";
|
||||
private static MqttManager instance;
|
||||
private Mqtt3AsyncClient client;
|
||||
|
||||
// 指令去重(保險):同一 command_id 短時間內只處理一次,防 MQTT 重複投遞(QoS1 重送等)導致同指令被執行多次
|
||||
private final java.util.Map<String, Long> recentCmdIds = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private static final long CMD_DEDUP_WINDOW_MS = 30000L;
|
||||
private final Gson gson = new Gson();
|
||||
private com.unibuy.smartdevice.exception.Logs logs;
|
||||
private String serialNo;
|
||||
@ -78,20 +82,29 @@ public class MqttManager {
|
||||
}
|
||||
}
|
||||
|
||||
String serverHost = "mqtt.setdomains.work";
|
||||
if ("prod".equals(MyApp.getInstance().getMachine().getMqttEnv())) {
|
||||
serverHost = "emqx.taiwan-star.com.tw";
|
||||
}
|
||||
String env = MyApp.getInstance().getMachine().getMqttEnv();
|
||||
boolean prod = "prod".equals(env);
|
||||
boolean local = "local".equals(env); // 130 測試站
|
||||
String serverHost = prod ? "emqx.taiwan-star.com.tw"
|
||||
: (local ? "192.168.0.130" : "mqtt.setdomains.work");
|
||||
|
||||
client = MqttClient.builder()
|
||||
com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder builder = MqttClient.builder()
|
||||
.useMqttVersion3()
|
||||
.identifier(clientId)
|
||||
.serverHost(serverHost)
|
||||
.serverPort(443)
|
||||
.useSslWithDefaultConfig()
|
||||
.webSocketConfig()
|
||||
.serverPath("/mqtt")
|
||||
.applyWebSocketConfig()
|
||||
.serverHost(serverHost);
|
||||
|
||||
if (local) {
|
||||
// 130 測試 EMQX:WebSocket 8083、無 TLS
|
||||
builder = builder.serverPort(8083)
|
||||
.webSocketConfig().serverPath("/mqtt").applyWebSocketConfig();
|
||||
} else {
|
||||
// prod / demo:WSS 443
|
||||
builder = builder.serverPort(443)
|
||||
.useSslWithDefaultConfig()
|
||||
.webSocketConfig().serverPath("/mqtt").applyWebSocketConfig();
|
||||
}
|
||||
|
||||
client = builder
|
||||
.simpleAuth()
|
||||
.username(machineId)
|
||||
.password(apiToken.getBytes(StandardCharsets.UTF_8))
|
||||
@ -102,9 +115,56 @@ public class MqttManager {
|
||||
publishStatus("online");
|
||||
subscribeToCommandTopic();
|
||||
startHeartbeatScheduler();
|
||||
// 連線就緒後接手未完成的 OTA(續傳監看 / 安裝後版本確認回報),
|
||||
// 確保下載被重啟打斷時能續傳完成,且終態 ACK 不會因尚未連線而遺失。
|
||||
try {
|
||||
com.unibuy.smartdevice.tools.OtaManager.resumeIfPending(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getApplicationContext());
|
||||
} 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();
|
||||
|
||||
// 收訊回呼「每個 client 只註冊一次」的全域 publishes(取代每次重連 subscribe 都掛一個 callback 的舊寫法)。
|
||||
// 舊寫法會在一整天斷線重連中累積數十個 callback,導致同一則指令被處理數十次(OTA 因此並行互相取消而失敗)。
|
||||
final String globalCommandTopic = "machine/" + machineId + "/command";
|
||||
client.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> {
|
||||
try {
|
||||
if (!globalCommandTopic.equals(publish.getTopic().toString())) {
|
||||
return;
|
||||
}
|
||||
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
|
||||
logs.info("Received command payload: " + payloadString);
|
||||
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
|
||||
if (commandListener != null && command != null) {
|
||||
// 去重:同一 command_id 短時間內重複投遞只處理一次
|
||||
String cid = command.getCommand_id();
|
||||
if (cid != null && !cid.isEmpty()) {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
Long lastMs = recentCmdIds.put(cid, nowMs);
|
||||
if (lastMs != null && (nowMs - lastMs) < CMD_DEDUP_WINDOW_MS) {
|
||||
logs.info("Duplicate MQTT command ignored (ID: " + cid + ")");
|
||||
return;
|
||||
}
|
||||
if (recentCmdIds.size() > 200) {
|
||||
recentCmdIds.entrySet().removeIf(en -> nowMs - en.getValue() > 60000L);
|
||||
}
|
||||
}
|
||||
commandListener.onCommandReceived(command);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("Error parsing command payload: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
client.connectWith()
|
||||
.cleanSession(true)
|
||||
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 秒,加快 LWT 觸發
|
||||
@ -141,6 +201,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());
|
||||
}
|
||||
@ -224,21 +288,11 @@ public class MqttManager {
|
||||
return;
|
||||
|
||||
String commandTopic = "machine/" + serialNo + "/command";
|
||||
// 只送 SUBSCRIBE 建立訂閱(cleanSession 每次重連都要重送);不再於此掛 callback,
|
||||
// 收訊統一走 connect() 內註冊一次的全域 publishes 回呼,避免重連累積多個 callback。
|
||||
client.subscribeWith()
|
||||
.topicFilter(commandTopic)
|
||||
.qos(MqttQos.AT_LEAST_ONCE)
|
||||
.callback(publish -> {
|
||||
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
|
||||
logs.info("Received command payload: " + payloadString);
|
||||
try {
|
||||
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
|
||||
if (commandListener != null && command != null) {
|
||||
commandListener.onCommandReceived(command);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("Error parsing command payload: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.send()
|
||||
.whenComplete((subAck, throwable) -> {
|
||||
if (throwable != null) {
|
||||
@ -353,14 +407,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))
|
||||
@ -368,11 +434,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) {
|
||||
@ -173,31 +190,57 @@ public class MqttService extends Service {
|
||||
System.exit(0);
|
||||
});
|
||||
}), 500);
|
||||
} else if ("nfc_reset".equals(cmd)) {
|
||||
} else if ("nfc_reset".equals(cmd) || "reboot_card".equals(cmd)) {
|
||||
// 後台送 reboot_card、App 舊名 nfc_reset,兩者皆相容
|
||||
logs.info("Executing remote NFC reset command...");
|
||||
try {
|
||||
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
|
||||
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
|
||||
getApplicationContext());
|
||||
comPort2.send(devNFCPay.reset());
|
||||
mqttManager.publishCommandAck(cmdId, "success", "NFC reset command sent");
|
||||
} catch (Exception e) {
|
||||
logs.info("NFC reset failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "NFC reset failed: " + e.getMessage());
|
||||
String busyReason = com.unibuy.smartdevice.MyApp.getInstance().getBusyReason();
|
||||
if (!busyReason.isEmpty()) {
|
||||
// 現場正在交易:拒收,避免與現場刷卡撞 comPort2(撞埠會觸發 Rs232Dev mock)
|
||||
logs.info("NFC reset rejected, machine busy: " + busyReason);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Machine busy: " + busyReason);
|
||||
} else {
|
||||
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = null;
|
||||
try {
|
||||
comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
|
||||
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
|
||||
getApplicationContext());
|
||||
comPort2.send(devNFCPay.reset());
|
||||
mqttManager.publishCommandAck(cmdId, "success", "NFC reset command sent");
|
||||
} catch (Exception e) {
|
||||
logs.info("NFC reset failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "NFC reset failed: " + e.getMessage());
|
||||
} finally {
|
||||
// 送完務必關埠,避免序列埠洩漏導致下一筆現場交易開不了埠
|
||||
if (comPort2 != null) {
|
||||
try { comPort2.closePort(); } catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("nfc_checkout".equals(cmd)) {
|
||||
} else if ("nfc_checkout".equals(cmd) || "checkout".equals(cmd)) {
|
||||
// 後台送 checkout、App 舊名 nfc_checkout,兩者皆相容
|
||||
logs.info("Executing remote NFC checkout command...");
|
||||
try {
|
||||
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
|
||||
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
|
||||
getApplicationContext());
|
||||
comPort2.send(devNFCPay.checkout());
|
||||
mqttManager.publishCommandAck(cmdId, "success", "NFC checkout command sent");
|
||||
} catch (Exception e) {
|
||||
logs.info("NFC checkout failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "NFC checkout failed: " + e.getMessage());
|
||||
String busyReason = com.unibuy.smartdevice.MyApp.getInstance().getBusyReason();
|
||||
if (!busyReason.isEmpty()) {
|
||||
logs.info("NFC checkout rejected, machine busy: " + busyReason);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Machine busy: " + busyReason);
|
||||
} else {
|
||||
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = null;
|
||||
try {
|
||||
comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
|
||||
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
|
||||
getApplicationContext());
|
||||
comPort2.send(devNFCPay.checkout());
|
||||
mqttManager.publishCommandAck(cmdId, "success", "NFC checkout command sent");
|
||||
} catch (Exception e) {
|
||||
logs.info("NFC checkout failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "NFC checkout failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (comPort2 != null) {
|
||||
try { comPort2.closePort(); } catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("change".equals(cmd)) {
|
||||
logs.info("Executing remote change command...");
|
||||
@ -339,7 +382,15 @@ public class MqttService extends Service {
|
||||
int slotNo = -1;
|
||||
int stock = -1;
|
||||
String expiryDate = null;
|
||||
boolean hasExpiryKey = false;
|
||||
String batchNo = null;
|
||||
boolean hasBatchKey = false;
|
||||
boolean isLocked = false;
|
||||
boolean hasLock = false;
|
||||
int lockRev = 0;
|
||||
boolean hasLockRev = false;
|
||||
int expiryRev = 0;
|
||||
boolean hasExpiryRev = false;
|
||||
|
||||
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
|
||||
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
|
||||
@ -347,10 +398,28 @@ public class MqttService extends Service {
|
||||
slotNo = payloadObj.get("slot_no").getAsInt();
|
||||
if (payloadObj.has("stock"))
|
||||
stock = payloadObj.get("stock").getAsInt();
|
||||
if (payloadObj.has("expiry_date") && !payloadObj.get("expiry_date").isJsonNull())
|
||||
expiryDate = payloadObj.get("expiry_date").getAsString();
|
||||
if (payloadObj.has("batch_no") && !payloadObj.get("batch_no").isJsonNull())
|
||||
batchNo = payloadObj.get("batch_no").getAsString();
|
||||
if (payloadObj.has("expiry_date")) {
|
||||
hasExpiryKey = true;
|
||||
if (!payloadObj.get("expiry_date").isJsonNull())
|
||||
expiryDate = payloadObj.get("expiry_date").getAsString();
|
||||
}
|
||||
if (payloadObj.has("batch_no")) {
|
||||
hasBatchKey = true;
|
||||
if (!payloadObj.get("batch_no").isJsonNull())
|
||||
batchNo = payloadObj.get("batch_no").getAsString();
|
||||
}
|
||||
if (payloadObj.has("is_locked") && !payloadObj.get("is_locked").isJsonNull()) {
|
||||
hasLock = true;
|
||||
isLocked = payloadObj.get("is_locked").getAsBoolean();
|
||||
}
|
||||
if (payloadObj.has("lock_rev") && !payloadObj.get("lock_rev").isJsonNull()) {
|
||||
hasLockRev = true;
|
||||
lockRev = payloadObj.get("lock_rev").getAsInt();
|
||||
}
|
||||
if (payloadObj.has("expiry_rev") && !payloadObj.get("expiry_rev").isJsonNull()) {
|
||||
hasExpiryRev = true;
|
||||
expiryRev = payloadObj.get("expiry_rev").getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
if (slotNo <= 0 || stock < 0) {
|
||||
@ -364,24 +433,57 @@ public class MqttService extends Service {
|
||||
if (slotData != null) {
|
||||
slotData.setCount(stock);
|
||||
|
||||
// 更新選填欄位至 otherData
|
||||
org.json.JSONObject otherData = slotData.getOtherData();
|
||||
if (otherData == null)
|
||||
otherData = new org.json.JSONObject();
|
||||
try {
|
||||
if (expiryDate != null)
|
||||
otherData.put("expiry_date", expiryDate);
|
||||
if (batchNo != null)
|
||||
otherData.put("batch_no", batchNo);
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
|
||||
// 鎖定 LWW:本地 rev>synced_rev 表示有未上報的現場編輯 → 拒絕被下行覆蓋
|
||||
if (hasLock) {
|
||||
if (slotData.getLockRev() <= slotData.getLockSyncedRev()) {
|
||||
slotData.setLock(isLocked);
|
||||
int r = hasLockRev ? lockRev : slotData.getLockSyncedRev();
|
||||
slotData.setLockRev(r);
|
||||
slotData.setLockSyncedRev(r);
|
||||
} else {
|
||||
logs.info("update_inventory: 本地有未上報鎖定編輯,略過下行 is_locked slot=" + slotNo);
|
||||
}
|
||||
}
|
||||
|
||||
// 效期/批號 LWW(只動「下行確實帶了該鍵」的欄位,避免缺鍵時誤清本地效期)
|
||||
if (hasExpiryRev) {
|
||||
if (slotData.getExpiryRev() <= slotData.getExpirySyncedRev()) {
|
||||
try {
|
||||
if (hasExpiryKey) {
|
||||
if (expiryDate != null) otherData.put("expiry_date", expiryDate);
|
||||
else otherData.remove("expiry_date");
|
||||
}
|
||||
if (hasBatchKey) {
|
||||
if (batchNo != null) otherData.put("batch_no", batchNo);
|
||||
else otherData.remove("batch_no");
|
||||
}
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
}
|
||||
slotData.setExpiryRev(expiryRev);
|
||||
slotData.setExpirySyncedRev(expiryRev);
|
||||
} else {
|
||||
logs.info("update_inventory: 本地有未上報效期編輯,略過下行 expiry slot=" + slotNo);
|
||||
}
|
||||
} else {
|
||||
// 舊版相容:無 expiry_rev,沿用原本「有給就寫」行為
|
||||
try {
|
||||
if (expiryDate != null) otherData.put("expiry_date", expiryDate);
|
||||
if (batchNo != null) otherData.put("batch_no", batchNo);
|
||||
} catch (org.json.JSONException e) {
|
||||
logs.info("Failed to update JSON otherData: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
slotData.setOtherData(otherData);
|
||||
|
||||
// 單行更新,避免 deleteAll()+insertAll() 全表重寫與 UI 存檔 race
|
||||
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(
|
||||
getApplicationContext());
|
||||
slotsDao.deleteAll();
|
||||
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
|
||||
slotsDao.updateSlot(slotData);
|
||||
slotsDao.close();
|
||||
|
||||
logs.info("Inventory updated: slot=" + slotNo + ", stock=" + stock);
|
||||
@ -452,24 +554,73 @@ public class MqttService extends Service {
|
||||
logs.info("Executing remote app OTA update command...");
|
||||
try {
|
||||
String apkUrl = null;
|
||||
String targetVersion = null;
|
||||
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
|
||||
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
|
||||
if (payloadObj.has("url")) {
|
||||
apkUrl = payloadObj.get("url").getAsString();
|
||||
}
|
||||
if (payloadObj.has("version") && !payloadObj.get("version").isJsonNull()) {
|
||||
targetVersion = payloadObj.get("version").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
if (apkUrl == null || apkUrl.isEmpty()) {
|
||||
throw new Exception("Missing 'url' parameter in update_app payload.");
|
||||
}
|
||||
|
||||
// 啟動背景 OTA 更新管理器
|
||||
com.unibuy.smartdevice.tools.OtaManager.startUpdate(getApplicationContext(), apkUrl, cmdId);
|
||||
// 啟動背景 OTA 更新管理器(帶目標版本,供安裝後比對回報終態)
|
||||
com.unibuy.smartdevice.tools.OtaManager.startUpdate(getApplicationContext(), apkUrl, cmdId, targetVersion);
|
||||
|
||||
} catch (Exception e) {
|
||||
logs.info("Failed to start remote app update: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
|
||||
}
|
||||
} else if ("update_settings".equals(cmd)) {
|
||||
logs.info("Executing remote settings sync command...");
|
||||
|
||||
// 🚩 忙碌時拒收:交易/購物車/對話框進行中,避免支付開關等設定中途被改動。
|
||||
// 後台 RemoteCommand 約 1 分鐘 timeout,閒置後可重發。
|
||||
String busyReason = MyApp.getInstance().getBusyReason();
|
||||
if (!busyReason.isEmpty()) {
|
||||
logs.info("Remote settings sync rejected: " + busyReason);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Machine busy: " + busyReason);
|
||||
} else {
|
||||
// 📢 在主執行緒顯示 Toast 提示使用者
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() ->
|
||||
android.widget.Toast.makeText(getApplicationContext(),
|
||||
"📢 系統設定更新中...", android.widget.Toast.LENGTH_SHORT).show());
|
||||
|
||||
com.unibuy.smartdevice.tools.HandlerMain settingHandler = new com.unibuy.smartdevice.tools.HandlerMain(
|
||||
getApplicationContext(), logs) {
|
||||
@Override
|
||||
protected void execute(Context context, int code, String message) {
|
||||
logs.info("Settings sync event: [" + code + "] " + message);
|
||||
if (code == 200 && "綁定成功".equals(message)) {
|
||||
// 1. 先發 ACK,確保在 MQTT 重連之前送達後台
|
||||
mqttManager.publishCommandAck(cmdId, "success",
|
||||
"Settings sync completed successfully");
|
||||
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() ->
|
||||
android.widget.Toast.makeText(context,
|
||||
"✅ 系統設定更新完成", android.widget.Toast.LENGTH_SHORT).show());
|
||||
|
||||
// 2. 延後重連 MQTT(token 可能已輪換),留時間讓 ACK 先送出
|
||||
new android.os.Handler(android.os.Looper.getMainLooper())
|
||||
.postDelayed(() -> MyApp.getInstance().initMqtt(), 1500);
|
||||
} else if (code >= 400) {
|
||||
mqttManager.publishCommandAck(cmdId, "failed",
|
||||
"Settings sync failed: " + message);
|
||||
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() ->
|
||||
android.widget.Toast.makeText(context,
|
||||
"❌ 系統設定更新失敗: " + message, android.widget.Toast.LENGTH_LONG).show());
|
||||
}
|
||||
}
|
||||
};
|
||||
// 只重抓設定(B014),不串接商品/廣告;reconnectMqtt=false,改由上方 ACK 後再重連
|
||||
new com.unibuy.smartdevice.external.StarCloudAPI(settingHandler, settingHandler).downloadSetting(false);
|
||||
}
|
||||
} else {
|
||||
logs.info("Unknown MQTT command: " + cmd);
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "Unknown command");
|
||||
|
||||
@ -15,10 +15,27 @@ public class DevSetStructure {
|
||||
private boolean isTapPay32;
|
||||
private boolean isTapPay33;
|
||||
private boolean isTapPay34;
|
||||
// 取貨碼/通行碼:改由雲端 B014 FunctionSet 下發控制(預設值來自 flavor devset_defaults.xml)
|
||||
private boolean isPickupCode;
|
||||
private boolean isPassCode;
|
||||
// 以下為 B014 新增開關,預設 false,由雲端下發或主控台(系統方)設定後覆寫。
|
||||
// DevSet 細項:刷卡機/掃碼底下的子開關
|
||||
private boolean isDevCreditCard;
|
||||
private boolean isDevMobilePay;
|
||||
private boolean isDevCardPay;
|
||||
private boolean isDevScanPay;
|
||||
// FunctionSet 細項:取貨模組(主)、來店禮、會員系統、環境溫度監控
|
||||
private boolean isPickupModule;
|
||||
// 領藥單(雲端建單):取物單(pickup_sheet)模式下的子開關,true 時走雲端 B660 領藥流程
|
||||
private boolean isPharmacyPickup;
|
||||
private boolean isWelcomeGift;
|
||||
private boolean isMemberSystem;
|
||||
private boolean isAmbientTemp;
|
||||
|
||||
public DevSetStructure(boolean isVMC, boolean isElectic, boolean isShoppingCar, boolean isInvoice,
|
||||
boolean isDevNFCPay, boolean isDevEsunPay, boolean isDevTapPay, boolean isDevCash, boolean isDevLinePay,
|
||||
boolean isTapPay30,boolean isTapPay31,boolean isTapPay32,boolean isTapPay33, boolean isTapPay34) {
|
||||
boolean isTapPay30,boolean isTapPay31,boolean isTapPay32,boolean isTapPay33, boolean isTapPay34,
|
||||
boolean isPickupCode, boolean isPassCode) {
|
||||
this.isVMC = isVMC;
|
||||
this.isElectic = isElectic;
|
||||
this.isShoppingCar = isShoppingCar;
|
||||
@ -33,6 +50,8 @@ public class DevSetStructure {
|
||||
this.isTapPay32 = isTapPay32;
|
||||
this.isTapPay33 = isTapPay33;
|
||||
this.isTapPay34 = isTapPay34;
|
||||
this.isPickupCode = isPickupCode;
|
||||
this.isPassCode = isPassCode;
|
||||
}
|
||||
|
||||
public boolean isVMC() {
|
||||
@ -124,4 +143,75 @@ public class DevSetStructure {
|
||||
public void setTapPay33(boolean tapPay33) {isTapPay33 = tapPay33;}
|
||||
public void setTapPay34(boolean tapPay34) {isTapPay34 = tapPay34;}
|
||||
|
||||
public boolean isPickupCode() {
|
||||
return isPickupCode;
|
||||
}
|
||||
|
||||
public void setPickupCode(boolean pickupCode) {
|
||||
isPickupCode = pickupCode;
|
||||
}
|
||||
|
||||
public boolean isPassCode() {
|
||||
return isPassCode;
|
||||
}
|
||||
|
||||
public void setPassCode(boolean passCode) {
|
||||
isPassCode = passCode;
|
||||
}
|
||||
|
||||
public boolean isDevCreditCard() { return isDevCreditCard; }
|
||||
public void setDevCreditCard(boolean devCreditCard) { isDevCreditCard = devCreditCard; }
|
||||
|
||||
public boolean isDevMobilePay() { return isDevMobilePay; }
|
||||
public void setDevMobilePay(boolean devMobilePay) { isDevMobilePay = devMobilePay; }
|
||||
|
||||
public boolean isDevCardPay() { return isDevCardPay; }
|
||||
public void setDevCardPay(boolean devCardPay) { isDevCardPay = devCardPay; }
|
||||
|
||||
public boolean isDevScanPay() { return isDevScanPay; }
|
||||
public void setDevScanPay(boolean devScanPay) { isDevScanPay = devScanPay; }
|
||||
|
||||
public boolean isPickupModule() { return isPickupModule; }
|
||||
public void setPickupModule(boolean pickupModule) { isPickupModule = pickupModule; }
|
||||
|
||||
public boolean isPharmacyPickup() { return isPharmacyPickup; }
|
||||
public void setPharmacyPickup(boolean pharmacyPickup) { isPharmacyPickup = pharmacyPickup; }
|
||||
|
||||
public boolean isWelcomeGift() { return isWelcomeGift; }
|
||||
public void setWelcomeGift(boolean welcomeGift) { isWelcomeGift = welcomeGift; }
|
||||
|
||||
public boolean isMemberSystem() { return isMemberSystem; }
|
||||
public void setMemberSystem(boolean memberSystem) { isMemberSystem = memberSystem; }
|
||||
|
||||
public boolean isAmbientTemp() { return isAmbientTemp; }
|
||||
public void setAmbientTemp(boolean ambientTemp) { isAmbientTemp = ambientTemp; }
|
||||
|
||||
// ── 支付方式「母 && 子」便利判斷 ──────────────────────────────────────────
|
||||
// 各支付實際是否可用 = 母開關開啟 && 子項開啟。集中於此,讓 UI 顯示與入口防呆共用同一份真相,
|
||||
// 避免在多處(BuyDialog 購物車/單品/例外分支、changeCheckout 入口)各自拼湊而漏判。
|
||||
// 刷卡機(母 isDevNFCPay)→ 信用卡 / 電子票證(卡片) / 手機支付
|
||||
// 掃碼(母 isDevScanPay)→ 玉山 EsunPay / TapPay
|
||||
// Line 官方支付、現金:獨立大項,無母開關(自身即為總開關)
|
||||
|
||||
/** flowType 1 信用卡:刷卡機開啟且信用卡子項開啟。 */
|
||||
public boolean canCreditCard() { return isDevNFCPay && isDevCreditCard; }
|
||||
|
||||
/** flowType 2 電子票證(卡片支付):刷卡機開啟且卡片支付子項開啟。 */
|
||||
public boolean canCardPay() { return isDevNFCPay && isDevCardPay; }
|
||||
|
||||
/** flowType 10 手機支付:刷卡機開啟且手機支付子項開啟。 */
|
||||
public boolean canMobilePay() { return isDevNFCPay && isDevMobilePay; }
|
||||
|
||||
/** flowType 3 玉山 EsunPay:掃碼開啟且玉山子項開啟。 */
|
||||
public boolean canEsunPay() { return isDevScanPay && isDevEsunPay; }
|
||||
|
||||
/** flowType 30 TapPay:掃碼開啟且 TapPay 子項開啟。 */
|
||||
public boolean canTapPay() { return isDevScanPay && isDevTapPay; }
|
||||
|
||||
/** flowType 70 Line 官方支付:獨立大項,不從屬掃碼,只看自身開關。 */
|
||||
public boolean canLinePay() { return isDevLinePay; }
|
||||
|
||||
/** flowType 9 現金:獨立大項,只看自身開關。 */
|
||||
public boolean canCash() { return isDevCash; }
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package com.unibuy.smartdevice.structure;
|
||||
|
||||
/**
|
||||
* HardwareSet:硬體貨道類型設定(B014 HardwareSet)。
|
||||
* 每個欄位代表一段貨道(10 槽一組)的型式:true=彈簧 / false=履帶。
|
||||
* 由雲端 B014 下發,主控台系統方可編輯後回寫雲端。預設 false。
|
||||
*/
|
||||
public class HardwareSetStructure {
|
||||
private boolean springSlot1_10;
|
||||
private boolean springSlot11_20;
|
||||
private boolean springSlot21_30;
|
||||
private boolean springSlot31_40;
|
||||
private boolean springSlot41_50;
|
||||
private boolean springSlot51_60;
|
||||
|
||||
public HardwareSetStructure(boolean springSlot1_10, boolean springSlot11_20, boolean springSlot21_30,
|
||||
boolean springSlot31_40, boolean springSlot41_50, boolean springSlot51_60) {
|
||||
this.springSlot1_10 = springSlot1_10;
|
||||
this.springSlot11_20 = springSlot11_20;
|
||||
this.springSlot21_30 = springSlot21_30;
|
||||
this.springSlot31_40 = springSlot31_40;
|
||||
this.springSlot41_50 = springSlot41_50;
|
||||
this.springSlot51_60 = springSlot51_60;
|
||||
}
|
||||
|
||||
public boolean isSpringSlot1_10() { return springSlot1_10; }
|
||||
public void setSpringSlot1_10(boolean v) { springSlot1_10 = v; }
|
||||
|
||||
public boolean isSpringSlot11_20() { return springSlot11_20; }
|
||||
public void setSpringSlot11_20(boolean v) { springSlot11_20 = v; }
|
||||
|
||||
public boolean isSpringSlot21_30() { return springSlot21_30; }
|
||||
public void setSpringSlot21_30(boolean v) { springSlot21_30 = v; }
|
||||
|
||||
public boolean isSpringSlot31_40() { return springSlot31_40; }
|
||||
public void setSpringSlot31_40(boolean v) { springSlot31_40 = v; }
|
||||
|
||||
public boolean isSpringSlot41_50() { return springSlot41_50; }
|
||||
public void setSpringSlot41_50(boolean v) { springSlot41_50 = v; }
|
||||
|
||||
public boolean isSpringSlot51_60() { return springSlot51_60; }
|
||||
public void setSpringSlot51_60(boolean v) { springSlot51_60 = v; }
|
||||
|
||||
/**
|
||||
* 依貨道號回傳是否為彈簧貨道(對齊後台 SPRING_SLOT_FLAGS 的 10 槽分組)。
|
||||
* 超出 1–60 範圍視為履帶(false),維持「未知一律走履帶上限」的安全預設。
|
||||
*/
|
||||
public boolean isSpringSlot(int slotNo) {
|
||||
if (slotNo >= 1 && slotNo <= 10) return springSlot1_10;
|
||||
if (slotNo >= 11 && slotNo <= 20) return springSlot11_20;
|
||||
if (slotNo >= 21 && slotNo <= 30) return springSlot21_30;
|
||||
if (slotNo >= 31 && slotNo <= 40) return springSlot31_40;
|
||||
if (slotNo >= 41 && slotNo <= 50) return springSlot41_50;
|
||||
if (slotNo >= 51 && slotNo <= 60) return springSlot51_60;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
package com.unibuy.smartdevice.structure;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.unibuy.smartdevice.utils.LanguageHelper;
|
||||
|
||||
public class ProductStructure {
|
||||
private String productID;
|
||||
private String productImg;
|
||||
@ -115,6 +119,127 @@ public class ProductStructure {
|
||||
this.productNameJp = productNameJp;
|
||||
}
|
||||
|
||||
// B012 t060v01_i18n 原始 JSON 字串(底線代碼 key,如 {"zh_TW":..,"en":..,"ko":..})。
|
||||
// 由 B012 解析時設入;離線從 DB 重新載入商品時此欄為空,改向 MyApp 持久化 blob 取。
|
||||
private String nameI18nJson;
|
||||
|
||||
public String getNameI18nJson() {
|
||||
return nameI18nJson;
|
||||
}
|
||||
|
||||
public void setNameI18nJson(String nameI18nJson) {
|
||||
this.nameI18nJson = nameI18nJson;
|
||||
}
|
||||
|
||||
// 依目前語言取商品名稱。優先順序:
|
||||
// 1) B012 多語 map 對應語系 → 2) map 的 zh_TW → 3) 舊欄位 _en/_jp(向下相容)→ 4) 中文主值。
|
||||
// 所有顯示商品名稱的地方都應呼叫這個方法,避免各自重複判斷導致顯示不一致。
|
||||
public String getLocalizedName(Context context) {
|
||||
String canonical = LanguageHelper.getSavedLanguage(context); // zh-TW / en / ko ...
|
||||
String backendLocale = LanguageHelper.toBackendLocale(canonical); // zh_TW / en / ko ...
|
||||
|
||||
// 1) 商品多語 map:目前語系
|
||||
String fromMap = lookupNameI18n(backendLocale);
|
||||
if (notBlank(fromMap)) return fromMap;
|
||||
|
||||
// 2) 商品多語 map:zh_TW(後台保證的 fallback 主值)
|
||||
if (!"zh_TW".equals(backendLocale)) {
|
||||
String zh = lookupNameI18n("zh_TW");
|
||||
if (notBlank(zh)) return zh;
|
||||
}
|
||||
|
||||
// 3) 舊欄位 _en/_jp(線上舊資料相容;新 map 缺時兜底)
|
||||
String productLang = LanguageHelper.toProductLang(canonical); // en / ja / zh
|
||||
if ("en".equals(productLang) && notBlank(productNameEn)) return productNameEn;
|
||||
if ("ja".equals(productLang) && notBlank(productNameJp)) return productNameJp;
|
||||
|
||||
// 4) 中文主值
|
||||
return productName;
|
||||
}
|
||||
|
||||
// 從 i18n map 取指定後台 locale 的名稱;本物件欄位為空時改向 MyApp 持久化 blob 取(離線重啟)。
|
||||
private String lookupNameI18n(String backendLocale) {
|
||||
String json = nameI18nJson;
|
||||
if (json == null || json.isEmpty()) {
|
||||
json = com.unibuy.smartdevice.MyApp.getInstance().getProductNameI18nJson(productID);
|
||||
}
|
||||
if (json == null || json.isEmpty()) return null;
|
||||
try {
|
||||
return new org.json.JSONObject(json).optString(backendLocale, null);
|
||||
} catch (org.json.JSONException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// B012 t060v03_i18n 原始 JSON 字串(底線代碼 key,如 {"zh_TW":..,"en":..})。
|
||||
// 內容為純文字含換行(\n),顯示時以 setText 原樣呈現(保留換行/空格/縮排)。
|
||||
// 由 B012 解析時設入;離線從 DB 重新載入商品時此欄為空,改向 MyApp 持久化 blob 取。
|
||||
private String specI18nJson;
|
||||
|
||||
public String getSpecI18nJson() {
|
||||
return specI18nJson;
|
||||
}
|
||||
|
||||
public void setSpecI18nJson(String specI18nJson) {
|
||||
this.specI18nJson = specI18nJson;
|
||||
}
|
||||
|
||||
// 依目前語言取商品規格/描述。順序:對應語系 → zh_TW → 空字串(無描述)。
|
||||
public String getLocalizedSpec(Context context) {
|
||||
String canonical = LanguageHelper.getSavedLanguage(context);
|
||||
String backendLocale = LanguageHelper.toBackendLocale(canonical);
|
||||
|
||||
String fromMap = lookupSpecI18n(backendLocale);
|
||||
if (notBlank(fromMap)) return fromMap;
|
||||
|
||||
if (!"zh_TW".equals(backendLocale)) {
|
||||
String zh = lookupSpecI18n("zh_TW");
|
||||
if (notBlank(zh)) return zh;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String lookupSpecI18n(String backendLocale) {
|
||||
String json = specI18nJson;
|
||||
if (json == null || json.isEmpty()) {
|
||||
json = com.unibuy.smartdevice.MyApp.getInstance().getProductSpecI18nJson(productID);
|
||||
}
|
||||
if (json == null || json.isEmpty()) return null;
|
||||
try {
|
||||
return new org.json.JSONObject(json).optString(backendLocale, null);
|
||||
} catch (org.json.JSONException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 履帶/彈簧 補貨上限 =====
|
||||
// 一鍵補貨依貨道型式取不同上限:履帶用 trackLimit、彈簧用 springLimit。
|
||||
// trackLimit = slotUpperLimit(B012 t060v11,本來就等於後台 track_limit),沿用既有持久化。
|
||||
// springLimit = B012 spring_limit;由 saveProducts 設入。離線從 DB 重載商品時此欄為 -1,
|
||||
// 改向 MyApp 持久化 blob 取;blob 也缺時退回履帶上限(維持舊行為,不會把數量補成 0)。
|
||||
private int springLimit = -1;
|
||||
|
||||
public void setSpringLimit(int springLimit) {
|
||||
this.springLimit = springLimit;
|
||||
}
|
||||
|
||||
/** 履帶貨道補貨上限(= slotUpperLimit = 後台 track_limit)。 */
|
||||
public int getTrackLimit() {
|
||||
return this.slotUpperLimit;
|
||||
}
|
||||
|
||||
/** 彈簧貨道補貨上限。本物件欄位缺時向 MyApp 持久化 blob 取;再缺則退回履帶上限。 */
|
||||
public int getSpringLimit() {
|
||||
if (springLimit >= 0) return springLimit;
|
||||
int fromBlob = com.unibuy.smartdevice.MyApp.getInstance().getProductSpringLimit(productID);
|
||||
if (fromBlob >= 0) return fromBlob;
|
||||
return this.slotUpperLimit;
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.trim().isEmpty();
|
||||
}
|
||||
|
||||
public int getRealPrice() {
|
||||
int machinePrice = getMachinePrice();
|
||||
int sellingPrice = getSellingPrice();
|
||||
|
||||
@ -14,7 +14,19 @@ public class ReportFlowInfoStructure {
|
||||
int flowStatusType;
|
||||
int giveChange;
|
||||
int usePoints;
|
||||
// 現金收款面額明細(僅現金 payment_type=9 有值):仟/伍佰/佰紙鈔張數 + 50/10/5/1 硬幣個數。
|
||||
// 由 CashPayDialogGeneral 於付款完成寫入,供 TransactionFinalizeBuilder 組 cash_detail 上報。
|
||||
int cashBill1000;
|
||||
int cashBill500;
|
||||
int cashBill100;
|
||||
int cashCoin50;
|
||||
int cashCoin10;
|
||||
int cashCoin5;
|
||||
int cashCoin1;
|
||||
String reportFlowId;
|
||||
// MQTT 交易結案流水號:進入付款時生成一次,pending → completed/failed 全程共用同一個,
|
||||
// 讓後台依同一 flow_id 做狀態轉移。與 reportFlowId(取貨碼/通行碼驗證碼、B600 legacy)語意不同,獨立一欄。
|
||||
String mqttFlowId;
|
||||
JSONArray shoppingCartInfoByJsonArray;
|
||||
|
||||
public ReportFlowInfoStructure(String orderId, int flowType, String flowSendInfo, String flowRequestInfo, String productId, int totalPrice, String invoiceInfo, String flowBarCode, int flowStatusType, int giveChange, int usePoints, JSONArray shoppingCartInfoByJsonArray) {
|
||||
@ -120,6 +132,27 @@ public class ReportFlowInfoStructure {
|
||||
this.usePoints = usePoints;
|
||||
}
|
||||
|
||||
public int getCashBill1000() { return cashBill1000; }
|
||||
public void setCashBill1000(int cashBill1000) { this.cashBill1000 = cashBill1000; }
|
||||
|
||||
public int getCashBill500() { return cashBill500; }
|
||||
public void setCashBill500(int cashBill500) { this.cashBill500 = cashBill500; }
|
||||
|
||||
public int getCashBill100() { return cashBill100; }
|
||||
public void setCashBill100(int cashBill100) { this.cashBill100 = cashBill100; }
|
||||
|
||||
public int getCashCoin50() { return cashCoin50; }
|
||||
public void setCashCoin50(int cashCoin50) { this.cashCoin50 = cashCoin50; }
|
||||
|
||||
public int getCashCoin10() { return cashCoin10; }
|
||||
public void setCashCoin10(int cashCoin10) { this.cashCoin10 = cashCoin10; }
|
||||
|
||||
public int getCashCoin5() { return cashCoin5; }
|
||||
public void setCashCoin5(int cashCoin5) { this.cashCoin5 = cashCoin5; }
|
||||
|
||||
public int getCashCoin1() { return cashCoin1; }
|
||||
public void setCashCoin1(int cashCoin1) { this.cashCoin1 = cashCoin1; }
|
||||
|
||||
public JSONArray getShoppingCartInfoByJsonArray() {
|
||||
return shoppingCartInfoByJsonArray;
|
||||
}
|
||||
@ -136,6 +169,14 @@ public class ReportFlowInfoStructure {
|
||||
this.reportFlowId = reportFlowId;
|
||||
}
|
||||
|
||||
public String getMqttFlowId() {
|
||||
return mqttFlowId;
|
||||
}
|
||||
|
||||
public void setMqttFlowId(String mqttFlowId) {
|
||||
this.mqttFlowId = mqttFlowId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReportFlowInfoStructure{" +
|
||||
|
||||
@ -8,6 +8,9 @@ public class ReportInvoiceInfoStructure {
|
||||
String invoiceDate;
|
||||
String randomNumber;
|
||||
String loveCode;
|
||||
// 綠界 RelateNumber(冪等鍵,= machineID + flow_id)。由 DispatchDialog 開票前產生,
|
||||
// 開票請求與 finalize 上報共用同一值,後台據此查詢/補開避免重複開立。
|
||||
String relateNumber = "";
|
||||
|
||||
public ReportInvoiceInfoStructure(String reportFlowId, String rtnCode, String rtnMsg, String invoiceNo, String invoiceDate, String randomNumber, String loveCode) {
|
||||
this.reportFlowId = reportFlowId;
|
||||
@ -75,6 +78,14 @@ public class ReportInvoiceInfoStructure {
|
||||
this.loveCode = loveCode;
|
||||
}
|
||||
|
||||
public String getRelateNumber() {
|
||||
return relateNumber;
|
||||
}
|
||||
|
||||
public void setRelateNumber(String relateNumber) {
|
||||
this.relateNumber = relateNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReportInvoiceInfoStructure{" +
|
||||
|
||||
@ -10,6 +10,16 @@ public class SlotStructure {
|
||||
private boolean lock;
|
||||
private int secord;
|
||||
private JSONObject otherData;
|
||||
public int cartSelectedCount;// 本地欄位:購物車已選數量(General flavor 多品項購物車使用)
|
||||
|
||||
// ── 雙向 LWW 同步序號(per-field)──
|
||||
// rev:店員在貨道管理「存檔且實際變更」時才遞增;synced_rev:已被後台確認(B009 成功/下行採用)的 rev。
|
||||
// rev > synced_rev = 有未上報的現場編輯 → 收到下行時該欄位拒絕被覆蓋。
|
||||
// 注意:出貨扣到 0 的 clearExpiry / 自動扣庫存 / 一鍵補貨「不可」遞增 rev。
|
||||
private int lockRev;
|
||||
private int lockSyncedRev;
|
||||
private int expiryRev;
|
||||
private int expirySyncedRev;
|
||||
|
||||
/*
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
@ -99,7 +109,96 @@ 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(賣完)時呼叫,避免舊批貨效期殘留,
|
||||
* 導致下次本機補新貨時被舊效期誤鎖成「暫不販售」。
|
||||
* 注意:這是「自動」清除,呼叫端不可遞增 expiryRev(否則賣完會把後台預設的下批效期洗掉)。
|
||||
*/
|
||||
public void clearExpiry() {
|
||||
if (otherData == null)
|
||||
return;
|
||||
otherData.remove("expiry_date");
|
||||
otherData.remove("batch_no");
|
||||
}
|
||||
|
||||
/** 批號(otherData.batch_no),無則回空字串。 */
|
||||
public String getBatchNo() {
|
||||
if (otherData == null || !otherData.has("batch_no"))
|
||||
return "";
|
||||
try {
|
||||
String b = otherData.getString("batch_no");
|
||||
return (b == null || "null".equalsIgnoreCase(b)) ? "" : b;
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定效期字串(yyyy-MM-dd)。供貨道管理 UI 編輯用;空字串/null 代表清除(無效期管控)。
|
||||
* 不在此遞增 rev —— rev 由 saveData 與 baseline 比對後統一遞增。
|
||||
*/
|
||||
public void setExpiryDate(String expiry) {
|
||||
if (otherData == null)
|
||||
otherData = new JSONObject();
|
||||
try {
|
||||
if (expiry == null || expiry.trim().isEmpty()) {
|
||||
otherData.remove("expiry_date");
|
||||
} else {
|
||||
otherData.put("expiry_date", expiry.trim());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public int getLockRev() { return lockRev; }
|
||||
public void setLockRev(int lockRev) { this.lockRev = lockRev; }
|
||||
public int getLockSyncedRev() { return lockSyncedRev; }
|
||||
public void setLockSyncedRev(int lockSyncedRev) { this.lockSyncedRev = lockSyncedRev; }
|
||||
public int getExpiryRev() { return expiryRev; }
|
||||
public void setExpiryRev(int expiryRev) { this.expiryRev = expiryRev; }
|
||||
public int getExpirySyncedRev() { return expirySyncedRev; }
|
||||
public void setExpirySyncedRev(int expirySyncedRev) { this.expirySyncedRev = expirySyncedRev; }
|
||||
}
|
||||
|
||||
@ -35,6 +35,14 @@ public class TransactionFinalizePayload {
|
||||
/** 每個貨道的出貨結果列表 */
|
||||
private List<DispenseRecord> dispense;
|
||||
|
||||
/**
|
||||
* 電子發票開立結果(選填)。
|
||||
* 機台在進入出貨流程時平行向綠界開立,結果折進此物件隨 finalize 一起上報;
|
||||
* 若開票尚未回應則 status=pending,後台依 relate_number 去綠界查詢/補開。
|
||||
* 無發票需求(未帶發票資訊)時此欄為 null,後台不建立發票。
|
||||
*/
|
||||
private InvoicePayload invoice;
|
||||
|
||||
// =========== Getters & Setters ===========
|
||||
|
||||
public String getAction() {
|
||||
@ -82,6 +90,74 @@ public class TransactionFinalizePayload {
|
||||
this.dispense = dispense;
|
||||
}
|
||||
|
||||
public InvoicePayload getInvoice() {
|
||||
return invoice;
|
||||
}
|
||||
|
||||
public void setInvoice(InvoicePayload invoice) {
|
||||
this.invoice = invoice;
|
||||
}
|
||||
|
||||
// =========== 內部類別:電子發票開立結果 ===========
|
||||
|
||||
public static class InvoicePayload {
|
||||
/** 開立狀態:issued(已開)/ pending(已送出未回應)/ failed(綠界回失敗) */
|
||||
private String status;
|
||||
/** 綠界 RelateNumber(= machineID + flow_id),後台查詢/補開用 */
|
||||
private String relate_number;
|
||||
/** 發票號碼(pending/failed 時為空) */
|
||||
private String invoice_no;
|
||||
/** 綠界回應碼(1=成功) */
|
||||
private String rtn_code;
|
||||
/** 綠界回應訊息 */
|
||||
private String rtn_msg;
|
||||
/** 發票開立日期 */
|
||||
private String invoice_date;
|
||||
/** 隨機碼 */
|
||||
private String random_number;
|
||||
/** 愛心碼(捐贈時) */
|
||||
private String love_code;
|
||||
/** 載具號碼 */
|
||||
private String carrier_id;
|
||||
/** 統一編號(B2B 統編,有打統編時) */
|
||||
private String business_tax_id;
|
||||
/** 發票金額 */
|
||||
private int amount;
|
||||
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public String getRelate_number() { return relate_number; }
|
||||
public void setRelate_number(String relate_number) { this.relate_number = relate_number; }
|
||||
|
||||
public String getInvoice_no() { return invoice_no; }
|
||||
public void setInvoice_no(String invoice_no) { this.invoice_no = invoice_no; }
|
||||
|
||||
public String getRtn_code() { return rtn_code; }
|
||||
public void setRtn_code(String rtn_code) { this.rtn_code = rtn_code; }
|
||||
|
||||
public String getRtn_msg() { return rtn_msg; }
|
||||
public void setRtn_msg(String rtn_msg) { this.rtn_msg = rtn_msg; }
|
||||
|
||||
public String getInvoice_date() { return invoice_date; }
|
||||
public void setInvoice_date(String invoice_date) { this.invoice_date = invoice_date; }
|
||||
|
||||
public String getRandom_number() { return random_number; }
|
||||
public void setRandom_number(String random_number) { this.random_number = random_number; }
|
||||
|
||||
public String getLove_code() { return love_code; }
|
||||
public void setLove_code(String love_code) { this.love_code = love_code; }
|
||||
|
||||
public String getCarrier_id() { return carrier_id; }
|
||||
public void setCarrier_id(String carrier_id) { this.carrier_id = carrier_id; }
|
||||
|
||||
public String getBusiness_tax_id() { return business_tax_id; }
|
||||
public void setBusiness_tax_id(String business_tax_id) { this.business_tax_id = business_tax_id; }
|
||||
|
||||
public int getAmount() { return amount; }
|
||||
public void setAmount(int amount) { this.amount = amount; }
|
||||
}
|
||||
|
||||
// =========== 內部類別:訂單資訊 ===========
|
||||
|
||||
public static class OrderPayload {
|
||||
@ -103,22 +179,36 @@ public class TransactionFinalizePayload {
|
||||
/** 找零金額 */
|
||||
private int change_amount;
|
||||
|
||||
/**
|
||||
* 現金收款面額明細(僅現金 payment_type=9 才帶;其餘支付方式為 null)。
|
||||
* 記錄本筆交易「收」進的各面額張數,供後台銷售紀錄顯示「收:仟:x… 找:x」。
|
||||
* 找零仍以 change_amount 表示,不在此重複。
|
||||
*/
|
||||
private CashDetail cash_detail;
|
||||
|
||||
/** 使用點數 */
|
||||
private int points_used;
|
||||
|
||||
/**
|
||||
* 支付類型代碼:
|
||||
* 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:點數全額折抵。
|
||||
*/
|
||||
private int payment_type;
|
||||
|
||||
/**
|
||||
* 支付狀態:1 = 成功
|
||||
* 支付狀態:1 = 成功, 0 = 失敗/未付款
|
||||
*/
|
||||
private int payment_status;
|
||||
|
||||
/**
|
||||
* 交易生命週期狀態(後台 Order.status):
|
||||
* "pending"(進付款待結果)/ "completed"(付款成功)/ "failed"(付款失敗)/ "abandoned"。
|
||||
* 不帶(null)時後台預設 "completed",確保線上 main 機台行為不變。
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 訂單出貨狀態:0 = 全部失敗, 1 = 全部成功, 2 = 部分失敗
|
||||
*/
|
||||
@ -191,6 +281,14 @@ public class TransactionFinalizePayload {
|
||||
this.change_amount = change_amount;
|
||||
}
|
||||
|
||||
public CashDetail getCash_detail() {
|
||||
return cash_detail;
|
||||
}
|
||||
|
||||
public void setCash_detail(CashDetail cash_detail) {
|
||||
this.cash_detail = cash_detail;
|
||||
}
|
||||
|
||||
public int getPoints_used() {
|
||||
return points_used;
|
||||
}
|
||||
@ -215,6 +313,14 @@ public class TransactionFinalizePayload {
|
||||
this.payment_status = payment_status;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getDelivery_status() {
|
||||
return delivery_status;
|
||||
}
|
||||
@ -303,6 +409,41 @@ public class TransactionFinalizePayload {
|
||||
}
|
||||
}
|
||||
|
||||
// =========== 內部類別:現金面額明細 ===========
|
||||
|
||||
/**
|
||||
* 現金收款面額張數明細。欄位名即為後台/JSON 契約(Gson 依欄位名序列化):
|
||||
* b1000/b500/b100=仟/伍佰/佰元紙鈔張數,c50/c10/c5/c1=50/10/5/1 元硬幣個數。
|
||||
* 找零金額不放這裡,沿用 order.change_amount。
|
||||
*/
|
||||
public static class CashDetail {
|
||||
private int b1000;
|
||||
private int b500;
|
||||
private int b100;
|
||||
private int c50;
|
||||
private int c10;
|
||||
private int c5;
|
||||
private int c1;
|
||||
|
||||
public CashDetail(int b1000, int b500, int b100, int c50, int c10, int c5, int c1) {
|
||||
this.b1000 = b1000;
|
||||
this.b500 = b500;
|
||||
this.b100 = b100;
|
||||
this.c50 = c50;
|
||||
this.c10 = c10;
|
||||
this.c5 = c5;
|
||||
this.c1 = c1;
|
||||
}
|
||||
|
||||
public int getB1000() { return b1000; }
|
||||
public int getB500() { return b500; }
|
||||
public int getB100() { return b100; }
|
||||
public int getC50() { return c50; }
|
||||
public int getC10() { return c10; }
|
||||
public int getC5() { return c5; }
|
||||
public int getC1() { return c1; }
|
||||
}
|
||||
|
||||
// =========== 內部類別:出貨記錄 ===========
|
||||
|
||||
public static class DispenseRecord {
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* 現金收銀機(MDB232 轉換盒)傳輸層工廠。
|
||||
*
|
||||
* <p>目標機台的收銀轉換盒(MDB232)可透過三種接法連線,由使用者在「系統設定 → 現金機設定」頁選擇;
|
||||
* 三者皆為串口,統一由 {@link SerialCashAcceptorHandler}(Modbus) 處理,只差 /dev 路徑:</p>
|
||||
* <ul>
|
||||
* <li>{@link ConnType#USB}:USB 轉 RS-232 轉接器 = /dev/ttyUSB0(讀取快又乾淨→即時,推薦;非 Android UsbManager 機制)</li>
|
||||
* <li>{@link ConnType#COM_PORT_3}:原生串口 Com Port 3 = /dev/ttyS3(緩衝延遲大,硬幣判斷會慢/漏)</li>
|
||||
* <li>{@link ConnType#TTYS9}:原生串口 = /dev/ttyS9</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>選擇後持久化於 SharedPreferences;{@link #create(Context, MdbEventListener)} 依設定回傳對應
|
||||
* 傳輸實體。所有消費端(CashPayDialogGeneral / CashPayoutManager / CashMachineSettings)一律
|
||||
* 透過本工廠取得 {@link ICashAcceptorHandler},確保三處使用同一連線設定。</p>
|
||||
*/
|
||||
public final class CashAcceptorFactory {
|
||||
|
||||
private static final String PREFS_NAME = "cash_connection_prefs";
|
||||
private static final String KEY_CONN_TYPE = "conn_type";
|
||||
|
||||
/** USB 轉 RS-232 轉接器的串口節點(USB 選項用;讀取快又乾淨→硬幣即時偵測,推薦)。 */
|
||||
public static final String PATH_USB = "/dev/ttyUSB0";
|
||||
|
||||
/** ttyS9 串口路徑。 */
|
||||
public static final String PATH_TTYS9 = "/dev/ttyS9";
|
||||
|
||||
/**
|
||||
* Com Port 3 串口路徑 = /dev/ttyS3。
|
||||
* 某台 Android 9 IPC 有三個 com port,第三個 ttyS3 專接現金 MDB232 轉換盒。
|
||||
*/
|
||||
public static final String PATH_COM_PORT_3 = "/dev/ttyS3";
|
||||
|
||||
public enum ConnType {
|
||||
USB,
|
||||
COM_PORT_3,
|
||||
TTYS9
|
||||
}
|
||||
|
||||
private CashAcceptorFactory() {
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context context) {
|
||||
return context.getApplicationContext()
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
/** 讀取目前選擇的連線方式,預設 USB(維持既有行為)。 */
|
||||
public static ConnType getConnType(Context context) {
|
||||
String name = prefs(context).getString(KEY_CONN_TYPE, ConnType.USB.name());
|
||||
try {
|
||||
return ConnType.valueOf(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ConnType.USB;
|
||||
}
|
||||
}
|
||||
|
||||
/** 儲存使用者在設定頁選擇的連線方式。 */
|
||||
public static void setConnType(Context context, ConnType type) {
|
||||
prefs(context).edit().putString(KEY_CONN_TYPE, type.name()).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* 依目前設定建立對應的收銀傳輸實體。
|
||||
*
|
||||
* @param context Context
|
||||
* @param listener 事件監聽器(可為 null)
|
||||
* @return USB 或串口(ttyS9 / Com Port 3)handler
|
||||
*/
|
||||
public static ICashAcceptorHandler create(Context context, MdbEventListener listener) {
|
||||
switch (getConnType(context)) {
|
||||
case COM_PORT_3:
|
||||
return new SerialCashAcceptorHandler(context, listener, null, PATH_COM_PORT_3);
|
||||
case TTYS9:
|
||||
return new SerialCashAcceptorHandler(context, listener, null, PATH_TTYS9);
|
||||
case USB:
|
||||
default:
|
||||
// USB = USB 轉 RS-232 轉接器(/dev/ttyUSB0) 走串口,非 Android UsbManager。這是即時的推薦接法。
|
||||
return new SerialCashAcceptorHandler(context, listener, null, PATH_USB);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,7 @@ public class CashPayoutManager {
|
||||
|
||||
private final Context context;
|
||||
private final PayoutListener listener;
|
||||
private SimpleCashAcceptorHandler handler;
|
||||
private ICashAcceptorHandler handler;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
// 庫存變數
|
||||
@ -45,7 +45,7 @@ public class CashPayoutManager {
|
||||
Log.i(TAG, "Starting remote payout for amount: " + amount);
|
||||
listener.onProgress("正在初始化退幣系統...");
|
||||
|
||||
handler = new SimpleCashAcceptorHandler(context, event -> {
|
||||
handler = CashAcceptorFactory.create(context, event -> {
|
||||
Log.d(TAG, "MDB Event: " + event);
|
||||
handleMdbEvent(event);
|
||||
});
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
/**
|
||||
* 現金收銀機除錯訊息監聽器。
|
||||
*
|
||||
* <p>收銀 handler(USB / 串口)可透過此介面把連線、初始化、投退幣等診斷訊息
|
||||
* 即時回拋給現金機設定測試頁,顯示在畫面的 Debug 訊息區,方便現場排查。</p>
|
||||
*/
|
||||
public interface DebugLogListener {
|
||||
void onDebugLog(String message);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 現金收銀機(MDB232 轉換盒)傳輸層共用介面。
|
||||
*
|
||||
* <p>目標機台的收銀轉換盒可透過三種方式接線:USB、Com Port 3、ttyS9。
|
||||
* 前者由 {@link SimpleCashAcceptorHandler}(USB 笨管道)實作,後兩者由
|
||||
* {@link SerialCashAcceptorHandler}(串口 Modbus,可傳入 /dev 埠路徑)實作。
|
||||
* 消費端一律透過此介面操作,由 {@link CashAcceptorFactory} 依使用者在系統設定頁
|
||||
* 選擇的連線方式決定實體。</p>
|
||||
*
|
||||
* <p>方法簽章刻意與既有 USB handler 完全一致,讓 CashPayDialogGeneral /
|
||||
* CashPayoutManager 幾乎零改動即可切換底層傳輸。</p>
|
||||
*/
|
||||
public interface ICashAcceptorHandler {
|
||||
|
||||
/** 連線並初始化裝置。成功時回傳 {@code "成功連接到 USB 轉接器!"}(沿用既有成功字串比對)。 */
|
||||
String connect();
|
||||
|
||||
/** 送出(已 hex 解碼的)MDB 指令位元組,回傳實際寫入長度,失敗回傳負值。 */
|
||||
int writeData(byte[] data);
|
||||
|
||||
/** 讀取一筆裝置回應。 */
|
||||
byte[] readData() throws IOException;
|
||||
|
||||
/** 啟動背景讀取執行緒,讀到的事件經 {@link MdbEventListener} 回拋。 */
|
||||
void startReading();
|
||||
|
||||
/** 停止背景讀取執行緒。 */
|
||||
void stopReading();
|
||||
|
||||
/** 關閉連線並釋放資源。 */
|
||||
void close();
|
||||
|
||||
/** 設定除錯訊息監聽器(供現金機設定測試頁顯示連線/初始化/投退幣診斷)。可傳 null 關閉。 */
|
||||
void setDebugListener(DebugLogListener listener);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
/**
|
||||
* 現金收銀機(MDB232 轉換盒)事件監聽器。
|
||||
*
|
||||
* <p>無論底層走 USB 笨管道或串口 Modbus,收銀 handler 都透過此介面把
|
||||
* 「原始/語意事件字串」回拋給對話框處理。抽成共用型別後,
|
||||
* {@link ICashAcceptorHandler} 的各實作與消費端(CashPayDialogGeneral /
|
||||
* CashPayoutManager / CashMachineSettings)即可用同一個 lambda 介面串接。</p>
|
||||
*/
|
||||
public interface MdbEventListener {
|
||||
void onEventReceived(String event);
|
||||
}
|
||||
@ -1,110 +1,544 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import android.app.DownloadManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import com.unibuy.smartdevice.exception.ErrorCode;
|
||||
import com.unibuy.smartdevice.exception.Logs;
|
||||
import com.unibuy.smartdevice.external.mqtt.MqttManager;
|
||||
import java.io.BufferedInputStream;
|
||||
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* 負責遠端 APK 的背景下載與 Root 靜默覆蓋安裝管理
|
||||
* 負責遠端 APK 的背景下載與 Root 靜默覆蓋安裝管理。
|
||||
*
|
||||
* <p>下載改用系統 {@link DownloadManager},解決舊版「自架 Thread 下載」的兩個致命傷:
|
||||
* <ul>
|
||||
* <li>由系統程序負責下載,<b>App 重啟/被殺不會中斷</b>,並支援自動續傳與重試;</li>
|
||||
* <li>下載期間定期回報<b>進度 ACK(result="progress")</b>,後台不視為終態,看得到實際進度。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>回報語意修正:舊版一「開始下載」就回 {@code result=success},導致後台立刻把指令標成完成。
|
||||
* 新版只在<b>最終成敗</b>時回 {@code success}/{@code failed};下載/安裝過程一律走 {@code progress}。
|
||||
* 由於 {@code pm install -r} 成功後本程序會被新版替換,<b>「安裝成功」的終態 ACK 改由新版啟動後
|
||||
* 透過 {@link #resumeIfPending(Context)} 比對版本回報</b>,確保後台狀態與機台實際版本一致。
|
||||
*/
|
||||
public class OtaManager {
|
||||
private static final String TAG = "OtaManager";
|
||||
private static final Logs logs = new Logs(OtaManager.class);
|
||||
private static final int DOWNLOAD_CONNECT_TIMEOUT_MS = 30000;
|
||||
private static final int DOWNLOAD_READ_TIMEOUT_MS = 120000;
|
||||
|
||||
private static final String PREFS = "ota_state";
|
||||
private static final String KEY_DOWNLOAD_ID = "download_id";
|
||||
private static final String KEY_CMD_ID = "cmd_id";
|
||||
private static final String KEY_VERSION = "version";
|
||||
private static final String KEY_PHASE = "phase";
|
||||
private static final String KEY_INSTALL_ATTEMPTS = "install_attempts";
|
||||
|
||||
private static final String PHASE_DOWNLOADING = "downloading";
|
||||
private static final String PHASE_READY = "ready"; // 下載完成,等待機台閒置才安裝
|
||||
private static final String PHASE_INSTALLING = "installing";
|
||||
|
||||
/** 非終態回報結果值;後台 ProcessCommandAck 對 update_app 的此值只更新備註、保持 pending。 */
|
||||
private static final String RESULT_PROGRESS = "progress";
|
||||
private static final String RESULT_SUCCESS = "success";
|
||||
private static final String RESULT_FAILED = "failed";
|
||||
|
||||
private static final long PROGRESS_REPORT_INTERVAL_MS = 5000;
|
||||
private static final int MAX_INSTALL_ATTEMPTS = 2;
|
||||
private static final String APK_FILE_NAME = "update_ota.apk";
|
||||
|
||||
// 安裝閘門:下載完成後,需「不忙碌且閒置達門檻」才執行安裝(安裝會替換 App 並重啟,
|
||||
// 不可在有人操作/交易中進行)。忙碌時持續等待、定期重試。
|
||||
private static final int INSTALL_REQUIRED_IDLE_SECONDS = 60;
|
||||
private static final long IDLE_CHECK_INTERVAL_MS = 30000;
|
||||
private static final int WAIT_NOTIFY_EVERY_N_CHECKS = 10; // 約每 5 分鐘回報一次等待中
|
||||
|
||||
// 防止重複監看同一筆下載(resume 會在每次 MQTT 連線時被呼叫)
|
||||
private static volatile long monitoringDownloadId = -1;
|
||||
private static volatile boolean awaitingIdleInstall = false;
|
||||
private static BroadcastReceiver completionReceiver;
|
||||
|
||||
// 防重入:MQTT 可能重複投遞同一則 update_app(機台若有多條堆疊連線會收到 N 次)。
|
||||
// 同一 cmdId 已在進行中就忽略重複,避免 N 個並行 startUpdate 經 cancelExisting() 互相取消下載。
|
||||
// 不在 clearState 清除(cancelExisting 會於 startUpdate 開頭呼叫 clearState),改由新 cmdId 覆蓋 + 15 分鐘保險過期。
|
||||
private static volatile String activeOtaCmdId = null;
|
||||
private static volatile long activeOtaStartMs = 0L;
|
||||
private static final long OTA_REENTRY_WINDOW_MS = 15 * 60 * 1000L;
|
||||
|
||||
/** 相容舊呼叫端:未帶版本資訊。 */
|
||||
public static void startUpdate(Context context, String apkUrl, String cmdId) {
|
||||
startUpdate(context, apkUrl, cmdId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 啟動背景執行緒執行 APP OTA 更新
|
||||
* 啟動遠端 APP OTA 更新。
|
||||
*
|
||||
* @param context 應用程式 Context
|
||||
* @param apkUrl 新版 APK 的下載位址
|
||||
* @param cmdId MQTT 指令 ID,用於回報執行結果
|
||||
* @param context 應用程式 Context
|
||||
* @param apkUrl 新版 APK 的下載位址
|
||||
* @param cmdId MQTT 指令 ID,用於回報執行結果
|
||||
* @param expectedVersion 目標版本名(如 "21_02_9_R"),安裝後用於比對確認;可為 null
|
||||
*/
|
||||
public static void startUpdate(Context context, String apkUrl, String cmdId) {
|
||||
new Thread(new Runnable() {
|
||||
public static void startUpdate(Context context, String apkUrl, String cmdId, String expectedVersion) {
|
||||
Context appContext = context.getApplicationContext();
|
||||
MqttManager mqttManager = MqttManager.getInstance();
|
||||
|
||||
// 防重入:同一 cmdId 的重複投遞直接忽略,避免並行 startUpdate 互相 cancelExisting() 取消下載。
|
||||
synchronized (OtaManager.class) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (cmdId != null && cmdId.equals(activeOtaCmdId) && (now - activeOtaStartMs) < OTA_REENTRY_WINDOW_MS) {
|
||||
logs.info("OTA duplicate command ignored (cmdId=" + cmdId + "); already in progress.");
|
||||
return;
|
||||
}
|
||||
activeOtaCmdId = cmdId;
|
||||
activeOtaStartMs = now;
|
||||
}
|
||||
|
||||
try {
|
||||
logs.info("Starting OTA update process. URL: " + apkUrl + " (cmdId: " + cmdId
|
||||
+ ", version: " + expectedVersion + ")");
|
||||
|
||||
// 先清掉前一筆殘留下載,避免重複下載/狀態混淆
|
||||
cancelExisting(appContext);
|
||||
|
||||
DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
if (dm == null) {
|
||||
// 極少數 ROM 停用 DownloadManager:退回舊版直連下載(不可續傳,僅作保險)
|
||||
logs.info("DownloadManager unavailable, falling back to legacy direct download.");
|
||||
legacyDownloadAndInstall(appContext, apkUrl, cmdId, expectedVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
File target = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME);
|
||||
if (target.exists()) {
|
||||
target.delete();
|
||||
}
|
||||
|
||||
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apkUrl));
|
||||
request.setTitle("OTA Update");
|
||||
request.setDescription("Downloading APK " + (expectedVersion != null ? expectedVersion : ""));
|
||||
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN);
|
||||
// 下載到 App 私有外部目錄,su (root) 可讀取進行安裝,免 WRITE_EXTERNAL_STORAGE 權限
|
||||
request.setDestinationInExternalFilesDir(appContext, null, APK_FILE_NAME);
|
||||
request.setAllowedOverMetered(true);
|
||||
request.setAllowedOverRoaming(true);
|
||||
|
||||
long downloadId = dm.enqueue(request);
|
||||
|
||||
// 持久化狀態:App 重啟後可由 resumeIfPending 接手(續傳監看 / 安裝 / 回報)
|
||||
prefs(appContext).edit()
|
||||
.putLong(KEY_DOWNLOAD_ID, downloadId)
|
||||
.putString(KEY_CMD_ID, cmdId)
|
||||
.putString(KEY_VERSION, expectedVersion)
|
||||
.putString(KEY_PHASE, PHASE_DOWNLOADING)
|
||||
.putInt(KEY_INSTALL_ATTEMPTS, 0)
|
||||
.apply();
|
||||
|
||||
mqttManager.publishCommandAck(cmdId, RESULT_PROGRESS, "Downloading update APK...");
|
||||
|
||||
registerCompletionReceiver(appContext);
|
||||
startProgressMonitor(appContext, downloadId, cmdId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logs.error(ErrorCode.UNKNOWN_ERROR, "OTA Update failed to start: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, RESULT_FAILED, "OTA Update failed: " + e.getMessage());
|
||||
clearState(appContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App 啟動且 MQTT 連線後呼叫:若有未完成的 OTA,依目前狀態接手。
|
||||
* <ul>
|
||||
* <li>安裝階段(phase=installing):比對實際版本 → 回報 success;不符則重試安裝或回報 failed。</li>
|
||||
* <li>下載階段:依 DownloadManager 狀態 → 已完成則安裝;進行中則重掛監看;失敗則回報 failed。</li>
|
||||
* </ul>
|
||||
* 本方法可重入:無待辦時直接返回,重複呼叫不會重複監看或重複回報。
|
||||
*/
|
||||
public static void resumeIfPending(Context context) {
|
||||
Context appContext = context.getApplicationContext();
|
||||
SharedPreferences prefs = prefs(appContext);
|
||||
long downloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1);
|
||||
if (downloadId < 0) {
|
||||
return;
|
||||
}
|
||||
String cmdId = prefs.getString(KEY_CMD_ID, null);
|
||||
String phase = prefs.getString(KEY_PHASE, PHASE_DOWNLOADING);
|
||||
|
||||
// 安裝階段:新版已(應)啟動,比對版本回報終態
|
||||
if (PHASE_INSTALLING.equals(phase)) {
|
||||
confirmAfterInstall(appContext, cmdId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 已下載完成、等待閒置安裝:重啟後重新掛上閒置等待迴圈(APK 仍在本機)
|
||||
if (PHASE_READY.equals(phase)) {
|
||||
File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME);
|
||||
if (apkFile.exists()) {
|
||||
scheduleInstallWhenIdle(appContext, apkFile, cmdId);
|
||||
} else {
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED,
|
||||
"Downloaded APK missing before install.");
|
||||
}
|
||||
clearState(appContext);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
if (dm == null) {
|
||||
return;
|
||||
}
|
||||
int status = queryStatus(dm, downloadId);
|
||||
logs.info("OTA resume: pending download " + downloadId + " (cmdId: " + cmdId + ") status=" + status);
|
||||
|
||||
switch (status) {
|
||||
case DownloadManager.STATUS_SUCCESSFUL:
|
||||
handleDownloadComplete(appContext, cmdId);
|
||||
break;
|
||||
case DownloadManager.STATUS_RUNNING:
|
||||
case DownloadManager.STATUS_PENDING:
|
||||
case DownloadManager.STATUS_PAUSED:
|
||||
registerCompletionReceiver(appContext);
|
||||
startProgressMonitor(appContext, downloadId, cmdId);
|
||||
break;
|
||||
case DownloadManager.STATUS_FAILED:
|
||||
default:
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED,
|
||||
"OTA download failed or record missing after restart.");
|
||||
}
|
||||
clearState(appContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 內部流程
|
||||
// ====================================================================
|
||||
|
||||
/** 註冊下載完成廣播(runtime)。重複呼叫會先解除舊的,避免洩漏。 */
|
||||
private static synchronized void registerCompletionReceiver(Context appContext) {
|
||||
unregisterCompletionReceiver(appContext);
|
||||
completionReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void run() {
|
||||
MqttManager mqttManager = MqttManager.getInstance();
|
||||
try {
|
||||
logs.info("Starting OTA update process. URL: " + apkUrl + " (cmdId: " + cmdId + ")");
|
||||
mqttManager.publishCommandAck(cmdId, "success", "Downloading update APK...");
|
||||
|
||||
// 1. 背景下載 APK 到 APP 私有快取空間
|
||||
File apkFile = downloadApk(context, apkUrl);
|
||||
if (apkFile == null || !apkFile.exists()) {
|
||||
throw new Exception("Downloaded APK file is null or does not exist.");
|
||||
}
|
||||
logs.info("APK downloaded successfully to: " + apkFile.getAbsolutePath());
|
||||
|
||||
// 2. 執行 Root 靜默安裝
|
||||
mqttManager.publishCommandAck(cmdId, "success", "Installing update APK...");
|
||||
boolean installSuccess = silentInstall(apkFile.getAbsolutePath());
|
||||
|
||||
if (installSuccess) {
|
||||
logs.info("Silent installation triggered successfully. App should be restarted by BootReceiver soon.");
|
||||
// 安裝成功後,系統會因為 ACTION_MY_PACKAGE_REPLACED 自動重啟主 APP。
|
||||
// 此處已不需要再做其他處理。
|
||||
} else {
|
||||
throw new Exception("Silent installation command returned non-zero status.");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logs.error(com.unibuy.smartdevice.exception.ErrorCode.UNKNOWN_ERROR, "OTA Update failed: " + e.getMessage());
|
||||
mqttManager.publishCommandAck(cmdId, "failed", "OTA Update failed: " + e.getMessage());
|
||||
public void onReceive(Context ctx, Intent intent) {
|
||||
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
|
||||
long expected = prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1);
|
||||
if (id >= 0 && id == expected) {
|
||||
String cmdId = prefs(appContext).getString(KEY_CMD_ID, null);
|
||||
handleDownloadComplete(appContext, cmdId);
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
};
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.registerReceiver(completionReceiver, filter, Context.RECEIVER_EXPORTED);
|
||||
} else {
|
||||
appContext.registerReceiver(completionReceiver, filter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 從網路下載 APK 檔案並存入 APP 私有快取目錄中
|
||||
*/
|
||||
private static File downloadApk(Context context, String apkUrl) throws Exception {
|
||||
URL url = new URL(apkUrl);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(DOWNLOAD_CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(DOWNLOAD_READ_TIMEOUT_MS);
|
||||
connection.connect();
|
||||
|
||||
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
||||
throw new Exception("Server returned HTTP " + connection.getResponseCode()
|
||||
+ " " + connection.getResponseMessage());
|
||||
}
|
||||
|
||||
// 使用 APP 私有的外部快取目錄,免去 Android 12 的 WRITE_EXTERNAL_STORAGE 權限宣告與申請
|
||||
File cacheDir = context.getExternalCacheDir();
|
||||
if (cacheDir == null) {
|
||||
cacheDir = context.getCacheDir();
|
||||
}
|
||||
File apkFile = new File(cacheDir, "update_ota.apk");
|
||||
if (apkFile.exists()) {
|
||||
apkFile.delete();
|
||||
}
|
||||
|
||||
try (InputStream input = new BufferedInputStream(connection.getInputStream());
|
||||
FileOutputStream output = new FileOutputStream(apkFile)) {
|
||||
|
||||
byte[] data = new byte[4096];
|
||||
int count;
|
||||
while ((count = input.read(data)) != -1) {
|
||||
output.write(data, 0, count);
|
||||
private static synchronized void unregisterCompletionReceiver(Context appContext) {
|
||||
if (completionReceiver != null) {
|
||||
try {
|
||||
appContext.unregisterReceiver(completionReceiver);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
output.flush();
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
completionReceiver = null;
|
||||
}
|
||||
return apkFile;
|
||||
}
|
||||
|
||||
/** 背景輪詢下載進度並節流回報,下載結束(非進行中)即停止。 */
|
||||
private static void startProgressMonitor(Context appContext, long downloadId, String cmdId) {
|
||||
if (monitoringDownloadId == downloadId) {
|
||||
return; // 已在監看,避免重複起執行緒
|
||||
}
|
||||
monitoringDownloadId = downloadId;
|
||||
new Thread(() -> {
|
||||
DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
if (dm == null) {
|
||||
monitoringDownloadId = -1;
|
||||
return;
|
||||
}
|
||||
int lastPercent = -1;
|
||||
try {
|
||||
while (true) {
|
||||
Thread.sleep(PROGRESS_REPORT_INTERVAL_MS);
|
||||
if (prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1) != downloadId) {
|
||||
break; // 狀態已被清除或換成新下載
|
||||
}
|
||||
int[] info = queryProgress(dm, downloadId); // {status, percent}
|
||||
int status = info[0];
|
||||
int percent = info[1];
|
||||
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING
|
||||
|| status == DownloadManager.STATUS_PAUSED) {
|
||||
if (percent >= 0 && percent != lastPercent) {
|
||||
lastPercent = percent;
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS,
|
||||
"Downloading update APK... " + percent + "%");
|
||||
}
|
||||
} else {
|
||||
break; // 成功/失敗皆交由完成廣播或 resume 處理
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
} finally {
|
||||
monitoringDownloadId = -1;
|
||||
}
|
||||
}, "ota-progress-monitor").start();
|
||||
}
|
||||
|
||||
/** 下載完成:標記為「待安裝」並等待機台閒置後才安裝。 */
|
||||
private static synchronized void handleDownloadComplete(Context appContext, String cmdId) {
|
||||
SharedPreferences prefs = prefs(appContext);
|
||||
// 已進入待安裝/安裝階段就不重複觸發(完成廣播 + resume 可能同時到達)
|
||||
String currentPhase = prefs.getString(KEY_PHASE, PHASE_DOWNLOADING);
|
||||
if (PHASE_READY.equals(currentPhase) || PHASE_INSTALLING.equals(currentPhase)) {
|
||||
return;
|
||||
}
|
||||
long downloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1);
|
||||
DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
if (dm == null || queryStatus(dm, downloadId) != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, "OTA download did not complete.");
|
||||
}
|
||||
clearState(appContext);
|
||||
return;
|
||||
}
|
||||
unregisterCompletionReceiver(appContext);
|
||||
File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME);
|
||||
if (!apkFile.exists()) {
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED, "Downloaded APK file missing.");
|
||||
}
|
||||
clearState(appContext);
|
||||
return;
|
||||
}
|
||||
logs.info("APK downloaded successfully to: " + apkFile.getAbsolutePath());
|
||||
prefs.edit().putString(KEY_PHASE, PHASE_READY).apply();
|
||||
scheduleInstallWhenIdle(appContext, apkFile, cmdId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 調用 Root 權限執行 pm install -r 靜默安裝
|
||||
* 等待機台閒置後才安裝:背景輪詢,僅在「不忙碌(無對話框/NFC 交易)且閒置達門檻」時
|
||||
* 觸發安裝;忙碌則持續等待並定期回報「等待閒置中」。可重入:已在等待則直接返回。
|
||||
*/
|
||||
private static void scheduleInstallWhenIdle(Context appContext, File apkFile, String cmdId) {
|
||||
if (awaitingIdleInstall) {
|
||||
return;
|
||||
}
|
||||
awaitingIdleInstall = true;
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS,
|
||||
"Update downloaded, waiting for machine idle to install...");
|
||||
new Thread(() -> {
|
||||
int checks = 0;
|
||||
try {
|
||||
while (true) {
|
||||
// 狀態已被清除(如安裝完成/失敗)或不再是待安裝階段 → 結束等待
|
||||
if (!PHASE_READY.equals(prefs(appContext).getString(KEY_PHASE, ""))) {
|
||||
break;
|
||||
}
|
||||
String busyReason = com.unibuy.smartdevice.MyApp.getInstance().getBusyReason();
|
||||
long idle = com.unibuy.smartdevice.MyApp.getInstance().getIdleSeconds();
|
||||
if (busyReason.isEmpty() && idle >= INSTALL_REQUIRED_IDLE_SECONDS) {
|
||||
logs.info("OTA install gate passed (idle " + idle + "s). Installing now.");
|
||||
installNow(appContext, apkFile, cmdId);
|
||||
break;
|
||||
}
|
||||
if (checks % WAIT_NOTIFY_EVERY_N_CHECKS == 0) {
|
||||
String detail = !busyReason.isEmpty() ? busyReason : ("idle " + idle + "s");
|
||||
logs.info("OTA install deferred, machine in use (" + detail + "). Waiting...");
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS,
|
||||
"Downloaded, waiting for machine idle to install (" + detail + ")...");
|
||||
}
|
||||
checks++;
|
||||
Thread.sleep(IDLE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
} finally {
|
||||
awaitingIdleInstall = false;
|
||||
}
|
||||
}, "ota-install-idle-gate").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行靜默安裝。安裝成功(exit 0)後本程序即將被新版替換,終態 success 由
|
||||
* {@link #confirmAfterInstall} 於新版啟動後回報;安裝指令失敗則就地回報 failed。
|
||||
*/
|
||||
private static void installNow(Context appContext, File apkFile, String cmdId) {
|
||||
prefs(appContext).edit().putString(KEY_PHASE, PHASE_INSTALLING).apply();
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, "Installing update APK...");
|
||||
boolean triggered = silentInstall(apkFile.getAbsolutePath());
|
||||
if (!triggered) {
|
||||
// pm install 回傳非 0(無 root / 簽章不符等),此時 App 未被替換,可直接回報失敗
|
||||
logs.error(ErrorCode.UNKNOWN_ERROR, "Silent install command returned non-zero status.");
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED,
|
||||
"Silent install failed (root/signature?).");
|
||||
}
|
||||
clearState(appContext);
|
||||
} else {
|
||||
logs.info("Silent installation triggered. Awaiting package replacement & restart for confirmation.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 新版啟動後比對實際版本,回報最終成敗。 */
|
||||
private static void confirmAfterInstall(Context appContext, String cmdId) {
|
||||
SharedPreferences prefs = prefs(appContext);
|
||||
String expected = prefs.getString(KEY_VERSION, null);
|
||||
String current = currentVersionName(appContext);
|
||||
logs.info("OTA confirm after install. expected=" + expected + " current=" + current);
|
||||
|
||||
boolean ok = (expected == null) || expected.equals(current);
|
||||
if (ok) {
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_SUCCESS,
|
||||
"App updated to " + (current != null ? current : "(unknown)") + ".");
|
||||
}
|
||||
clearState(appContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// 版本未變:可能上次安裝被中途打斷。APK 還在就重試一次,否則回報失敗。
|
||||
int attempts = prefs.getInt(KEY_INSTALL_ATTEMPTS, 0);
|
||||
File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME);
|
||||
if (apkFile.exists() && attempts < MAX_INSTALL_ATTEMPTS) {
|
||||
prefs.edit().putInt(KEY_INSTALL_ATTEMPTS, attempts + 1).apply();
|
||||
logs.info("OTA confirm: version mismatch, retrying install (attempt " + (attempts + 1) + ").");
|
||||
installNow(appContext, apkFile, cmdId);
|
||||
} else {
|
||||
if (cmdId != null) {
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED,
|
||||
"Install did not result in expected version " + expected
|
||||
+ " (current " + current + ").");
|
||||
}
|
||||
clearState(appContext);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 工具
|
||||
// ====================================================================
|
||||
|
||||
private static SharedPreferences prefs(Context appContext) {
|
||||
return appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private static void clearState(Context appContext) {
|
||||
prefs(appContext).edit().clear().apply();
|
||||
monitoringDownloadId = -1;
|
||||
awaitingIdleInstall = false;
|
||||
unregisterCompletionReceiver(appContext);
|
||||
}
|
||||
|
||||
/** 取消並移除先前殘留的下載任務。 */
|
||||
private static void cancelExisting(Context appContext) {
|
||||
long prev = prefs(appContext).getLong(KEY_DOWNLOAD_ID, -1);
|
||||
if (prev >= 0) {
|
||||
DownloadManager dm = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
if (dm != null) {
|
||||
try {
|
||||
dm.remove(prev);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
clearState(appContext);
|
||||
}
|
||||
|
||||
private static int queryStatus(DownloadManager dm, long downloadId) {
|
||||
return queryProgress(dm, downloadId)[0];
|
||||
}
|
||||
|
||||
/** @return {status, percent};查無紀錄回 {STATUS_FAILED, -1}。 */
|
||||
private static int[] queryProgress(DownloadManager dm, long downloadId) {
|
||||
Cursor c = null;
|
||||
try {
|
||||
c = dm.query(new DownloadManager.Query().setFilterById(downloadId));
|
||||
if (c != null && c.moveToFirst()) {
|
||||
int status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
long done = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
||||
long total = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
||||
int percent = (total > 0) ? (int) (done * 100 / total) : -1;
|
||||
return new int[]{status, percent};
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("OTA queryProgress error: " + e.getMessage());
|
||||
} finally {
|
||||
if (c != null) c.close();
|
||||
}
|
||||
return new int[]{DownloadManager.STATUS_FAILED, -1};
|
||||
}
|
||||
|
||||
private static String currentVersionName(Context appContext) {
|
||||
try {
|
||||
return appContext.getPackageManager()
|
||||
.getPackageInfo(appContext.getPackageName(), 0).versionName;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退回方案:DownloadManager 不可用時的直連下載(背景 Thread,不可續傳)。
|
||||
* 下載完成後一樣走 {@link #installNow} 與安裝後版本確認流程。
|
||||
*/
|
||||
private static void legacyDownloadAndInstall(Context appContext, String apkUrl, String cmdId, String expectedVersion) {
|
||||
prefs(appContext).edit()
|
||||
.putLong(KEY_DOWNLOAD_ID, 0) // 佔位,使 resume 視為有待辦(安裝階段)
|
||||
.putString(KEY_CMD_ID, cmdId)
|
||||
.putString(KEY_VERSION, expectedVersion)
|
||||
.putString(KEY_PHASE, PHASE_DOWNLOADING)
|
||||
.putInt(KEY_INSTALL_ATTEMPTS, 0)
|
||||
.apply();
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_PROGRESS, "Downloading update APK...");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
File apkFile = new File(appContext.getExternalFilesDir(null), APK_FILE_NAME);
|
||||
if (apkFile.exists()) apkFile.delete();
|
||||
|
||||
java.net.HttpURLConnection conn =
|
||||
(java.net.HttpURLConnection) new java.net.URL(apkUrl).openConnection();
|
||||
conn.setConnectTimeout(30000);
|
||||
conn.setReadTimeout(120000);
|
||||
conn.connect();
|
||||
if (conn.getResponseCode() != java.net.HttpURLConnection.HTTP_OK) {
|
||||
throw new Exception("Server returned HTTP " + conn.getResponseCode());
|
||||
}
|
||||
try (java.io.InputStream in = new java.io.BufferedInputStream(conn.getInputStream());
|
||||
java.io.FileOutputStream out = new java.io.FileOutputStream(apkFile)) {
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
out.flush();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
logs.info("APK downloaded (legacy) to: " + apkFile.getAbsolutePath());
|
||||
prefs(appContext).edit().putString(KEY_PHASE, PHASE_READY).apply();
|
||||
scheduleInstallWhenIdle(appContext, apkFile, cmdId);
|
||||
} catch (Exception e) {
|
||||
logs.error(ErrorCode.UNKNOWN_ERROR, "Legacy OTA download failed: " + e.getMessage());
|
||||
MqttManager.getInstance().publishCommandAck(cmdId, RESULT_FAILED,
|
||||
"OTA Update failed: " + e.getMessage());
|
||||
clearState(appContext);
|
||||
}
|
||||
}, "ota-legacy-download").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 調用 Root 權限執行 pm install -r 靜默安裝。
|
||||
*
|
||||
* @return 安裝指令是否成功觸發(exit code 0)
|
||||
*/
|
||||
private static boolean silentInstall(String apkPath) {
|
||||
Process process = null;
|
||||
@ -128,7 +562,8 @@ public class OtaManager {
|
||||
try {
|
||||
if (os != null) os.close();
|
||||
if (process != null) process.destroy();
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,7 @@ import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class SimpleCashAcceptorHandler {
|
||||
public class SimpleCashAcceptorHandler implements ICashAcceptorHandler {
|
||||
|
||||
// 使用你找到的 VID 和 PID
|
||||
private static final int VENDOR_ID = 0x067B;
|
||||
@ -33,6 +33,8 @@ public class SimpleCashAcceptorHandler {
|
||||
|
||||
// 這裡要新增一個成員變數來儲存傳入的監聽器
|
||||
private MdbEventListener eventListener;
|
||||
// 除錯訊息監聽器(供現金機設定測試頁顯示診斷)
|
||||
private DebugLogListener debugListener;
|
||||
|
||||
|
||||
// **修改後的建構子**
|
||||
@ -43,9 +45,19 @@ public class SimpleCashAcceptorHandler {
|
||||
}
|
||||
|
||||
|
||||
// 確保這裡的介面是 public
|
||||
public interface MdbEventListener {
|
||||
void onEventReceived(String event);
|
||||
// MdbEventListener / DebugLogListener 已抽為套件層級共用介面(見 tools/)
|
||||
|
||||
@Override
|
||||
public void setDebugListener(DebugLogListener listener) {
|
||||
this.debugListener = listener;
|
||||
}
|
||||
|
||||
/** 同時寫 log 並回拋 UI Debug 顯示。 */
|
||||
private void sendDebug(String message) {
|
||||
Log.d("MDB_Handler", message);
|
||||
if (debugListener != null) {
|
||||
debugListener.onDebugLog(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -55,6 +67,7 @@ public class SimpleCashAcceptorHandler {
|
||||
*/
|
||||
public String connect() {
|
||||
|
||||
sendDebug("[連線] 開始搜尋 USB MDB 轉接器 (VID/PID)…");
|
||||
|
||||
HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList();
|
||||
Iterator<UsbDevice> deviceIterator = deviceList.values().iterator();
|
||||
@ -134,7 +147,7 @@ public class SimpleCashAcceptorHandler {
|
||||
}
|
||||
|
||||
connection.claimInterface(usbInterface, true);
|
||||
Log.d("CashAcceptor", "成功連接到 USB 轉接器!");
|
||||
sendDebug("[連線] ✅ 成功連接到 USB 轉接器");
|
||||
return "成功連接到 USB 轉接器!";
|
||||
|
||||
|
||||
|
||||
@ -346,8 +346,12 @@ public class Tools {
|
||||
public void setImmOpenByFocusChange(EditText editText) {
|
||||
editText.requestFocus();
|
||||
editText.setSelection(editText.getText().length());
|
||||
InputMethodManager imm = (InputMethodManager) context.getSystemService(context.INPUT_METHOD_SERVICE);
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
|
||||
// 使用 post() 延遲執行,確保 hideSystemBars() 觸發的 Window 重繪完成後才彈出鍵盤
|
||||
// 若直接呼叫 showSoftInput,Window flag 變更會清除 focus,導致鍵盤無法彈出
|
||||
editText.post(() -> {
|
||||
InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
|
||||
});
|
||||
}
|
||||
|
||||
public void setImmHideByFocusChange(EditText editText) {
|
||||
|
||||
@ -4,7 +4,9 @@ import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.external.mqtt.MqttManager;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.InvoiceStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportFlowInfoStructure;
|
||||
import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload;
|
||||
|
||||
@ -45,20 +47,76 @@ public class TransactionFinalizeBuilder {
|
||||
/** 累積的出貨結果清單 */
|
||||
private final List<TransactionFinalizePayload.DispenseRecord> dispenseRecords = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 商品明細快照。在「buyList 仍完整」時擷取(例如進入出貨流程當下);
|
||||
* 之後 build() 會優先採用此快照,不再即時讀 live buyList。
|
||||
* 用以修正掃碼支付(玉山)等較慢流程:結案前 buyList 可能被取消/倒數路徑清空,
|
||||
* 導致 order.items 變成空陣列(出現「無商品紀錄」),但出貨其實成功。
|
||||
*/
|
||||
private List<TransactionFinalizePayload.ItemRecord> itemsSnapshot = null;
|
||||
|
||||
private String codeId = "0";
|
||||
|
||||
/** 交易生命週期狀態(pending/completed/failed/abandoned);預設 completed 維持成功路徑原行為。 */
|
||||
private String status = "completed";
|
||||
|
||||
/** 支付狀態:1=成功、0=失敗/未付款;預設 1 維持成功路徑原行為。 */
|
||||
private int paymentStatus = 1;
|
||||
|
||||
/** 失敗結果碼(如刷卡機 0015);非空時折進 payment_response 方便後台查閱。 */
|
||||
private String resultCode = null;
|
||||
|
||||
// 訂單層級欄位的快照(在「全域 flowInfo 仍正確」時由 captureOrderInfo() 擷取)。
|
||||
// null 代表未快照 → build() 退回讀全域即時值(保留 publishPending/publishFailed 等同步路徑的原行為)。
|
||||
// 出貨是個長流程,期間全域 flowInfo 可能被別的路徑(殘留加購倒數彈出 BuyDialog →
|
||||
// initialReportData())洗回 0;用快照可避免 payment_type/total 在結案當下被誤讀成 0「未知」,
|
||||
// 也避免通行碼(5)的不上報判斷失效。與既有 captureItems()(商品明細快照)一致。
|
||||
private Integer snapFlowType = null;
|
||||
private Integer snapTotalAmount = null;
|
||||
private Integer snapChangeAmount = null;
|
||||
private Integer snapPointsUsed = null;
|
||||
|
||||
/** 現金收款面額明細快照(僅現金流程有值);與其他 order 欄位快照同時擷取,避免結案前被全域清空。 */
|
||||
private TransactionFinalizePayload.CashDetail snapCashDetail = null;
|
||||
|
||||
public TransactionFinalizeBuilder() {
|
||||
this.orderMachineTime = SDF.format(new Date());
|
||||
// 在進入出貨流程時立即生成本次交易流水號(一次交易只生成一次)
|
||||
this.flowId = FlowIdGenerator.next(MyApp.getInstance());
|
||||
// flow_id 共用「進入付款時生成的 mqttFlowId」,讓 pending → completed/failed 為同一筆,
|
||||
// 後台才能依 flow_id 做狀態轉移。若尚未生成(防禦)則即時生成並回存 flowInfo。
|
||||
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
|
||||
String existing = flowInfo != null ? flowInfo.getMqttFlowId() : null;
|
||||
if (existing != null && !existing.isEmpty()) {
|
||||
this.flowId = existing;
|
||||
} else {
|
||||
this.flowId = FlowIdGenerator.next(MyApp.getInstance());
|
||||
if (flowInfo != null) {
|
||||
flowInfo.setMqttFlowId(this.flowId);
|
||||
}
|
||||
}
|
||||
this.orderNo = this.flowId;
|
||||
Log.i(TAG, "TransactionFinalizeBuilder created, flow_id=" + this.flowId);
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public void setPaymentStatus(int paymentStatus) {
|
||||
this.paymentStatus = paymentStatus;
|
||||
}
|
||||
|
||||
public void setResultCode(String resultCode) {
|
||||
this.resultCode = resultCode;
|
||||
}
|
||||
|
||||
public void setFlowId(String flowId) {
|
||||
this.flowId = flowId;
|
||||
}
|
||||
|
||||
public String getFlowId() {
|
||||
return flowId;
|
||||
}
|
||||
|
||||
public void setOrderNo(String orderNo) {
|
||||
this.orderNo = orderNo;
|
||||
}
|
||||
@ -67,6 +125,165 @@ public class TransactionFinalizeBuilder {
|
||||
this.codeId = codeId;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 交易生命週期上報(pending / failed)。只在 basic 購物模式(本專案 general/demo)才送:
|
||||
// - basic:以「進付款時生成的同一 flow_id」上報,後台依 flow_id 做 pending → completed/failed 轉移。
|
||||
// - employee_card(晟崴)/pickup_sheet(中國醫):無實際付款,直接 return 不送,後台資料不受影響。
|
||||
// 用 shopping_mode 判斷(雲端 B014 已逐機台下發),不另設旗標。
|
||||
// 成功(completed)仍由 DispatchDialog 出貨完成後的既有流程上報,不在此處。
|
||||
// =========================================================================
|
||||
|
||||
/** 僅 basic 購物模式才上報生命週期;其餘模式不送。 */
|
||||
private static boolean lifecycleLoggingEnabled() {
|
||||
return "basic".equals(MyApp.getInstance().getShoppingMode());
|
||||
}
|
||||
|
||||
/** 進入付款時上報 pending(進付款一律留底)。非 basic 模式則不送。 */
|
||||
public static void publishPending() {
|
||||
if (!lifecycleLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TransactionFinalizeBuilder b = new TransactionFinalizeBuilder();
|
||||
b.setStatus("pending");
|
||||
b.setPaymentStatus(0);
|
||||
MqttManager.getInstance().publishTransactionFinalize(b.build());
|
||||
Log.i(TAG, "published pending finalize, flow_id=" + b.getFlowId());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "publishPending failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款失敗/逾時時上報。非 basic 模式則不送。
|
||||
* - 0003 交易逾時 / 0006 使用者終止:顧客根本沒完成刷卡(電文無卡號)、不可能扣款,
|
||||
* 視為「未完成 abandoned」而非付款失敗 → 後台立即把 pending 轉 abandoned,
|
||||
* 列表不顯示為「失敗」,且不帶 code 故不產生刷卡機硬體警告。
|
||||
* - 其餘(空電文、真實拒絕碼 0001/0002/0005 通訊失敗/0015/0016/1001… 等,卡片有讀到或可能已扣款):
|
||||
* 照常上報 failed(帶 code),留底供對帳。
|
||||
*/
|
||||
public static void publishFailed(String resultCode) {
|
||||
if (!lifecycleLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
// 未完成(沒真的刷到卡):逾時 / 使用者終止 → 改判 abandoned,不算付款失敗
|
||||
boolean abandonedNotFailed = "0003".equals(resultCode) || "0006".equals(resultCode);
|
||||
try {
|
||||
TransactionFinalizeBuilder b = new TransactionFinalizeBuilder();
|
||||
b.setPaymentStatus(0);
|
||||
if (abandonedNotFailed) {
|
||||
b.setStatus("abandoned");
|
||||
// 不帶 code:後台 regex 取不到回應碼 → 不會記為刷卡機硬體警告
|
||||
MqttManager.getInstance().publishTransactionFinalize(b.build());
|
||||
Log.i(TAG, "published abandoned finalize (no real tap) code=" + resultCode + ", flow_id=" + b.getFlowId());
|
||||
} else {
|
||||
b.setStatus("failed");
|
||||
b.setResultCode(resultCode);
|
||||
MqttManager.getInstance().publishTransactionFinalize(b.build());
|
||||
Log.i(TAG, "published failed finalize code=" + resultCode + ", flow_id=" + b.getFlowId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "publishFailed failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 擷取目前 buyList 的商品明細快照。
|
||||
* 應在「buyList 仍完整」時呼叫(例如 DispatchDialog 進入出貨流程當下);
|
||||
* 之後 {@link #build()} 會優先採用此快照,不再即時讀 live buyList,
|
||||
* 避免結案前 buyList 被取消/倒數等其他執行緒清空,導致 order.items 變成空陣列。
|
||||
* buyList 為空時不覆寫既有快照(避免把有效快照洗成空)。
|
||||
*/
|
||||
public void captureItems() {
|
||||
List<BuyStructure> buyList = MyApp.getInstance().getBuyList();
|
||||
if (buyList == null || buyList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
this.itemsSnapshot = buildItemRecords(buyList);
|
||||
Log.i(TAG, "captureItems: snapshot " + this.itemsSnapshot.size()
|
||||
+ " item(s), flow_id=" + flowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 擷取訂單層級欄位(flowType / 總金額 / 找零 / 點數)的快照。
|
||||
* 應在「全域 flowInfo 已是本筆最終值」時呼叫(例如 DispatchDialog 進入出貨、且 CMUH→42
|
||||
* 覆寫之後)。之後 {@link #build()} 與「通行碼不上報」判斷皆優先採用此快照,不再即時讀全域,
|
||||
* 避免結案前全域被其他路徑清空導致 payment_type/total 落地成 0「未知」或誤報通行碼。
|
||||
*/
|
||||
public void captureOrderInfo() {
|
||||
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
|
||||
if (flowInfo == null) {
|
||||
return;
|
||||
}
|
||||
this.snapFlowType = flowInfo.getFlowType();
|
||||
this.snapTotalAmount = flowInfo.getTotalPrice();
|
||||
this.snapChangeAmount = flowInfo.getGiveChange();
|
||||
this.snapPointsUsed = flowInfo.getUsePoints();
|
||||
// 現金流程才擷取面額明細快照(其餘支付方式無投幣統計,維持 null → 不帶 cash_detail)
|
||||
if (flowInfo.getFlowType() == 9) {
|
||||
this.snapCashDetail = buildCashDetail(flowInfo);
|
||||
}
|
||||
Log.i(TAG, "captureOrderInfo: flowType=" + snapFlowType + ", total=" + snapTotalAmount
|
||||
+ ", flow_id=" + flowId);
|
||||
}
|
||||
|
||||
/** 由 flowInfo 的收款張數欄位組出 cash_detail(captureOrderInfo 與 build() fallback 共用)。 */
|
||||
private TransactionFinalizePayload.CashDetail buildCashDetail(ReportFlowInfoStructure flowInfo) {
|
||||
return new TransactionFinalizePayload.CashDetail(
|
||||
flowInfo.getCashBill1000(),
|
||||
flowInfo.getCashBill500(),
|
||||
flowInfo.getCashBill100(),
|
||||
flowInfo.getCashCoin50(),
|
||||
flowInfo.getCashCoin10(),
|
||||
flowInfo.getCashCoin5(),
|
||||
flowInfo.getCashCoin1());
|
||||
}
|
||||
|
||||
/** 取得快照的 flowType;尚未快照時退回全域即時值。供「通行碼(5)不上報」判斷使用。 */
|
||||
public int getOrderFlowType() {
|
||||
if (snapFlowType != null) {
|
||||
return snapFlowType;
|
||||
}
|
||||
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
|
||||
return flowInfo != null ? flowInfo.getFlowType() : 0;
|
||||
}
|
||||
|
||||
// 訂單欄位取值:有快照用快照,否則退回全域即時值(build() 共用)。
|
||||
private int effectiveFlowType(ReportFlowInfoStructure flowInfo) {
|
||||
return snapFlowType != null ? snapFlowType : flowInfo.getFlowType();
|
||||
}
|
||||
|
||||
private int effectiveTotalAmount(ReportFlowInfoStructure flowInfo) {
|
||||
return snapTotalAmount != null ? snapTotalAmount : flowInfo.getTotalPrice();
|
||||
}
|
||||
|
||||
private int effectiveChangeAmount(ReportFlowInfoStructure flowInfo) {
|
||||
return snapChangeAmount != null ? snapChangeAmount : flowInfo.getGiveChange();
|
||||
}
|
||||
|
||||
private int effectivePointsUsed(ReportFlowInfoStructure flowInfo) {
|
||||
return snapPointsUsed != null ? snapPointsUsed : flowInfo.getUsePoints();
|
||||
}
|
||||
|
||||
/** 由 buyList 組出 items 明細(captureItems 與 build() fallback 共用)。 */
|
||||
private List<TransactionFinalizePayload.ItemRecord> buildItemRecords(List<BuyStructure> buyList) {
|
||||
List<TransactionFinalizePayload.ItemRecord> items = new ArrayList<>();
|
||||
if (buyList != null) {
|
||||
for (BuyStructure buy : buyList) {
|
||||
int productId = 0;
|
||||
try {
|
||||
productId = Integer.parseInt(buy.getProduct().getProductID());
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Cannot parse productID to int: " + buy.getProduct().getProductID());
|
||||
}
|
||||
int price = buy.getProduct().getRealPrice();
|
||||
int quantity = buy.getCount() > 0 ? buy.getCount() : 1;
|
||||
items.add(new TransactionFinalizePayload.ItemRecord(productId, price, quantity));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入一筆出貨結果記錄(每個貨道出貨後呼叫一次)。
|
||||
@ -123,7 +340,7 @@ public class TransactionFinalizeBuilder {
|
||||
|
||||
|
||||
// --- payment_type:支付類型 (Root 級別) ---
|
||||
payload.setPayment_type(flowInfo.getFlowType());
|
||||
payload.setPayment_type(effectiveFlowType(flowInfo));
|
||||
|
||||
// --- 組裝 order ---
|
||||
TransactionFinalizePayload.OrderPayload order = buildOrderPayload(flowInfo);
|
||||
@ -133,6 +350,26 @@ public class TransactionFinalizeBuilder {
|
||||
// --- 出貨記錄 ---
|
||||
payload.setDispense(new ArrayList<>(dispenseRecords));
|
||||
|
||||
// --- 電子發票(改由後台開立)---
|
||||
// 機台不開票,只把「開立輸入」(載具/愛心碼/統編/金額)帶上去,狀態固定 pending;
|
||||
// 後台建 pending 發票後派 Job 去綠界開立。無發票需求(invoiceInfo 為空)時不帶,後台不建立發票。
|
||||
// 只有付款成功(completed)才建立發票;pending/failed 不開票(避免對未成交的交易開立)。
|
||||
if ("completed".equals(status)
|
||||
&& flowInfo.getInvoiceInfo() != null && !flowInfo.getInvoiceInfo().isEmpty()) {
|
||||
TransactionFinalizePayload.InvoicePayload invoicePayload =
|
||||
new TransactionFinalizePayload.InvoicePayload();
|
||||
invoicePayload.setStatus("pending");
|
||||
invoicePayload.setAmount(effectiveTotalAmount(flowInfo));
|
||||
|
||||
InvoiceStructure invoiceData = MyApp.getInstance().getInvoiceData();
|
||||
if (invoiceData != null) {
|
||||
invoicePayload.setCarrier_id(invoiceData.getCarrier());
|
||||
invoicePayload.setBusiness_tax_id(invoiceData.getCustomerIdentifier());
|
||||
invoicePayload.setLove_code(invoiceData.getLoveCode());
|
||||
}
|
||||
payload.setInvoice(invoicePayload);
|
||||
}
|
||||
|
||||
Log.i(TAG, "TransactionFinalizePayload built: flow_id=" + flowId
|
||||
+ ", order_no=" + order.getOrder_no()
|
||||
+ ", items=" + order.getItems().size()
|
||||
@ -170,24 +407,34 @@ public class TransactionFinalizeBuilder {
|
||||
|
||||
order.setOrder_no(orderNo != null && !orderNo.isEmpty() ? orderNo : flowId);
|
||||
|
||||
// 金額資訊
|
||||
int totalAmount = flowInfo.getTotalPrice();
|
||||
// 金額資訊(優先採快照,未快照時退回全域即時值)
|
||||
int totalAmount = effectiveTotalAmount(flowInfo);
|
||||
order.setOriginal_amount(totalAmount);
|
||||
order.setDiscount_amount(0); // TODO: 如有折扣活動需進一步填入
|
||||
order.setTotal_amount(totalAmount);
|
||||
order.setPay_amount(totalAmount);
|
||||
order.setChange_amount(flowInfo.getGiveChange());
|
||||
order.setPoints_used(flowInfo.getUsePoints());
|
||||
order.setChange_amount(effectiveChangeAmount(flowInfo));
|
||||
order.setPoints_used(effectivePointsUsed(flowInfo));
|
||||
|
||||
// 現金交易(payment_type=9)才帶面額明細 cash_detail:優先用快照,未快照時退回全域即時值。
|
||||
if (effectiveFlowType(flowInfo) == 9) {
|
||||
order.setCash_detail(snapCashDetail != null ? snapCashDetail : buildCashDetail(flowInfo));
|
||||
}
|
||||
|
||||
// 支付類型:flowType 直接對應 payment_type
|
||||
order.setPayment_type(flowInfo.getFlowType());
|
||||
order.setPayment_status(1); // 走到出貨流程代表支付已成功
|
||||
order.setPayment_type(effectiveFlowType(flowInfo));
|
||||
// 生命週期狀態與支付狀態:成功路徑沿用預設(completed/1);pending/failed 由 setter 覆寫。
|
||||
order.setStatus(status);
|
||||
order.setPayment_status(paymentStatus);
|
||||
|
||||
// 支付流程原始資料
|
||||
order.setPayment_request(
|
||||
flowInfo.getFlowSendInfo() != null ? flowInfo.getFlowSendInfo() : "");
|
||||
order.setPayment_response(
|
||||
flowInfo.getFlowRequestInfo() != null ? flowInfo.getFlowRequestInfo() : "");
|
||||
String paymentResponse = flowInfo.getFlowRequestInfo() != null ? flowInfo.getFlowRequestInfo() : "";
|
||||
if (resultCode != null && !resultCode.isEmpty()) {
|
||||
paymentResponse = paymentResponse + " | code=" + resultCode;
|
||||
}
|
||||
order.setPayment_response(paymentResponse);
|
||||
|
||||
// 會員資訊
|
||||
order.setMember_barcode(
|
||||
@ -201,20 +448,13 @@ public class TransactionFinalizeBuilder {
|
||||
order.setMachine_time(orderMachineTime);
|
||||
|
||||
// --- 組裝購買商品清單 (items) ---
|
||||
List<TransactionFinalizePayload.ItemRecord> items = new ArrayList<>();
|
||||
List<BuyStructure> buyList = MyApp.getInstance().getBuyList();
|
||||
if (buyList != null) {
|
||||
for (BuyStructure buy : buyList) {
|
||||
int productId = 0;
|
||||
try {
|
||||
productId = Integer.parseInt(buy.getProduct().getProductID());
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Cannot parse productID to int: " + buy.getProduct().getProductID());
|
||||
}
|
||||
int price = buy.getProduct().getRealPrice();
|
||||
int quantity = buy.getCount() > 0 ? buy.getCount() : 1;
|
||||
items.add(new TransactionFinalizePayload.ItemRecord(productId, price, quantity));
|
||||
}
|
||||
// 優先採用 captureItems() 在出貨流程開始時擷取的快照;無快照時才即時讀 live buyList。
|
||||
// 快照可避免結案前 buyList 被其他執行緒清空(掃碼支付等慢流程的競態),造成「無商品紀錄」。
|
||||
List<TransactionFinalizePayload.ItemRecord> items;
|
||||
if (itemsSnapshot != null && !itemsSnapshot.isEmpty()) {
|
||||
items = new ArrayList<>(itemsSnapshot);
|
||||
} else {
|
||||
items = buildItemRecords(MyApp.getInstance().getBuyList());
|
||||
}
|
||||
order.setItems(items);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
357
app/src/main/java/com/unibuy/smartdevice/ui/BasicSaleFlow.java
Normal file
357
app/src/main/java/com/unibuy/smartdevice/ui/BasicSaleFlow.java
Normal file
@ -0,0 +1,357 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.blankj.utilcode.util.ToastUtils;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.BuyDialog;
|
||||
import com.unibuy.smartdevice.ui.dialog.FontendPickupCodeDialog;
|
||||
import com.unibuy.v2.UnibuyHelper;
|
||||
import com.unibuy.v2.ui.ShoppingCartFragment;
|
||||
|
||||
/**
|
||||
* basic 模式(原 General 通用版)銷售流程。
|
||||
* 結帳時彈出標準付款 Dialog (BuyDialog);購物車浮鈕則開啟多品項購物車 Fragment。
|
||||
*/
|
||||
public class BasicSaleFlow extends SaleFlowHandler {
|
||||
|
||||
/** 多品項購物車 Fragment(懶建立,重複開啟時沿用同一實例)。 */
|
||||
private ShoppingCartFragment cartFragment;
|
||||
|
||||
// ─── 取貨口(主櫃 XinYuan)狀態常數(移植自 LockBoard-Honlifeng)───
|
||||
private static final int PICKUP_OK = 0; // 正常,可放行
|
||||
private static final int PICKUP_HAVE_THINGS = 1; // 取貨口有東西 (02 05)
|
||||
private static final int PICKUP_DOOR = 2; // 取貨口門未關 (02 06 / 02 09)
|
||||
private static final int PICKUP_ELEVATOR = 3; // 升降梯未回原點 (02 07)
|
||||
private static final int PICKUP_NO_RESPONSE = 4; // 下位機完全無通訊(可能真的斷線)
|
||||
private static final int PICKUP_ALIVE_UNKNOWN = 5; // 下位機在線但未回報取貨口狀態(忙其他協定)→放行,交後續階段再判
|
||||
|
||||
// 「點選商品」階段已判斷過取貨口的旗標與時效。直購路徑把取貨口判斷提前到點選當下,
|
||||
// 之後 BuyDialog 開啟時若旗標仍新鮮就跳過重驗取貨口(避免剛連線就偵測導致漏判、也免重複跳窗)。
|
||||
private static final long PICKUP_PRECHECK_TTL_MS = 30000;
|
||||
private static volatile long lastPickupOkAtMs = 0L;
|
||||
|
||||
// 單飛守衛:同一時間只允許一個「點選商品」檢查在進行。連續快速點商品時,若不擋,
|
||||
// 每次點擊都會疊加一輪 checkSlot/syncPackNo 到送出佇列,塞爆後觸發過載→下位機失連。
|
||||
private static volatile boolean pickupCheckRunning = false;
|
||||
|
||||
// 「點選商品」階段的合併檢查開關(連線+取貨口,比照舊專案 DaiLemei)。
|
||||
// true=點下商品當下就用一次主動問答同時判「下位機是否連線」與「取貨口是否有東西」,早退、~3.5 秒封頂:
|
||||
// 正常→開詳情頁並記旗標(直接購買略過重複連線判斷);有東西/門/升降→擋;完全無回應→視為未連線擋住。
|
||||
// false=點商品直接開詳情頁(連線判斷延到直接購買時做)。
|
||||
private static final boolean PICKUP_PRECHECK_AT_SELECT = true;
|
||||
|
||||
/** 直購取貨口預檢旗標是否仍在時效內;是則消費掉(一次性)並回 true。BuyDialog 開啟時呼叫以決定是否略過重驗。 */
|
||||
public static boolean consumeFreshPickupPreCheck() {
|
||||
boolean fresh = lastPickupOkAtMs > 0
|
||||
&& android.os.SystemClock.elapsedRealtime() - lastPickupOkAtMs <= PICKUP_PRECHECK_TTL_MS;
|
||||
lastPickupOkAtMs = 0L; // 用過即失效,避免下一筆誤用舊結果
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** 只查詢(不消費)預檢旗標是否仍新鮮。onBuyRequested 用來決定是否略過重複的下位機連線判斷。 */
|
||||
private boolean isFreshPickupPreCheck() {
|
||||
return lastPickupOkAtMs > 0
|
||||
&& android.os.SystemClock.elapsedRealtime() - lastPickupOkAtMs <= PICKUP_PRECHECK_TTL_MS;
|
||||
}
|
||||
|
||||
public BasicSaleFlow(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
super(context, handlerMain, callback);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBuyRequested() {
|
||||
// 下位機「連線判斷」先移除(不穩定、常誤報「未連線」):一律直接進付款,連線問題交由出貨階段處理。
|
||||
// 取貨口偵測仍保留——直購已在「點選商品」當下檢查(checkPickupOnly);其他入口(購物車/發票重開等)
|
||||
// 由 BuyDialog.checkPickupPortBeforePay 檢查取貨口(有東西/門才擋,無回應則放行)。
|
||||
new BuyDialog(context, handlerMain).show();
|
||||
}
|
||||
|
||||
/** 購物清單是否含主櫃(VMC)商品;非主櫃商品不需檢查主櫃下位機連線。 */
|
||||
private boolean buyListHasMainCabinetItem() {
|
||||
for (BuyStructure b : MyApp.getInstance().getBuyList()) {
|
||||
if (b.getField() == com.unibuy.smartdevice.devices.SlotField.VMC.getField()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 連線判斷中:轉圈圈 + 提示文字(不可取消)。 */
|
||||
private android.app.AlertDialog buildConnectingDialog() {
|
||||
android.widget.LinearLayout layout = new android.widget.LinearLayout(context);
|
||||
layout.setOrientation(android.widget.LinearLayout.HORIZONTAL);
|
||||
layout.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||
int pad = dp(32);
|
||||
layout.setPadding(pad, pad, pad, pad);
|
||||
|
||||
android.widget.ProgressBar spinner = new android.widget.ProgressBar(context);
|
||||
android.widget.LinearLayout.LayoutParams sp =
|
||||
new android.widget.LinearLayout.LayoutParams(dp(56), dp(56));
|
||||
sp.rightMargin = dp(24);
|
||||
layout.addView(spinner, sp);
|
||||
|
||||
android.widget.TextView tv = new android.widget.TextView(context);
|
||||
tv.setText("下位機連線判斷中......");
|
||||
tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 28);
|
||||
tv.setTextColor(android.graphics.Color.BLACK);
|
||||
layout.addView(tv);
|
||||
|
||||
return new android.app.AlertDialog.Builder(context)
|
||||
.setView(layout)
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
}
|
||||
|
||||
/** 主櫃未連線:跳出大字提示框。 */
|
||||
private void showNotConnectedDialog() {
|
||||
android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(context)
|
||||
.setMessage("主櫃尚未連線,請稍後再試")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("確定", (d, w) -> d.dismiss())
|
||||
.create();
|
||||
try { dialog.show(); } catch (Exception ignore) { return; }
|
||||
android.widget.TextView msg = dialog.findViewById(android.R.id.message);
|
||||
if (msg != null) msg.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 28);
|
||||
android.widget.Button btn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE);
|
||||
if (btn != null) btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 24);
|
||||
}
|
||||
|
||||
private int dp(int v) {
|
||||
return Math.round(v * context.getResources().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
/**
|
||||
* 點選 VMC 商品,依雲端 B014 DevSet.ShoppingCar 決定流程:
|
||||
* - 購物車開啟:加入多品項購物車並停留商品列表(不做取貨口檢查,出貨時才檢查)。
|
||||
* - 購物車關閉:單品直購——清空後僅放入這一筆,直接彈出 BuyDialog(商品資料+選付款)。
|
||||
* @return true 表示已消化此次點擊。
|
||||
*/
|
||||
@Override
|
||||
public boolean handleProductSelected(SlotStructure slot) {
|
||||
// 【第一階段:點選商品】直購(購物車關閉)且點的是主櫃(VMC)商品:點選當下就用一次主動問答
|
||||
// 同時判「下位機是否連線」與「取貨口是否有東西」(比照舊專案 DaiLemei,快、~3.5 秒封頂、早退)。
|
||||
// 有東西/門/升降異常 → 直接跳提示擋住,連詳情頁都不開;完全無回應 → 視為未連線擋住;
|
||||
// 正常才開詳情頁並記旗標,之後按「直接購買」若旗標仍新鮮就不再重做連線判斷(避免重複佔用串口)。
|
||||
boolean cartOn = MyApp.getInstance().getDevSet().isShoppingCar();
|
||||
if (PICKUP_PRECHECK_AT_SELECT && !cartOn && isMainCabinetSlot(slot)) {
|
||||
checkPickupOnly(slot.getSlot(), () ->
|
||||
new com.unibuy.smartdevice.ui.dialog.DialogProductDetail(context, handlerMain, slot).show());
|
||||
return true;
|
||||
}
|
||||
// 購物車開啟(加入購物車、出貨時才檢查):一律直接彈出商品詳情頁。
|
||||
new com.unibuy.smartdevice.ui.dialog.DialogProductDetail(context, handlerMain, slot).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 是否為主櫃(VMC,field 0)商品;只有主櫃才需取貨口檢查。 */
|
||||
private boolean isMainCabinetSlot(SlotStructure slot) {
|
||||
return slot != null
|
||||
&& slot.getField() == com.unibuy.smartdevice.devices.SlotField.VMC.getField();
|
||||
}
|
||||
|
||||
/**
|
||||
* 【第一階段:點選商品】一次主動問答同時判「下位機是否連線」與「取貨口是否有東西」(比照舊專案 DaiLemei)。
|
||||
* 有東西/門/升降 → 跳提示擋住(不開詳情頁);完全無回應 → 視為下位機未連線擋住;
|
||||
* 正常 → onPass 開詳情頁並記旗標(直接購買據此略過重複連線判斷,BuyDialog 也略過取貨口重驗)。
|
||||
*/
|
||||
private void checkPickupOnly(int slotnum, Runnable onPass) {
|
||||
// 單飛:已有一個檢查在進行(轉圈提示中)就忽略這次點擊,避免疊加查詢把送出佇列塞爆導致失連。
|
||||
if (pickupCheckRunning) return;
|
||||
pickupCheckRunning = true;
|
||||
final android.app.AlertDialog progress = buildProgressDialog(context.getString(R.string.pickup_checking));
|
||||
try { progress.show(); } catch (Exception ignore) {}
|
||||
new Thread(() -> {
|
||||
com.unibuy.smartdevice.exception.Logs logs = new com.unibuy.smartdevice.exception.Logs(BasicSaleFlow.class);
|
||||
// 下位機連線判斷已移除:不再自動重連重試。只做一次取貨口偵測。
|
||||
final int pickup = probePickupHasThing(slotnum);
|
||||
logs.info("點選商品-取貨口檢查 pickup=" + pickup
|
||||
+ " (0=OK,1=有東西,2=門,3=升降梯,4=無通訊,5=在線但狀態未知)");
|
||||
postMain(() -> {
|
||||
pickupCheckRunning = false; // 檢查結束復位,允許下一次點擊
|
||||
dismissQuietly(progress);
|
||||
switch (pickup) {
|
||||
case PICKUP_HAVE_THINGS:
|
||||
showPickupBlockedDialog(context.getString(R.string.there_is_something_at_the_pickup_port));
|
||||
return;
|
||||
case PICKUP_DOOR:
|
||||
showPickupBlockedDialog(context.getString(R.string.pickup_door_not_closed));
|
||||
return;
|
||||
case PICKUP_ELEVATOR:
|
||||
showPickupBlockedDialog(context.getString(R.string.pickup_elevator_not_home));
|
||||
return;
|
||||
case PICKUP_OK:
|
||||
// 收到「正常(01)」= 下位機在線且取貨口空 → 記旗標;
|
||||
// 後續「直接購買」據此略過重複連線判斷,BuyDialog 也略過取貨口重驗。
|
||||
lastPickupOkAtMs = android.os.SystemClock.elapsedRealtime();
|
||||
onPass.run();
|
||||
break;
|
||||
case PICKUP_ALIVE_UNKNOWN:
|
||||
// 下位機在線但此刻沒回報取貨口狀態(忙其他協定,3.5 秒內沒等到 VMC_02)。
|
||||
// 選擇「保守擋住」而非放行:寧可多擋也不漏放「取貨口有東西」。請客人稍候再點一次,
|
||||
// 屆時多半已收到取貨口狀態廣播即可正常判定。(非未連線,故不走重連提示)
|
||||
showPickupBlockedDialog(context.getString(R.string.pickup_status_confirming));
|
||||
break;
|
||||
default:
|
||||
// PICKUP_NO_RESPONSE:機台完全無 VMC 通訊。連線判斷已移除 → 不再擋「未連線」,
|
||||
// 直接放行開詳情頁;連線問題交由出貨階段處理(BuyDialog 取貨口無回應也放行)。
|
||||
onPass.run();
|
||||
}
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主動問答式的連線+取貨口判斷(比照舊專案 DaiLemei,快)。
|
||||
* 下位機為輪詢式主從:送 checkSlot 後機台會於下一個 POLL 主動回一筆 VMC_02,通常數百毫秒內即到,
|
||||
* controller 收到即更新 lastSlotStatus。故此處送出後只採信「送出時間之後」才到的新回覆(getSlotStatusSince),
|
||||
* 一收到明確狀態就立即回傳(早退),不空等;~1、2 秒仍無回應才補送 syncPackNo 自癒;~3.5 秒封頂。
|
||||
* 不清讀緩衝:保留廣播快取當「有東西(05)」的保險,避免清緩衝丟掉剛到的廣播(過去漏判主因)。
|
||||
*/
|
||||
private int probePickupHasThing(int slotnum) {
|
||||
com.unibuy.smartdevice.controller.DevXinYuanController dev =
|
||||
MyApp.getInstance().getDevXinYuanController();
|
||||
if (dev == null || !dev.isRun()) return PICKUP_NO_RESPONSE;
|
||||
final long startAt = System.currentTimeMillis();
|
||||
// 送出佇列若已堆積(browse 階段只會是前次查詢殘留的 checkSlot/syncPackNo),先清掉,
|
||||
// 確保這次的 checkSlot 立即排到最前面送出、不排在一堆舊指令後面而在時效內送不出去。
|
||||
// 這是連續點商品時「越查越慢、最後全部無回應」的關鍵:不清會一路堆到過載。
|
||||
try {
|
||||
if (dev.getCountBySendBuffer() > 6) dev.clearSendBuffers();
|
||||
} catch (Exception ignore) {}
|
||||
// 主動送 checkSlot 促請下位機立即回報取貨口/貨道狀態(不清讀緩衝)。
|
||||
try {
|
||||
dev.addSendBuffer(dev.getDevXinYuan().checkSlotPre(slotnum, 1));
|
||||
dev.addSendBuffer(dev.getDevXinYuan().checkSlot(slotnum));
|
||||
} catch (Exception ignore) {}
|
||||
final int MAX_MS = 3500; // 封頂 ~3.5 秒;機台在線通常 <1 秒就回,早退提前結束
|
||||
int retries = 0;
|
||||
while (System.currentTimeMillis() - startAt < MAX_MS) {
|
||||
// 只採信「送出後」才到的新 VMC_02 回覆,避免採到查詢前的舊快取
|
||||
int st = com.unibuy.smartdevice.controller.DevXinYuanController.getSlotStatusSince(startAt);
|
||||
if (st == 0x05) return PICKUP_HAVE_THINGS;
|
||||
if (st == 0x06 || st == 0x09) return PICKUP_DOOR;
|
||||
if (st == 0x07) return PICKUP_ELEVATOR;
|
||||
if (st == 0x01) return PICKUP_OK; // ★ 一收到「正常」立即放行,不空等
|
||||
// 逾 ~1 秒仍無回應:補送同步包修序號+再查一次(比照舊專案 syncPackNo 自癒),最多 2 次
|
||||
long elapsed = System.currentTimeMillis() - startAt;
|
||||
if (retries < 2 && elapsed >= (retries + 1) * 1000L) {
|
||||
retries++;
|
||||
try {
|
||||
dev.addSendBuffer(dev.getDevXinYuan().syncPackNo((byte) 0x01));
|
||||
dev.addSendBuffer(dev.getDevXinYuan().checkSlot(slotnum));
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
try { Thread.sleep(150); } catch (InterruptedException e) { return PICKUP_NO_RESPONSE; }
|
||||
}
|
||||
// 主動查詢在 3.5 秒內沒等到「送出後」的新回覆:不要立刻判斷線。先退回最近的 VMC_02 廣播——
|
||||
// 只要近 7 秒內收過任何 VMC_02,就代表下位機仍在線(這次只是回覆稍慢),用該狀態判定,
|
||||
// 避免把「回覆慢」誤判成「主櫃尚未連線」(實測多數假性未連線的來源)。
|
||||
int cached = com.unibuy.smartdevice.controller.DevXinYuanController.getRecentSlotStatus(7000);
|
||||
if (cached == 0x05) return PICKUP_HAVE_THINGS;
|
||||
if (cached == 0x06 || cached == 0x09) return PICKUP_DOOR;
|
||||
if (cached == 0x07) return PICKUP_ELEVATOR;
|
||||
if (cached == 0x01) return PICKUP_OK;
|
||||
// 沒有 VMC_02,但機台近 4 秒仍有其他 VMC 通訊(POLL/ACK/金錢/狀態等) → 下位機其實在線,
|
||||
// 只是此狀態下沒回報取貨口狀態。視為「連線正常但取貨口狀態未知」,別誤判未連線、也別做無謂重連。
|
||||
if (com.unibuy.smartdevice.controller.DevXinYuanController.isRecentlyAlive(4000)) {
|
||||
return PICKUP_ALIVE_UNKNOWN;
|
||||
}
|
||||
// 連近 7 秒的廣播、近 4 秒的任何通訊都沒有 → 真的沒回應(交由呼叫端自動重連重試)。
|
||||
return PICKUP_NO_RESPONSE;
|
||||
}
|
||||
|
||||
private void postMain(Runnable r) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(r);
|
||||
}
|
||||
|
||||
private void dismissQuietly(android.app.AlertDialog d) {
|
||||
try { if (d != null && d.isShowing()) d.dismiss(); } catch (Exception ignore) {}
|
||||
}
|
||||
|
||||
/** 處理中:轉圈圈 + 提示文字(不可取消)。text 例如「取貨口檢查中......」。 */
|
||||
private android.app.AlertDialog buildProgressDialog(String text) {
|
||||
android.widget.LinearLayout layout = new android.widget.LinearLayout(context);
|
||||
layout.setOrientation(android.widget.LinearLayout.HORIZONTAL);
|
||||
layout.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||
int pad = dp(32);
|
||||
layout.setPadding(pad, pad, pad, pad);
|
||||
|
||||
android.widget.ProgressBar spinner = new android.widget.ProgressBar(context);
|
||||
android.widget.LinearLayout.LayoutParams sp =
|
||||
new android.widget.LinearLayout.LayoutParams(dp(56), dp(56));
|
||||
sp.rightMargin = dp(24);
|
||||
layout.addView(spinner, sp);
|
||||
|
||||
android.widget.TextView tv = new android.widget.TextView(context);
|
||||
tv.setText(text);
|
||||
tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 28);
|
||||
tv.setTextColor(android.graphics.Color.BLACK);
|
||||
layout.addView(tv);
|
||||
|
||||
return new android.app.AlertDialog.Builder(context)
|
||||
.setView(layout)
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
}
|
||||
|
||||
/** 取貨口異常:跳大字提示框,擋住不開詳情頁。 */
|
||||
private void showPickupBlockedDialog(String message) {
|
||||
android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(context)
|
||||
.setTitle(R.string.abnormal_reminder)
|
||||
.setMessage(message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.i_knew, (d, w) -> d.dismiss())
|
||||
.create();
|
||||
try { dialog.show(); } catch (Exception ignore) { return; }
|
||||
android.widget.TextView msg = dialog.findViewById(android.R.id.message);
|
||||
if (msg != null) msg.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 28);
|
||||
android.widget.Button btn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE);
|
||||
if (btn != null) btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 24);
|
||||
}
|
||||
|
||||
/** basic 使用 Luzui 移植的現金強化版(卡鈔/找零保障/自癒),上報接 TaiwanStar 新 MQTT。 */
|
||||
@Override
|
||||
public Class<? extends com.unibuy.smartdevice.DialogAbstract> cashDialogClass() {
|
||||
return com.unibuy.smartdevice.ui.dialog.CashPayDialogGeneral.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* 語言切換時:清掉快取的購物車 fragment 並從 FragmentManager 移除舊的(它已掛在被取代的舊容器上)。
|
||||
* 否則購買後再開購物車會拿到孤兒 fragment(openFragment 因 isAdded()==true 跳過重加)→ 畫面空白。
|
||||
* 下次 onShoppingCartRequested 會建立新的並掛到新容器。
|
||||
*/
|
||||
@Override
|
||||
public void onLocaleSwitched() {
|
||||
if (cartFragment != null) {
|
||||
try {
|
||||
if (context instanceof androidx.fragment.app.FragmentActivity && cartFragment.isAdded()) {
|
||||
((androidx.fragment.app.FragmentActivity) context).getSupportFragmentManager()
|
||||
.beginTransaction().remove(cartFragment).commitNowAllowingStateLoss();
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
cartFragment = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 購物車浮鈕:開啟多品項購物車 Fragment。 */
|
||||
@Override
|
||||
public void onShoppingCartRequested() {
|
||||
if (!(context instanceof FontendActivity)) {
|
||||
return;
|
||||
}
|
||||
if (cartFragment == null) {
|
||||
cartFragment = new ShoppingCartFragment(handlerMain);
|
||||
}
|
||||
((FontendActivity) context).openFragment(cartFragment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPickupRequested() {
|
||||
new FontendPickupCodeDialog(context, handlerMain).show();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.ui.dialog.MemberCardPickupDialog;
|
||||
|
||||
/**
|
||||
* employee_card 模式(原 Chengwai 晟崴版)銷售流程。
|
||||
* 點選商品/購物車時直接進入員工卡刷卡取貨 Dialog (MemberCardPickupDialog, B680)。
|
||||
* 商品點擊不消化、現金走標準 Dialog、無取貨碼入口 —— 皆沿用基底預設。
|
||||
*/
|
||||
public class EmployeeCardSaleFlow extends SaleFlowHandler {
|
||||
|
||||
public EmployeeCardSaleFlow(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
super(context, handlerMain, callback);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBuyRequested() {
|
||||
new MemberCardPickupDialog(context, handlerMain).show();
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,7 @@ import com.unibuy.smartdevice.MyApp;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.controller.DevController;
|
||||
@ -92,8 +93,13 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
SET_NETWORK(6),
|
||||
RESET_SLOTNO(49),
|
||||
RESTART_HOST(7),
|
||||
HIDDEN_MULTILANGUAGE_BUTTON(8),
|
||||
SHOW_MULTILANGUAGE_BUTTON(9),
|
||||
// 對話框開啟時隱藏銷售平台浮層(取貨碼/購物車浮鈕、上下系統資訊列),關閉時還原。
|
||||
// 原為僅隱藏語言鈕(HIDDEN/SHOW_MULTILANGUAGE_BUTTON),擴大為整個浮層,避免對話框
|
||||
// (貼底部、非滿版)周圍把這些邊緣元件漏出來。沿用 8/9 代碼不影響既有呼叫。
|
||||
HIDE_SALE_OVERLAY(8),
|
||||
SHOW_SALE_OVERLAY(9),
|
||||
// 純時鐘刷新:每秒只更新機台資訊列時間文字,不做上報/截圖/重啟判斷等重活。
|
||||
CLOCK_TICK(50),
|
||||
;
|
||||
|
||||
private int option;
|
||||
@ -120,6 +126,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
private Fragment vmcFragment;
|
||||
private Fragment electricFragment;
|
||||
private ReportShotTimeByScheduler reportOnScheduler;
|
||||
private ClockTickScheduler clockTickScheduler;
|
||||
private ConnectivityManager connectivityManager;
|
||||
private ConnectivityManager.NetworkCallback networkCallback;
|
||||
|
||||
@ -259,6 +266,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
|
||||
String timeByNow = sdf.format(new Date());
|
||||
binding.textTime.setText(timeByNow);
|
||||
// 機台資訊列的時間改由 ClockTickScheduler 每秒更新,這裡不再重複設定。
|
||||
|
||||
String timeByM = new SimpleDateFormat("HH:mm").format(new Date());
|
||||
if (timeByM.equals("23:45")) {
|
||||
@ -330,6 +338,11 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
binding.textTemperature.setText(getString(R.string.temp) + ":" + tempStr);
|
||||
getLogs().update("Temperature", String.valueOf(DevXinYuanController.getTemperature()), "℃");
|
||||
|
||||
// 同步更新機台資訊列
|
||||
updateMachineInfoName();
|
||||
updateMachineInfoNetwork();
|
||||
binding.textMachineInfoTemperature.setText(getString(R.string.temp) + ":" + tempStr);
|
||||
|
||||
try {
|
||||
// httpAPI.detectHost();
|
||||
} catch (Exception e) {
|
||||
@ -383,6 +396,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
case SET_NETWORK:
|
||||
getLogs().info("網路:" + message);
|
||||
binding.textNetwork.setText(message);
|
||||
updateMachineInfoNetwork();
|
||||
break;
|
||||
case RESET_SLOTNO:
|
||||
new ResetSlotNo(this).start();
|
||||
@ -390,13 +404,21 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
case RESTART_HOST:
|
||||
new RestartHost(this).start();
|
||||
break;
|
||||
case HIDDEN_MULTILANGUAGE_BUTTON:
|
||||
getLogs().info("隱藏多國語按紐");
|
||||
// hiddenMultiLanguageButton();
|
||||
case HIDE_SALE_OVERLAY:
|
||||
getLogs().info("對話框開啟,隱藏銷售平台浮層");
|
||||
refreshSaleOverlay();
|
||||
break;
|
||||
case SHOW_MULTILANGUAGE_BUTTON:
|
||||
getLogs().info("顯示多國語按紐");
|
||||
// showMultiLanguageButton();
|
||||
case SHOW_SALE_OVERLAY:
|
||||
getLogs().info("對話框關閉,重新評估銷售平台浮層");
|
||||
refreshSaleOverlay();
|
||||
break;
|
||||
case CLOCK_TICK:
|
||||
// 輕量時鐘:每秒只更新時間文字,不做其他重活。
|
||||
if (binding != null) {
|
||||
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
.format(new Date());
|
||||
binding.textMachineInfoTime.setText(now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -421,12 +443,13 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
settingsDao.close();
|
||||
|
||||
// 讀取最後設定語言的設定
|
||||
// LanguageHelper.applySavedLanguage(this);
|
||||
LanguageHelper.applySavedLanguage(this);
|
||||
binding = ActivityFontendBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
applyUiSkin();
|
||||
|
||||
// 初始化銷售流程處理器
|
||||
saleFlowHandler = new SaleFlowHandler(this, getHandlerMain(), this);
|
||||
saleFlowHandler = SaleFlowHandler.create(this, getHandlerMain(), this);
|
||||
saleFlowHandler.onSetupUI();
|
||||
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
@ -449,8 +472,13 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
*/
|
||||
@Override
|
||||
public void onUserInteraction() {
|
||||
super.onUserInteraction();
|
||||
MyApp.getInstance().updateLastInteraction();
|
||||
// 🚩 刻意「不」在此更新閒置時間/重設待機計時器。
|
||||
// Android 的 Activity.dispatchKeyEvent() 內部會呼叫 onUserInteraction(),因此 HID 掃碼槍/
|
||||
// 讀卡機的週期性按鍵也會跑到這裡。先前這會把 5 分鐘待機看門狗無限往後頂,造成:
|
||||
// (1) 商品視窗被誤觸開啟後永遠不會自動關閉;
|
||||
// (2) RestartAPP2「閒置>=90秒」與遠端重啟的條件永遠達不到。
|
||||
// 因此閒置一律改以「實體觸控」為準(見本類別 dispatchTouchEvent 與 DialogAbstract.dispatchTouchEvent)。
|
||||
// 故意不呼叫 super.onUserInteraction(),以阻斷基底類別 AppCompatActivityAbstract 用按鍵更新 lastInteractionTime。
|
||||
}
|
||||
|
||||
public void ReSetAPP() {
|
||||
@ -534,6 +562,17 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
}
|
||||
|
||||
idleRunnable = () -> {
|
||||
// 🚩 以「全域最後互動時間」為準再確認一次是否真的閒置滿 IDLE_TIMEOUT。
|
||||
// 對話框(付款/商品詳情/結帳等)有自己的 Window,操作它時不會觸發本 Activity 的
|
||||
// dispatchTouchEvent,因此 5 分鐘計時器不會被重設;但 onUserInteraction 連對話框觸控
|
||||
// 都會更新 lastInteractionTime。若使用者其實在對話框內操作(尚未真正閒置),這裡就以
|
||||
// 剩餘時間重新計時,避免「明明在操作卻被踢回待機」。
|
||||
long idleMs = MyApp.getInstance().getIdleSeconds() * 1000L;
|
||||
if (idleMs < IDLE_TIMEOUT) {
|
||||
idleHandler.postDelayed(idleRunnable, IDLE_TIMEOUT - idleMs);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🚩 若目前處於主控台/管理員模式,則忽略閒置返回待機頁的觸發,並重新計時
|
||||
if (!MyApp.getInstance().getSuAccess().isEmpty()) {
|
||||
getLogs().info("ℹ️ 偵測到當前處於開發者/主控台模式,忽略閒置返回待機頁的觸發。");
|
||||
@ -548,8 +587,10 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
return;
|
||||
}
|
||||
|
||||
getLogs().info("⚠️ 無操作 3 分鐘,自動返回初始畫面(清空購物車)");
|
||||
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());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
@ -561,6 +602,7 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent ev) {
|
||||
MyApp.getInstance().updateLastInteraction(); // 🚩 只有實體觸控才算「真人互動」,HID 按鍵不算
|
||||
resetIdleTimer(); // 每次觸控都重設 idle 計時器
|
||||
return super.dispatchTouchEvent(ev);
|
||||
}
|
||||
@ -631,6 +673,80 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
return -1; // 無法獲取訊號強度
|
||||
}
|
||||
|
||||
/** 取得 App 版本名稱(versionName),取不到時回 "0"。 */
|
||||
private String getAppVersionName() {
|
||||
try {
|
||||
return getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return "0";
|
||||
}
|
||||
}
|
||||
|
||||
/** 機台資訊列左側:顯示「序號 + 版本號」。 */
|
||||
private void updateMachineInfoName() {
|
||||
if (binding == null) {
|
||||
return;
|
||||
}
|
||||
binding.textMachineInfoName.setText(
|
||||
MyApp.getInstance().getMachine().getMachineID() + " Ver " + getAppVersionName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 依系統/下位機回報的網路狀態,動態更新機台資訊列的網路訊號 icon 與文字。
|
||||
* WiFi 依 RSSI(dBm) 分 1~3 格與滿格;行動網路(SIM) 依 dBm 分 0~4 格;無網路顯示 0 格。
|
||||
*/
|
||||
private void updateMachineInfoNetwork() {
|
||||
if (binding == null) {
|
||||
return;
|
||||
}
|
||||
int iconRes = R.drawable.signal_cellular_0_bar_24; // 預設:無網路
|
||||
String label = getString(R.string.no_internet);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
NetworkCapabilities capabilities = (network != null)
|
||||
? connectivityManager.getNetworkCapabilities(network) : null;
|
||||
if (capabilities != null
|
||||
&& capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
&& capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
int rssi = 0; // 0 代表系統未提供強度
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
rssi = capabilities.getSignalStrength();
|
||||
}
|
||||
iconRes = wifiIconByRssi(rssi);
|
||||
label = getString(R.string.wireless_internet);
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
iconRes = cellularIconByDbm(getMobileSignalStrength());
|
||||
label = getString(R.string.mobile_network);
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
iconRes = R.drawable.network_wifi_24; // 有線:以滿格示意連線正常
|
||||
label = getString(R.string.wired_internet);
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.textMachineInfoNetwork.setText(label);
|
||||
binding.textMachineInfoNetwork.setCompoundDrawablesRelativeWithIntrinsicBounds(iconRes, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** WiFi RSSI(dBm) → icon;數字越接近 0 越強,rssi==0 視為未提供強度回滿格。 */
|
||||
private int wifiIconByRssi(int rssi) {
|
||||
if (rssi == 0 || rssi >= -55) return R.drawable.network_wifi_24; // 滿格
|
||||
if (rssi >= -66) return R.drawable.network_wifi_3_bar_24; // 等級3
|
||||
if (rssi >= -77) return R.drawable.network_wifi_2_bar_24; // 等級2
|
||||
return R.drawable.network_wifi_1_bar_24; // 等級1
|
||||
}
|
||||
|
||||
/** 行動網路 dBm → icon;數字越接近 0 越強,-1 代表取不到訊號。 */
|
||||
private int cellularIconByDbm(int dbm) {
|
||||
if (dbm == -1) return R.drawable.signal_cellular_0_bar_24; // 無網路
|
||||
if (dbm >= -85) return R.drawable.signal_cellular_4_bar_24; // 滿格
|
||||
if (dbm >= -95) return R.drawable.signal_cellular_3_bar_24; // 等級3
|
||||
if (dbm >= -105) return R.drawable.signal_cellular_2_bar_24; // 等級2
|
||||
if (dbm >= -115) return R.drawable.signal_cellular_1_bar_24; // 等級1
|
||||
return R.drawable.signal_cellular_0_bar_24; // 無網路
|
||||
}
|
||||
|
||||
private void setBottomNavigation() {
|
||||
vmcFragment = new VmcFragment(getHandlerMain());
|
||||
activeFragment = vmcFragment;
|
||||
@ -713,11 +829,21 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
}
|
||||
});
|
||||
|
||||
binding.floatButtonPickupCode.setVisibility(View.GONE);
|
||||
// 前台取貨碼浮鈕:取貨碼從屬「取貨模組」(B014 FunctionSet.PickupModule)。
|
||||
// 取貨模組關閉時,即使 PickupCode=true 也一律隱藏(畫面與流程都不出現)。
|
||||
binding.floatButtonPickupCode.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().isPickupModule()
|
||||
&& MyApp.getInstance().getDevSet().isPickupCode()) ? VISIBLE : View.GONE);
|
||||
|
||||
binding.floatButtonShoppingCart.setOnClickListener(view -> {
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.BUY_DIALOG_OPEN.getOption(),
|
||||
getString(R.string.detect_shopping_cart));
|
||||
// 改由 flavor 的 SaleFlowHandler 決定:General 開多品項購物車 Fragment,
|
||||
// 其他 flavor 維持原行為(onShoppingCartRequested 預設轉呼叫 onBuyRequested)。
|
||||
if (saleFlowHandler != null) {
|
||||
saleFlowHandler.onShoppingCartRequested();
|
||||
} else {
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.BUY_DIALOG_OPEN.getOption(),
|
||||
getString(R.string.detect_shopping_cart));
|
||||
}
|
||||
});
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
@ -726,13 +852,36 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
binding.floatButtonShoppingCart.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// 觀察購物車內容以更新數量 Badge
|
||||
com.unibuy.v2.UnibuyHelper.INSTANCE.getCartViewModel().getCartItems().observe(this, cartItems -> {
|
||||
int totalCount = 0;
|
||||
if (cartItems != null) {
|
||||
for (com.unibuy.smartdevice.structure.SlotStructure item : cartItems) {
|
||||
totalCount += item.cartSelectedCount;
|
||||
}
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
binding.textCartBadge.setText(String.valueOf(totalCount));
|
||||
binding.textCartBadge.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.textCartBadge.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
|
||||
startHeaderReportScheduler();
|
||||
|
||||
// binding.btnswitchlanTW.setOnClickListener(v -> switchLanguage("tw"));
|
||||
//
|
||||
// binding.btnswitchlanEn.setOnClickListener(v -> switchLanguage("en"));
|
||||
//
|
||||
// binding.btnswitchlanJa.setOnClickListener(v -> switchLanguage("ja"));
|
||||
// 語言切換:改為單一入口開啟語言選單(支援 8 語,無法並排)。
|
||||
// 沿用 btnswitchlanTW 當觸發鈕(XML 已改顯示 🌐),隱藏另兩顆舊鈕。
|
||||
binding.btnswitchlanEn.setVisibility(View.GONE);
|
||||
binding.btnswitchlanJa.setVisibility(View.GONE);
|
||||
binding.btnswitchlanTW.setOnClickListener(v -> showLanguagePicker());
|
||||
// 新增:機台資訊列的語言按鈕
|
||||
binding.textMachineInfoLang.setOnClickListener(v -> showLanguagePicker());
|
||||
// 機台資訊列:顯示序號 + 版本號
|
||||
updateMachineInfoName();
|
||||
updateMachineInfoNetwork();
|
||||
String currentLang = LanguageHelper.getSavedLanguage(this);
|
||||
binding.textMachineInfoLangText.setText(LanguageHelper.getDisplayName(currentLang));
|
||||
|
||||
// 🛠️ 隱藏後門:長按「網路狀態」文字 2 秒進入主控台
|
||||
final Handler networkHandler = new Handler();
|
||||
@ -742,7 +891,8 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
finish();
|
||||
};
|
||||
|
||||
binding.textNetwork.setOnTouchListener((v, event) -> {
|
||||
// 後門已遷至機台資訊列的「序號 + 版本號」元素(舊的 textNetwork 整條系統列已隱藏,不再接收觸控)
|
||||
binding.textMachineInfoName.setOnTouchListener((v, event) -> {
|
||||
switch (event.getAction()) {
|
||||
case android.view.MotionEvent.ACTION_DOWN:
|
||||
networkHandler.postDelayed(networkRunnable, 2000); // 2秒
|
||||
@ -763,15 +913,65 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
|
||||
}
|
||||
|
||||
// 顯示語言選單:8 種語言以各自文字呈現,選擇後即套用並重繪畫面。
|
||||
private void showLanguagePicker() {
|
||||
// 語系清單以後台 B014 LangSet 下發的機台啟用語系為準;未取得時退回全部支援語系
|
||||
final String[] codes = LanguageHelper.getMachineLanguages();
|
||||
String[] names = new String[codes.length];
|
||||
int current = 0;
|
||||
String saved = LanguageHelper.getSavedLanguage(this);
|
||||
for (int i = 0; i < codes.length; i++) {
|
||||
names[i] = LanguageHelper.getDisplayName(codes[i]);
|
||||
if (codes[i].equals(saved)) {
|
||||
current = i;
|
||||
}
|
||||
}
|
||||
new androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_language)
|
||||
.setSingleChoiceItems(names, current, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
switchLanguage(codes[which]);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 套用後台「UI元素設定」的換膚:整頁背景(BG0) / 機台資訊列底色(C0) / 機台資訊列文字色(T0)。
|
||||
* onCreate 與切換語言重建 binding 後都要呼叫。未設定的部位會保留 XML 預設。
|
||||
*/
|
||||
private void applyUiSkin() {
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.applyBackground("BG0", binding.container);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.applyBgColor("C0", binding.machineInfoBar);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.applyTextColor("T0", binding.textMachineInfoName);
|
||||
// 浮動鈕 icon(套後台圖時 UiSkin.apply 會一併清掉 XML 的 app:tint,避免整片被染色)
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("B1", binding.imgFloatPickupCode); // 取貨碼
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("B4", binding.imgFloatShoppingCart); // 購物車
|
||||
}
|
||||
|
||||
private void switchLanguage(String languageCode) {
|
||||
LanguageHelper.changeLanguage(this, languageCode);
|
||||
binding = ActivityFontendBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
applyUiSkin();
|
||||
|
||||
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
|
||||
setBottomNavigation();
|
||||
startOnSwitchFragment();
|
||||
// 版面/容器已重建:清掉 saleFlowHandler 內快取的購物車 fragment(否則購買後再開會空白)
|
||||
if (saleFlowHandler != null) {
|
||||
saleFlowHandler.onLocaleSwitched();
|
||||
}
|
||||
// 重新綁定語言選單入口(重繪後 binding 已換新)
|
||||
binding.btnswitchlanEn.setVisibility(View.GONE);
|
||||
binding.btnswitchlanJa.setVisibility(View.GONE);
|
||||
binding.btnswitchlanTW.setOnClickListener(v -> showLanguagePicker());
|
||||
binding.textMachineInfoLang.setOnClickListener(v -> showLanguagePicker());
|
||||
updateMachineInfoName();
|
||||
updateMachineInfoNetwork();
|
||||
String currentLang = LanguageHelper.getSavedLanguage(this);
|
||||
binding.textMachineInfoLangText.setText(LanguageHelper.getDisplayName(currentLang));
|
||||
}
|
||||
|
||||
private void startHeaderReportScheduler() {
|
||||
@ -779,6 +979,10 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
reportOnScheduler = new ReportShotTimeByScheduler(getHandlerMain());
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.REPORT_AND_SHOT_TIME.getOption(), "start");
|
||||
reportOnScheduler.start(1L, 10L, TimeUnit.SECONDS);
|
||||
|
||||
// 時鐘獨立每秒刷新,與上面的 10 秒重活迴圈分開,避免把上報/重啟判斷拉到每秒。
|
||||
clockTickScheduler = new ClockTickScheduler(getHandlerMain());
|
||||
clockTickScheduler.start(0L, 1L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void stopHeaderReportScheduler() {
|
||||
@ -786,18 +990,38 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
reportOnScheduler.shutdown();
|
||||
reportOnScheduler = null;
|
||||
}
|
||||
if (clockTickScheduler != null) {
|
||||
clockTickScheduler.shutdown();
|
||||
clockTickScheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void hiddenMultiLanguageButton() {
|
||||
binding.btnswitchlanTW.setVisibility(View.GONE);
|
||||
binding.btnswitchlanEn.setVisibility(View.GONE);
|
||||
binding.btnswitchlanJa.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void showMultiLanguageButton() {
|
||||
binding.btnswitchlanTW.setVisibility(VISIBLE);
|
||||
binding.btnswitchlanEn.setVisibility(VISIBLE);
|
||||
binding.btnswitchlanJa.setVisibility(VISIBLE);
|
||||
/**
|
||||
* 統一控制銷售平台「浮層」在對話框期間的顯示:取貨碼/購物車浮鈕、上方系統列(時間/溫度/網路)、
|
||||
* 下方系統列(版號/語言/執行緒)。對話框(貼底部、非滿版)只蓋住中間,這些邊緣元件若不隱藏會漏出來。
|
||||
* visible=false → 全部隱藏(對話框開啟時)。
|
||||
* visible=true → 還原;浮鈕依各自 B014 gating 重新判斷(不可無腦設 VISIBLE,否則會把已停用的
|
||||
* 取貨碼/購物車漏出來),系統列則直接還原。
|
||||
*/
|
||||
private void applySaleOverlayVisibility(boolean visible) {
|
||||
if (binding == null) return;
|
||||
if (visible) {
|
||||
// 取貨碼浮鈕:從屬取貨模組,PickupModule && PickupCode 才顯示。
|
||||
binding.floatButtonPickupCode.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().isPickupModule()
|
||||
&& MyApp.getInstance().getDevSet().isPickupCode()) ? VISIBLE : View.GONE);
|
||||
// 購物車浮鈕:依購物車開關。
|
||||
binding.floatButtonShoppingCart.setVisibility(
|
||||
MyApp.getInstance().getDevSet().isShoppingCar() ? VISIBLE : View.GONE);
|
||||
// 上/下灰色系統列已停用,銷售時不再顯示(機台資訊改由 machine_info_bar 呈現)。
|
||||
binding.linearLayout5.setVisibility(View.GONE);
|
||||
binding.bottomBar.setVisibility(View.GONE);
|
||||
} else {
|
||||
binding.floatButtonPickupCode.setVisibility(View.GONE);
|
||||
binding.floatButtonShoppingCart.setVisibility(View.GONE);
|
||||
binding.linearLayout5.setVisibility(View.GONE);
|
||||
binding.bottomBar.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private class RestartHost extends HandlerMainScheduler {
|
||||
@ -1087,6 +1311,28 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
}
|
||||
}
|
||||
|
||||
/** 每秒只刷新機台資訊列時間文字的輕量排程,與 10 秒上報迴圈分離。 */
|
||||
public class ClockTickScheduler extends HandlerMainScheduler {
|
||||
public ClockTickScheduler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), Option.CLOCK_TICK.getOption(), "tick");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
|
||||
public class AddProductOnCountdown extends HandlerMainCountdown {
|
||||
public AddProductOnCountdown(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
@ -1117,24 +1363,74 @@ public class FontendActivity extends AppCompatActivityAbstract implements SaleFl
|
||||
|
||||
private void switchFragment(Fragment from, Fragment to) {
|
||||
if (from != to) {
|
||||
// 防呆:若 Activity 已 onSaveInstanceState(非前景),commit 會丟
|
||||
// IllegalStateException。購物車 180 秒倒數 onFinish 在背景觸發返回時即屬此情況。
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
|
||||
getLogs().info("switchFragment skipped: activity not started");
|
||||
return;
|
||||
}
|
||||
|
||||
if (from instanceof FragmentAbstract) {
|
||||
getLogs().info("change pay stop");
|
||||
((FragmentAbstract) from).stopOnSwitchFragment();
|
||||
}
|
||||
|
||||
// 此切換僅為 Fragment 顯示切換(資料在 ViewModel),允許 state loss 不致實害。
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.hide(from)
|
||||
.show(to)
|
||||
.commit();
|
||||
.commitAllowingStateLoss();
|
||||
activeFragment = to;
|
||||
|
||||
if (to instanceof FragmentAbstract) {
|
||||
getLogs().info("change pay start");
|
||||
((FragmentAbstract) to).startOnSwitchFragment();
|
||||
}
|
||||
|
||||
// 浮層連動:購物車(General 多品項)是開在 fragment_container 的 Fragment,不走 DialogAbstract,
|
||||
// 故不會送 HIDE_SALE_OVERLAY。在此依目標 fragment 補上連動(activeFragment 已於上方更新)。
|
||||
refreshSaleOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新評估銷售平台浮層(取貨碼/購物車浮鈕、上下系統列)是否該顯示。
|
||||
* 唯一真相:僅當「處於商品瀏覽頁(VMC/Electric)」且「沒有任何對話框開著」時才顯示,否則隱藏。
|
||||
* 涵蓋兩條會遮住銷售平台的路徑:對話框(DialogAbstract,貼底部非滿版)與購物車 Fragment。
|
||||
*/
|
||||
private void refreshSaleOverlay() {
|
||||
boolean inProductBrowsing = (activeFragment == vmcFragment || activeFragment == electricFragment);
|
||||
boolean anyDialog = MyApp.getInstance().isShowDialog();
|
||||
applySaleOverlayVisibility(inProductBrowsing && !anyDialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將指定 Fragment 加入容器(若尚未加入)並切換顯示。
|
||||
* 供 flavor 端注入專屬 Fragment(如 General 多品項購物車)使用,本身不依賴任何 flavor 類別。
|
||||
*/
|
||||
public void openFragment(Fragment to) {
|
||||
if (to == null) {
|
||||
return;
|
||||
}
|
||||
// 同 switchFragment:背景狀態下 commitNow 亦會丟 IllegalStateException。
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
|
||||
getLogs().info("openFragment skipped: activity not started");
|
||||
return;
|
||||
}
|
||||
if (!to.isAdded()) {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.add(binding.fragmentContainer.getId(), to)
|
||||
.hide(to)
|
||||
.commitNow();
|
||||
}
|
||||
switchFragment(activeFragment, to);
|
||||
}
|
||||
|
||||
/** 切回 VMC 商品列表(供購物車「加購/取消/逾時」返回使用)。 */
|
||||
public void backToVmc() {
|
||||
switchFragment(activeFragment, vmcFragment);
|
||||
}
|
||||
|
||||
public void callbackOnBackPressed() {
|
||||
FragmentManager fragmentManager = getSupportFragmentManager();
|
||||
int backStackCount = fragmentManager.getBackStackEntryCount();
|
||||
@ -1163,11 +1459,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; // 消耗事件
|
||||
}
|
||||
@ -1248,23 +1549,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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,9 @@ public class MaintenanceActivity extends AppCompatActivityAbstract {
|
||||
|
||||
setContentView(R.layout.activity_maintenance);
|
||||
|
||||
// A3(異常鎖定/遠端鎖定頁):套用後台「UI元素設定」下發的滿版圖,未設定則保留預設 a3_errorlock_aat_01
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("A3", (android.widget.ImageView) findViewById(R.id.lockImage));
|
||||
|
||||
View backdoorView = findViewById(R.id.viewBackdoor);
|
||||
backdoorView.setOnTouchListener((v, event) -> {
|
||||
switch (event.getAction()) {
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.unibuy.smartdevice.AppCompatActivityAbstract;
|
||||
import com.unibuy.smartdevice.AppEntry;
|
||||
import com.unibuy.smartdevice.devices.MaintenanceMode;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
|
||||
/**
|
||||
* 維修模式畫面(下位機串口已釋放)。移植自 StarApp-LockBoard。
|
||||
*
|
||||
* 進入此畫面時,本 app 已停止佔用下位機串口(主櫃 XinYuan),
|
||||
* 現場人員可開啟原廠「下位機軟體」(興元)正常讀取/設定下位機。
|
||||
* 按「退出維修模式並恢復連線」→ 清除旗標並重啟 app,重新接管下位機。
|
||||
*/
|
||||
public class MaintenanceSerialActivity extends AppCompatActivityAbstract {
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getCtx(), getLogs());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_FULLSCREEN);
|
||||
|
||||
setContentView(buildLayout());
|
||||
}
|
||||
|
||||
private View buildLayout() {
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setGravity(Gravity.CENTER);
|
||||
root.setBackgroundColor(Color.parseColor("#005DAD"));
|
||||
int pad = dp(40);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("維修模式");
|
||||
title.setTextColor(Color.WHITE);
|
||||
title.setTextSize(40);
|
||||
title.setGravity(Gravity.CENTER);
|
||||
root.addView(title);
|
||||
|
||||
TextView desc = new TextView(this);
|
||||
desc.setText("下位機串口已釋放\n\n按「進到下位機設置」開啟原廠下位機軟體進行設置\n設置完成回到本畫面,按「退出設置模式並恢復連線」");
|
||||
desc.setTextColor(Color.WHITE);
|
||||
desc.setTextSize(20);
|
||||
desc.setGravity(Gravity.CENTER);
|
||||
desc.setLineSpacing(dp(6), 1f);
|
||||
LinearLayout.LayoutParams descLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
descLp.topMargin = dp(24);
|
||||
root.addView(desc, descLp);
|
||||
|
||||
// 進到下位機維護:串口已釋放,直接開啟原廠下位機(興元)軟體。
|
||||
Button toSlave = new Button(this);
|
||||
toSlave.setText("進到下位機設置");
|
||||
toSlave.setTextSize(22);
|
||||
toSlave.setAllCaps(false);
|
||||
toSlave.setTextColor(Color.parseColor("#005DAD"));
|
||||
toSlave.setBackgroundColor(Color.WHITE);
|
||||
LinearLayout.LayoutParams toSlaveLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
toSlaveLp.topMargin = dp(48);
|
||||
toSlave.setPadding(dp(32), dp(20), dp(32), dp(20));
|
||||
toSlave.setOnClickListener(v -> launchSlaveMachineApp());
|
||||
root.addView(toSlave, toSlaveLp);
|
||||
|
||||
Button exit = new Button(this);
|
||||
exit.setText("退出設置模式並恢復連線");
|
||||
exit.setTextSize(22);
|
||||
exit.setAllCaps(false);
|
||||
exit.setTextColor(Color.WHITE);
|
||||
exit.setBackgroundColor(Color.parseColor("#555555")); // 深灰,與白底「進到下位機維護」區別
|
||||
LinearLayout.LayoutParams exitLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
exitLp.topMargin = dp(48);
|
||||
exit.setPadding(dp(32), dp(20), dp(32), dp(20));
|
||||
exit.setOnClickListener(v -> exitMaintenance());
|
||||
root.addView(exit, exitLp);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 進到下位機維護:開啟原廠下位機(興元 com.xyshj.machine)軟體。
|
||||
* 維修模式下串口已釋放,故不需 killProcess;本畫面留在背景,維護完返回即回到維修畫面,
|
||||
* 再按「退出維修模式並恢復連線」即可。
|
||||
*/
|
||||
private void launchSlaveMachineApp() {
|
||||
try {
|
||||
getPackageManager().getPackageInfo("com.xyshj.machine", 0);
|
||||
Intent it = new Intent("android.intent.action.xinyuan.setting");
|
||||
it.putExtra("showLoginDialog", true);
|
||||
startActivity(it);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
Toast.makeText(this, "請先安裝興元管理程式", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void exitMaintenance() {
|
||||
getLogs().info("[維修模式] 退出維修模式,恢復下位機連線並重啟 app");
|
||||
// 清除持久化旗標 → 下次 MyApp.onCreate 會正常開啟下位機串口
|
||||
MaintenanceMode.setSerialReleased(getApplicationContext(), false);
|
||||
|
||||
// 重啟 app:清空 Task 後啟動首頁,再結束本行程;重新啟動時即重新接管下位機串口。
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
Intent intent = new Intent(getApplicationContext(), AppEntry.initialActivity());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
startActivity(intent);
|
||||
finishAffinity();
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// 維修畫面封鎖返回鍵,避免誤觸離開
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.devices.DeviceType;
|
||||
import com.unibuy.smartdevice.devices.UsbDev;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.exception.LogsIOException;
|
||||
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
|
||||
import com.unibuy.smartdevice.external.StarCloudAPI;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportFlowInfoStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainSchedulerOnClick;
|
||||
import com.unibuy.smartdevice.ui.dialog.TakeMedicineDialog;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 領藥單(雲端建單)銷售流程。
|
||||
*
|
||||
* 啟用條件:機台 shopping_mode=pickup_sheet 且 FunctionSet.PharmacyPickup=true(由 {@link SaleFlowHandler#create} 分流)。
|
||||
*
|
||||
* 與中國醫 {@link PickupSheetSaleFlow}(本地 AES 解密醫令)不同,本流程資料來源為雲端:
|
||||
* 1. 病人掃描後台列印的領藥單 QR(內容=8 碼領藥碼)
|
||||
* 2. 呼叫 B660 {@link StarCloudAPI#verifyPharmacyPickupCode},雲端回傳該單「多貨道出貨清單 items[]」與領藥單號 order_no
|
||||
* 3. 依 items[] 組 BuyList(一商品可跨多貨道,逐單位一筆),並把 finalize 的 flow_id 設為 order_no(RX…)
|
||||
* 4. 交由既有 {@link TakeMedicineDialog} → DispatchDialog 逐道出貨,出貨結果以 MQTT finalize(flow_id=order_no)回報雲端
|
||||
*/
|
||||
public class PharmacyPickupSaleFlow extends SaleFlowHandler {
|
||||
|
||||
/** 領藥單支付類型碼:沿用 42(與後端 PharmacyPickupService 一致)。 */
|
||||
private static final int PAYMENT_TYPE_PHARMACY = 42;
|
||||
|
||||
private boolean openBarcode = true;
|
||||
private long lastClickTime = 0;
|
||||
|
||||
public PharmacyPickupSaleFlow(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
super(context, handlerMain, callback);
|
||||
}
|
||||
|
||||
/** 綁定 VmcFragment 的掃碼按鈕(與 PickupSheetSaleFlow 相同)。 */
|
||||
@Override
|
||||
public void onSetupUI(View rootView) {
|
||||
if (context instanceof Activity) {
|
||||
View btnScan = rootView != null ? rootView.findViewById(R.id.scanbarcodebtn)
|
||||
: ((Activity) context).findViewById(R.id.scanbarcodebtn);
|
||||
if (btnScan != null) {
|
||||
btnScan.setOnClickListener(new BtnScanOnClick(handlerMain, context));
|
||||
MyApp.getInstance().getLogs().info("✅ 成功在 PharmacyPickupSaleFlow 綁定掃碼按鈕!");
|
||||
} else {
|
||||
MyApp.getInstance().getLogs().info("❌ 在 PharmacyPickupSaleFlow 找不到掃碼按鈕 R.id.scanbarcodebtn!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 外部(其他來源)傳入的領藥碼,直接驗證。 */
|
||||
@Override
|
||||
public void onBarcodeReceived(String rawData) {
|
||||
verifyAndBuild(rawData != null ? rawData.trim() : "");
|
||||
}
|
||||
|
||||
/** 點「取貨」按鈕:本流程同樣以掃碼領藥碼為主,提示刷碼。 */
|
||||
@Override
|
||||
public void onPickupRequested() {
|
||||
handlerMain.start(getClass().getSimpleName(), "@請刷領藥單 QR 碼");
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼叫 B660 驗證領藥碼;成功則組 BuyList、設定 finalize flow_id=領藥單號,再開出貨對話框。
|
||||
*/
|
||||
private void verifyAndBuild(final String code) {
|
||||
if (code == null || code.isEmpty()) {
|
||||
openBarcode = true;
|
||||
return;
|
||||
}
|
||||
new StarCloudAPI(handlerMain, handlerMain).verifyPharmacyPickupCode(code, new StarCloudAPI.PharmacyPickupCallback() {
|
||||
@Override
|
||||
public void onSuccess(String orderNo, String codeId, String pricingSlipNo, List<StarCloudAPI.PharmacyPickupItem> items) {
|
||||
try {
|
||||
// 領藥單模式下 isCmuh()=true,DispatchDialog/insertDrgnoToMemo 會讀 product.marketingPlan 的 SN/DRGNO,
|
||||
// 故給每個商品一份合法 JSON(SN=領藥單號,不含病人個資),避免 new JSONObject("") 崩潰。
|
||||
String memoJson = new org.json.JSONObject().put("SN", orderNo).put("DRGNO", "").toString();
|
||||
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
int added = 0;
|
||||
for (StarCloudAPI.PharmacyPickupItem item : items) {
|
||||
int slot = parseSlot(item.slotNo);
|
||||
if (slot <= 0) {
|
||||
MyApp.getInstance().getLogs().info("領藥單跳過無效貨道: " + item.slotNo);
|
||||
continue;
|
||||
}
|
||||
// 雲端 B660 回傳的 item 沒有商品圖片,改由本地已下載的商品清單以 product_id 取圖,
|
||||
// 否則領藥明細與出貨畫面只會顯示預設圖片。
|
||||
String productImg = findProductImg(String.valueOf(item.productId));
|
||||
ProductStructure product = new ProductStructure(
|
||||
String.valueOf(item.productId), productImg, item.productName,
|
||||
0, 0, 0, 0, memoJson, "", "", "");
|
||||
int qty = Math.max(item.qty, 0);
|
||||
for (int n = 0; n < qty; n++) {
|
||||
MyApp.getInstance().getBuyList().add(new BuyStructure(0, slot, product, 1));
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
if (added == 0) {
|
||||
showError("領藥單無可出貨項目,請洽藥局窗口");
|
||||
return;
|
||||
}
|
||||
|
||||
// TakeMedicineDialog 與 DispatchDialog(isCmuh) 會讀 MyApp.decryptedData 顯示/解析欄位;
|
||||
// 雲端流程沒有 AES 解密資料,這裡補一份合法 JSON(SN=領藥單號,不含病人個資)。
|
||||
org.json.JSONObject dec = new org.json.JSONObject();
|
||||
dec.put("HOSP_ID", "");
|
||||
dec.put("SN", orderNo);
|
||||
dec.put("NAME", "");
|
||||
dec.put("DRGNO", "");
|
||||
// 備註欄改顯示批價單號(藥師批價依據);雲端 B660 回傳的 pricing_slip_no。
|
||||
dec.put("DATA", pricingSlipNo != null ? pricingSlipNo : "");
|
||||
dec.put("PRINT_TIME", "");
|
||||
MyApp.getInstance().setDecryptedData(dec.toString());
|
||||
|
||||
// 關鍵:finalize 的 flow_id = 後端領藥單號(RX…),後端依 flow_id 對單;payment_type=42。
|
||||
ReportFlowInfoStructure info = MyApp.getInstance().getReportFlowInfoData();
|
||||
info.setMqttFlowId(orderNo);
|
||||
info.setReportFlowId(orderNo);
|
||||
info.setOrderId(orderNo);
|
||||
info.setFlowType(PAYMENT_TYPE_PHARMACY);
|
||||
|
||||
MyApp.getInstance().getLogs().info("領藥單驗證成功 order_no=" + orderNo + ", items=" + items.size() + ", buyList=" + added);
|
||||
|
||||
if (context instanceof Activity) {
|
||||
((Activity) context).runOnUiThread(() -> new TakeMedicineDialog(context, handlerMain).show());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
MyApp.getInstance().getLogs().warning(e);
|
||||
showError("領藥單處理失敗:" + (e.getMessage() != null ? e.getMessage() : ""));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String message) {
|
||||
showError(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showError(String message) {
|
||||
openBarcode = true;
|
||||
handlerMain.start(getClass().getSimpleName(), "@" + (message != null ? message : "領藥碼驗證失敗"));
|
||||
}
|
||||
|
||||
/** 以 product_id 從本地已下載的商品清單取商品圖片網址;找不到回傳空字串(顯示預設圖片)。 */
|
||||
private String findProductImg(String productId) {
|
||||
if (productId == null) return "";
|
||||
for (ProductStructure p : MyApp.getInstance().getProductList()) {
|
||||
if (productId.equals(p.getProductID())) {
|
||||
return p.getProductImg() != null ? p.getProductImg() : "";
|
||||
}
|
||||
}
|
||||
MyApp.getInstance().getLogs().info("領藥單找不到對應商品圖片 product_id=" + productId);
|
||||
return "";
|
||||
}
|
||||
|
||||
private int parseSlot(String slotNo) {
|
||||
if (slotNo == null) return -1;
|
||||
try {
|
||||
return Integer.parseInt(slotNo.trim());
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 掃碼按鈕監聽(40 秒內讀 USB 掃描器,讀到即送 B660 驗證) ──────────────
|
||||
private class BtnScanOnClick extends HandlerMainSchedulerOnClick {
|
||||
private final Context context;
|
||||
|
||||
BtnScanOnClick(HandlerMain srcHandlerMain, Context context) {
|
||||
super(srcHandlerMain);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return getSrcHandlerMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
handlerMain.start(getClass().getSimpleName(), "@40秒內刷領藥單 QR 碼");
|
||||
openBarcode = true;
|
||||
try {
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100); // 預讀清緩衝
|
||||
|
||||
for (int i = 0; i < 40; i++) {
|
||||
if (!isRun() || !openBarcode) break;
|
||||
int size = usbDev.read(buffer, 1000); // 超時 1 秒
|
||||
if (size > 5) {
|
||||
openBarcode = false; // 立即防呆,避免重複進入
|
||||
String barCodeString = new String(buffer, 0, size).trim();
|
||||
if (barCodeString.isEmpty()) {
|
||||
openBarcode = true;
|
||||
continue;
|
||||
}
|
||||
MyApp.getInstance().getLogs().info("領藥單掃碼成功: [" + barCodeString + "]");
|
||||
verifyAndBuild(barCodeString);
|
||||
break;
|
||||
}
|
||||
}
|
||||
usbDev.closePort();
|
||||
} catch (LogsUnsupportedOperationException | LogsIOException | LogsEmptyException e) {
|
||||
MyApp.getInstance().getLogs().warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (System.currentTimeMillis() - lastClickTime < 3000) {
|
||||
MyApp.getInstance().getLogs().info("⚠️ 點擊太快,觸發防呆");
|
||||
return;
|
||||
}
|
||||
lastClickTime = System.currentTimeMillis();
|
||||
setRun(true);
|
||||
start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(View v) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@ import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Cmuh (中國醫版) 銷售流程處理器。
|
||||
* pickup_sheet 模式(原 Cmuh 中國醫版)銷售流程。
|
||||
* 負責:
|
||||
* 1. 綁定「40秒內刷條碼」按鈕 (scanbarcodebtn)
|
||||
* 2. 連接 USB 掃描器背景讀取
|
||||
@ -44,49 +44,37 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
* 4. 庫存核對與自動加入購物車
|
||||
* 5. 顯示領藥與錯誤 Dialog
|
||||
*/
|
||||
public class SaleFlowHandler {
|
||||
public class PickupSheetSaleFlow extends SaleFlowHandler {
|
||||
private static final String ORDC_EMPTY = "ORDC_EMPTY";
|
||||
private static final String MATERIAL_NOT_FOUND = "MATERIAL_NOT_FOUND";
|
||||
private static final String STOCK_EMPTY = "STOCK_EMPTY";
|
||||
private static final String STOCK_NOT_ENOUGH = "STOCK_NOT_ENOUGH";
|
||||
|
||||
protected final Context context;
|
||||
protected final SaleFlowCallback callback;
|
||||
protected final HandlerMain handlerMain;
|
||||
|
||||
private boolean openBarcode = true;
|
||||
private boolean haveValuestate = false;
|
||||
private long lastClickTime = 0;
|
||||
|
||||
public SaleFlowHandler(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
this.context = context;
|
||||
this.handlerMain = handlerMain;
|
||||
this.callback = callback;
|
||||
public PickupSheetSaleFlow(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
super(context, handlerMain, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Activity 的 onResume() 中會被呼叫,此時尋找並綁定 VmcFragment 中的掃碼按鈕
|
||||
*/
|
||||
public void onSetupUI() {
|
||||
onSetupUI(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetupUI(View rootView) {
|
||||
if (context instanceof Activity) {
|
||||
View btnScan = rootView != null ? rootView.findViewById(R.id.scanbarcodebtn) : ((Activity) context).findViewById(R.id.scanbarcodebtn);
|
||||
if (btnScan != null) {
|
||||
btnScan.setOnClickListener(new BtnQrCodeOnClick(handlerMain, context));
|
||||
MyApp.getInstance().getLogs().info("✅ 成功在 Cmuh SaleFlowHandler 綁定掃碼按鈕!");
|
||||
MyApp.getInstance().getLogs().info("✅ 成功在 PickupSheetSaleFlow 綁定掃碼按鈕!");
|
||||
} else {
|
||||
MyApp.getInstance().getLogs().info("❌ 在 Cmuh SaleFlowHandler 找不到掃碼按鈕 R.id.scanbarcodebtn!");
|
||||
MyApp.getInstance().getLogs().info("❌ 在 PickupSheetSaleFlow 找不到掃碼按鈕 R.id.scanbarcodebtn!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void onBuyRequested() {
|
||||
// 中國醫版沒有購物車付款,此方法不執行
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBarcodeReceived(String rawData) {
|
||||
// 若是經由其他方式傳入的 Barcode 可在此處直接處理
|
||||
try {
|
||||
@ -99,8 +87,9 @@ public class SaleFlowHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* CMUH 點選取貨按鈕,開啟輸入取貨碼的 Dialog
|
||||
* 點選取貨按鈕,開啟輸入取貨碼的 Dialog(pickup_sheet 走舊 API 驗證,見 FontendPickupCodeDialog)。
|
||||
*/
|
||||
@Override
|
||||
public void onPickupRequested() {
|
||||
new FontendPickupCodeDialog(context, handlerMain).show();
|
||||
}
|
||||
@ -129,7 +118,7 @@ public class SaleFlowHandler {
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100); // 預讀清除緩衝
|
||||
|
||||
|
||||
for (int i = 0; i < 40; i++) { // 總計約 40 秒
|
||||
if (isRun() && openBarcode) {
|
||||
int size = usbDev.read(buffer, 1000); // 超時為 1 秒
|
||||
@ -216,7 +205,7 @@ public class SaleFlowHandler {
|
||||
if (encryptedBytes.length % 16 != 0) {
|
||||
throw new IllegalArgumentException("錯誤:加密數據長度不是 16 的倍數,可能需要填充");
|
||||
}
|
||||
|
||||
|
||||
byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8);
|
||||
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||
|
||||
@ -231,7 +220,7 @@ public class SaleFlowHandler {
|
||||
|
||||
String decryptedStr = MyApp.getInstance().getDecryptedData();
|
||||
MyApp.getInstance().getLogs().info("decryptedStr=" + decryptedStr);
|
||||
|
||||
|
||||
JSONObject jsonObject = new JSONObject(decryptedStr);
|
||||
if (checkORDCDispatchNumber(decryptedStr)) {
|
||||
return false;
|
||||
@ -290,7 +279,7 @@ public class SaleFlowHandler {
|
||||
JSONObject itemObject = itemsArray.getJSONObject(i);
|
||||
String ordc = itemObject.optString("ORDC", "").trim();
|
||||
int requestQty = safeParseInt(itemObject.optString("QTY"), 0);
|
||||
|
||||
|
||||
// 重新初始化這些變數
|
||||
MedicineName = "";
|
||||
MedicineCount = 0;
|
||||
@ -317,10 +306,10 @@ public class SaleFlowHandler {
|
||||
for (int i = 0; i < itemsArray.length(); i++) {
|
||||
JSONObject itemObject = itemsArray.getJSONObject(i);
|
||||
int requestQty = safeParseInt(itemObject.optString("QTY"), 0);
|
||||
|
||||
|
||||
if (requestQty <= medicineList.get(i).getMedicineCount()) {
|
||||
haveValuestate = true;
|
||||
|
||||
|
||||
if (requestQty > 1) {
|
||||
class TempSlot {
|
||||
int slotNo;
|
||||
@ -397,13 +386,19 @@ public class 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) {
|
||||
@ -81,7 +81,8 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
super.execute(context, commandCode, message);
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
binding.buttonBack.setEnabled(true);
|
||||
binding.buttonReload.setEnabled(true);
|
||||
});
|
||||
break;
|
||||
case RESET_DATA:
|
||||
@ -96,7 +97,8 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
super.execute(context, commandCode, message);
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
binding.buttonBack.setEnabled(true);
|
||||
binding.buttonReload.setEnabled(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -134,7 +136,8 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
com.unibuy.smartdevice.external.StarCloudAPI starCloudAPI = new com.unibuy.smartdevice.external.StarCloudAPI(getHandlerMain(), getHandlerMain());
|
||||
try {
|
||||
runOnUiThread(() -> {
|
||||
binding.buttonBack.setVisibility(View.GONE);
|
||||
binding.buttonBack.setEnabled(false);
|
||||
binding.buttonReload.setEnabled(false);
|
||||
binding.progressLayout.setVisibility(View.VISIBLE);
|
||||
updateProgressUI(0, getCtx().getString(R.string.downloading_product_data));
|
||||
});
|
||||
@ -183,9 +186,10 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
|
||||
@Override
|
||||
protected void execute(HandlerMain handlerMain) {
|
||||
// 隱藏返回按鈕,顯示進度框 (UI thread)
|
||||
// 顯示進度框 (UI thread),按鈕保留顯示但停用不可點
|
||||
runOnUiThread(() -> {
|
||||
binding.buttonBack.setVisibility(View.GONE);
|
||||
binding.buttonBack.setEnabled(false);
|
||||
binding.buttonReload.setEnabled(false);
|
||||
binding.progressLayout.setVisibility(View.VISIBLE);
|
||||
});
|
||||
|
||||
@ -212,10 +216,11 @@ public class ProductListActivity extends AppCompatActivityAbstract {
|
||||
updateProgressUI(persent, getCtx().getString(R.string.syncing) + (i + 1) + "/" + adnum);
|
||||
}
|
||||
|
||||
// 全部完成 → 隱藏進度框,顯示返回按鈕 (UI thread)
|
||||
// 全部完成 → 隱藏進度框,重新啟用按鈕 (UI thread)
|
||||
runOnUiThread(() -> {
|
||||
binding.progressLayout.setVisibility(View.GONE);
|
||||
binding.buttonBack.setVisibility(View.VISIBLE);
|
||||
binding.buttonBack.setEnabled(true);
|
||||
binding.buttonReload.setEnabled(true);
|
||||
});
|
||||
close(handlerMain);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import android.widget.ImageView;
|
||||
import android.widget.SearchView;
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
|
||||
import com.unibuy.smartdevice.AppCompatActivityAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
@ -94,34 +95,44 @@ public class ProductSearchActivity extends AppCompatActivityAbstract {
|
||||
if (size > 0) recyclerSearchTextAdpter.notifyItemRangeRemoved(0, size);
|
||||
|
||||
if (message.isEmpty()) {
|
||||
binding.recyclerSearchText.setVisibility(View.GONE);
|
||||
binding.textSearchStatus.setVisibility(View.VISIBLE);
|
||||
binding.textSearchStatus.setText("請輸入您要搜尋的文字");
|
||||
|
||||
for (ProductStructure product: MyApp.getInstance().getProductList()) {
|
||||
searchProductList.add(product);
|
||||
}
|
||||
|
||||
recyclerProductSearchAdpter.notifyItemInserted(searchProductList.size() -1);
|
||||
if (searchProductList.size() > 0) {
|
||||
recyclerProductSearchAdpter.notifyDataSetChanged();
|
||||
}
|
||||
} else {
|
||||
for (ProductStructure product: MyApp.getInstance().getProductList()) {
|
||||
if (product.getProductName().contains(message)) {
|
||||
searchProductList.add(product);
|
||||
String name = product.getProductName();
|
||||
// Log.i("product name", name);
|
||||
int index = name.indexOf(message);
|
||||
if (index != -1 && index < name.length() - 1) {
|
||||
String result = name.substring(index + message.length());
|
||||
|
||||
if (!result.isEmpty()) {
|
||||
String oneText = String.valueOf(result.charAt(0));
|
||||
if (!searchTextList.contains(oneText)) {
|
||||
searchTextList.add(oneText);
|
||||
}
|
||||
}
|
||||
if (!searchTextList.contains(name)) {
|
||||
searchTextList.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchTextList.size() > 2) Collections.sort(searchTextList);
|
||||
recyclerProductSearchAdpter.notifyItemInserted(searchProductList.size() -1);
|
||||
recyclerSearchTextAdpter.notifyItemInserted(searchTextList.size() -1);
|
||||
|
||||
if (searchTextList.isEmpty()) {
|
||||
binding.recyclerSearchText.setVisibility(View.GONE);
|
||||
binding.textSearchStatus.setVisibility(View.VISIBLE);
|
||||
binding.textSearchStatus.setText("查無相關的候選字");
|
||||
} else {
|
||||
binding.recyclerSearchText.setVisibility(View.VISIBLE);
|
||||
binding.textSearchStatus.setVisibility(View.GONE);
|
||||
recyclerSearchTextAdpter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
if (searchProductList.size() > 0) {
|
||||
recyclerProductSearchAdpter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -140,7 +151,7 @@ public class ProductSearchActivity extends AppCompatActivityAbstract {
|
||||
|
||||
recyclerProductSearchAdpter = new RecyclerProductSearchAdpter(getCtx(), position, searchProductList);
|
||||
binding.recyclerProductSearch.setAdapter(recyclerProductSearchAdpter);
|
||||
binding.recyclerProductSearch.setLayoutManager(new LinearLayoutManager(getCtx()));
|
||||
binding.recyclerProductSearch.setLayoutManager(new GridLayoutManager(getCtx(), 3));
|
||||
|
||||
recyclerSearchTextAdpter = new RecyclerSearchTextAdpter(binding, searchTextList);
|
||||
binding.recyclerSearchText.setAdapter(recyclerSearchTextAdpter);
|
||||
@ -149,8 +160,30 @@ public class ProductSearchActivity extends AppCompatActivityAbstract {
|
||||
// SearchView searchView = binding.searchProductList;
|
||||
// enlargeSearchView(searchView, 32, 20, R.drawable.search); // icon 32dp,文字 20sp,自訂 drawable
|
||||
replaceSearchViewIcon(binding.searchProductList);
|
||||
binding.searchProductList.setOnQueryTextListener(new SearchProductListOnQueryText(getHandlerMain()));
|
||||
binding.searchProductList.setOnQueryTextListener(new SearchProductListOnQueryText(getHandlerMain()) {
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query) {
|
||||
binding.searchProductList.clearFocus();
|
||||
return super.onQueryTextSubmit(query);
|
||||
}
|
||||
});
|
||||
|
||||
// 點擊列表時自動關閉鍵盤
|
||||
binding.recyclerProductSearch.setOnTouchListener((v, event) -> {
|
||||
binding.searchProductList.clearFocus();
|
||||
return false;
|
||||
});
|
||||
binding.recyclerSearchText.setOnTouchListener((v, event) -> {
|
||||
binding.searchProductList.clearFocus();
|
||||
return false;
|
||||
});
|
||||
|
||||
binding.buttonBack.setOnClickListener(v -> finish());
|
||||
|
||||
binding.buttonClear.setOnClickListener(v -> {
|
||||
binding.searchProductList.setQuery("", false);
|
||||
binding.searchProductList.clearFocus();
|
||||
});
|
||||
|
||||
if (MyApp.getInstance().getProductList().isEmpty()) {
|
||||
ProductsDao productsDao = new ProductsDao(getCtx());
|
||||
@ -172,9 +205,6 @@ public class ProductSearchActivity extends AppCompatActivityAbstract {
|
||||
private void replaceSearchViewIcon(SearchView searchView) {
|
||||
if (searchView == null) return;
|
||||
|
||||
// 設定 hint
|
||||
searchView.setQueryHint("輸入搜尋關鍵字");
|
||||
|
||||
// 找到內部的 EditText,修改字體大小 & 顏色
|
||||
int searchEditTextId = searchView.getContext()
|
||||
.getResources()
|
||||
@ -194,7 +224,24 @@ public class ProductSearchActivity extends AppCompatActivityAbstract {
|
||||
|
||||
ImageView searchIcon = searchView.findViewById(searchIconId);
|
||||
if (searchIcon != null) {
|
||||
searchIcon.setImageResource(R.drawable.searchopen);
|
||||
searchIcon.setImageDrawable(null);
|
||||
searchIcon.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// 隱藏內部的清除 icon
|
||||
int closeIconId = searchView.getContext()
|
||||
.getResources()
|
||||
.getIdentifier("android:id/search_close_btn", null, null);
|
||||
ImageView closeIcon = searchView.findViewById(closeIconId);
|
||||
if (closeIcon != null) {
|
||||
closeIcon.setImageDrawable(null);
|
||||
closeIcon.setEnabled(false);
|
||||
ViewGroup.LayoutParams params = closeIcon.getLayoutParams();
|
||||
if (params != null) {
|
||||
params.width = 0;
|
||||
params.height = 0;
|
||||
closeIcon.setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -83,6 +83,9 @@ public class RestartActivity extends AppCompatActivityAbstract {
|
||||
binding = ActivityRestartBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// A1(頁面跳轉中):套用後台「APP UI 元素設定」下發的滿版圖,未設定則保留預設 unibuy_transfer_page
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("A1", binding.restartImage);
|
||||
|
||||
// 🚩 重啟前統一釋放所有資源
|
||||
DeviceResourceManager.getInstance().releaseAllDevices();
|
||||
// 延遲幾秒後執行 App 重啟
|
||||
|
||||
@ -103,6 +103,9 @@ public class RestartHostActivity extends AppCompatActivityAbstract {
|
||||
binding = ActivityRestartHostBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// A1(頁面跳轉中):套用後台「APP UI 元素設定」下發的滿版圖,未設定則保留預設 unibuy_transfer_page
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("A1", binding.restartImage);
|
||||
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
starCloudAPI = new com.unibuy.smartdevice.external.StarCloudAPI(getHandlerMain(), getHandlerMain());
|
||||
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
|
||||
/**
|
||||
* 銷售流程處理器(抽象基底)。
|
||||
*
|
||||
* 三種購買流程改由雲端 B014 {@code shopping_mode} 執行期切換,取代舊 project flavor 分家:
|
||||
* <ul>
|
||||
* <li>{@code basic} → {@link BasicSaleFlow}(多品項購物車,原 General)</li>
|
||||
* <li>{@code employee_card}→ {@link EmployeeCardSaleFlow}(員工卡 B680,原 Chengwai)</li>
|
||||
* <li>{@code pickup_sheet} → {@link PickupSheetSaleFlow}(掃碼醫令自動結帳,原 Cmuh)</li>
|
||||
* </ul>
|
||||
* 由 {@link #create} 依 {@link MyApp#getShoppingMode()} 建立對應策略。
|
||||
*/
|
||||
public abstract class SaleFlowHandler {
|
||||
|
||||
protected final Context context;
|
||||
protected final SaleFlowCallback callback;
|
||||
protected final HandlerMain handlerMain;
|
||||
|
||||
protected SaleFlowHandler(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
this.context = context;
|
||||
this.handlerMain = handlerMain;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
/** 依雲端 shopping_mode 建立對應的銷售流程策略(預設 basic)。 */
|
||||
public static SaleFlowHandler create(Context context, HandlerMain handlerMain, SaleFlowCallback callback) {
|
||||
String mode = MyApp.getInstance().getShoppingMode();
|
||||
if ("employee_card".equals(mode)) {
|
||||
return new EmployeeCardSaleFlow(context, handlerMain, callback);
|
||||
}
|
||||
if ("pickup_sheet".equals(mode)) {
|
||||
// 取物單模式下,若雲端開啟「領藥單」子開關 → 走雲端 B660 領藥流程;否則維持中國醫本地解密流程。
|
||||
if (MyApp.getInstance().getDevSet().isPharmacyPickup()) {
|
||||
return new PharmacyPickupSaleFlow(context, handlerMain, callback);
|
||||
}
|
||||
return new PickupSheetSaleFlow(context, handlerMain, callback);
|
||||
}
|
||||
return new BasicSaleFlow(context, handlerMain, callback);
|
||||
}
|
||||
|
||||
public void onSetupUI() {
|
||||
onSetupUI(null);
|
||||
}
|
||||
|
||||
public void onSetupUI(View rootView) {
|
||||
// 預設無動作
|
||||
}
|
||||
|
||||
public void onBuyRequested() {
|
||||
// 預設無動作
|
||||
}
|
||||
|
||||
public void onShoppingCartRequested() {
|
||||
// 預設:購物車浮鈕等同進入購買流程
|
||||
onBuyRequested();
|
||||
}
|
||||
|
||||
public boolean handleProductSelected(SlotStructure slot) {
|
||||
// 預設:不消化商品點擊,交回 adapter 既有流程
|
||||
return false;
|
||||
}
|
||||
|
||||
public Class<? extends DialogAbstract> cashDialogClass() {
|
||||
// 全模式統一用串口/USB 皆支援的強化版現金對話框(走 CashAcceptorFactory 依連線設定選傳輸)。
|
||||
// 舊的 CashPayDialog 只支援 USB,會在串口機台上跳「找不到 USB 轉接器」,故不再作為預設。
|
||||
return com.unibuy.smartdevice.ui.dialog.CashPayDialogGeneral.class;
|
||||
}
|
||||
|
||||
public void onBarcodeReceived(String rawData) {
|
||||
// 預設無動作
|
||||
}
|
||||
|
||||
/**
|
||||
* 語言切換時呼叫:FontendActivity 會重新 inflate 版面/容器,saleFlowHandler 內快取的 fragment
|
||||
* (如購物車)會變成掛在舊容器的孤兒 → 購買後再開會空白。子類在此清掉快取並移除舊 fragment。
|
||||
*/
|
||||
public void onLocaleSwitched() {
|
||||
// 預設無動作
|
||||
}
|
||||
|
||||
public void onPickupRequested() {
|
||||
// 預設無動作
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,8 @@ public class SettingActivity extends AppCompatActivityAbstract {
|
||||
MyApp.getInstance().getMachine().setMachineID(binding.editMachineID.getText().toString());
|
||||
if (binding.radioProd.isChecked()) {
|
||||
MyApp.getInstance().getMachine().setMqttEnv("prod");
|
||||
} else if (binding.radioLocal.isChecked()) {
|
||||
MyApp.getInstance().getMachine().setMqttEnv("local");
|
||||
} else {
|
||||
MyApp.getInstance().getMachine().setMqttEnv("demo");
|
||||
}
|
||||
@ -172,8 +174,11 @@ public class SettingActivity extends AppCompatActivityAbstract {
|
||||
binding.buttonReload.setText("重新綁定");
|
||||
}
|
||||
|
||||
if ("prod".equals(MyApp.getInstance().getMachine().getMqttEnv())) {
|
||||
String currentEnv = MyApp.getInstance().getMachine().getMqttEnv();
|
||||
if ("prod".equals(currentEnv)) {
|
||||
binding.radioProd.setChecked(true);
|
||||
} else if ("local".equals(currentEnv)) {
|
||||
binding.radioLocal.setChecked(true);
|
||||
} else {
|
||||
binding.radioDemo.setChecked(true);
|
||||
}
|
||||
|
||||
@ -2,30 +2,28 @@ package com.unibuy.smartdevice.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TableRow;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.unibuy.smartdevice.AppCompatActivityAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
import com.unibuy.smartdevice.database.MemoDao;
|
||||
import com.unibuy.smartdevice.database.SettingsDao;
|
||||
import com.unibuy.smartdevice.databinding.ActivitySettingTestBinding;
|
||||
import com.unibuy.smartdevice.databinding.ActivitySystemSetBinding;
|
||||
import com.unibuy.smartdevice.structure.DevSetStructure;
|
||||
import com.unibuy.smartdevice.structure.MemoStructure;
|
||||
import com.unibuy.smartdevice.external.StarCloudAPI;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.recycler.RecyclerSettingListAdpter;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 主控台「系統設定」:呈現雲端 B014 下發的開關設定。
|
||||
* 系統方(identity=system)可編輯並按「儲存」回寫雲端(B016);其他身分唯讀。
|
||||
*/
|
||||
public class SystemSetActivity extends AppCompatActivityAbstract {
|
||||
private ActivitySystemSetBinding binding;
|
||||
private boolean isSystem;
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
@ -49,57 +47,337 @@ public class SystemSetActivity extends AppCompatActivityAbstract {
|
||||
binding = ActivitySystemSetBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
isSystem = MyApp.getInstance().isSystemIdentity();
|
||||
|
||||
applyShoppingModeView();
|
||||
updateCheckBoxes();
|
||||
applyRolePermission();
|
||||
applyPortProfileView();
|
||||
|
||||
binding.buttonBack.setOnClickListener(v -> {
|
||||
updateSystemSet();
|
||||
finish();
|
||||
});
|
||||
|
||||
// IC 卡讀取功能已移除
|
||||
binding.buttonBack.setOnClickListener(v -> finish());
|
||||
binding.buttonSave.setOnClickListener(v -> onSaveClicked());
|
||||
binding.buttonPortProfile.setOnClickListener(v -> showPortProfileDialog());
|
||||
}
|
||||
|
||||
/**
|
||||
* 串口方案(本機硬體設定,不回寫雲端):顯示目前方案並提供切換。因應不同 IPC 主板的
|
||||
* 下位機(主櫃 XinYuan)/刷卡機(NFCPay)串口位置不一致——舊版本 ttyS1/ttyS2、星科技 ttyS7/ttyS6。
|
||||
*/
|
||||
private void applyPortProfileView() {
|
||||
String profile = com.unibuy.smartdevice.devices.PortProfile.getProfile(getCtx());
|
||||
binding.textViewPortProfile.setText(
|
||||
com.unibuy.smartdevice.devices.PortProfile.displayName(profile));
|
||||
}
|
||||
|
||||
/** 顯示串口方案選單;選到不同方案時持久化並提示重啟套用(串口於 app 啟動時綁定)。 */
|
||||
private void showPortProfileDialog() {
|
||||
final String current = com.unibuy.smartdevice.devices.PortProfile.getProfile(getCtx());
|
||||
final String[] values = {
|
||||
com.unibuy.smartdevice.devices.PortProfile.LEGACY,
|
||||
com.unibuy.smartdevice.devices.PortProfile.STARTECH
|
||||
};
|
||||
final String[] labels = {
|
||||
"舊版本(下位機 ttyS1/刷卡機 ttyS2)",
|
||||
"星科技(下位機 ttyS7/刷卡機 ttyS6)"
|
||||
};
|
||||
int checked = com.unibuy.smartdevice.devices.PortProfile.STARTECH.equals(current) ? 1 : 0;
|
||||
|
||||
new android.app.AlertDialog.Builder(this)
|
||||
.setTitle("選擇串口方案(本機)")
|
||||
.setSingleChoiceItems(labels, checked, null)
|
||||
.setPositiveButton("套用", (dialog, which) -> {
|
||||
int pos = ((android.app.AlertDialog) dialog).getListView().getCheckedItemPosition();
|
||||
if (pos < 0) return;
|
||||
String selected = values[pos];
|
||||
if (selected.equals(current)) {
|
||||
android.widget.Toast.makeText(getCtx(), "方案未變更", android.widget.Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
com.unibuy.smartdevice.devices.PortProfile.setProfile(getApplicationContext(), selected);
|
||||
applyPortProfileView();
|
||||
getLogs().info("[串口方案] 切換為 " + selected + ",將重啟 app 套用");
|
||||
confirmRestartForPortProfile(selected);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/** 切換方案後需重啟 app 才會以新串口開啟;提示並重啟。 */
|
||||
private void confirmRestartForPortProfile(String selected) {
|
||||
new android.app.AlertDialog.Builder(this)
|
||||
.setTitle("需重啟套用")
|
||||
.setMessage("已設定為「" + com.unibuy.smartdevice.devices.PortProfile.displayName(selected)
|
||||
+ "」。\n\n串口於 app 啟動時綁定,需重啟 app 才會以新串口連線。是否立即重啟?")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("立即重啟", (d, w) -> restartApp())
|
||||
.setNegativeButton("稍後重啟", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/** 清空 Task 重新啟動首頁並結束本行程,重新以新串口方案接管硬體。 */
|
||||
private void restartApp() {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
android.content.Intent intent = new android.content.Intent(
|
||||
getApplicationContext(), com.unibuy.smartdevice.AppEntry.initialActivity());
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
| android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
startActivity(intent);
|
||||
finishAffinity();
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 購物方式(雲端 B014 ShoppingMode,機台唯讀):顯示中文名稱,並只在 basic(基礎版)
|
||||
* 時顯示其下的可設定項;員工卡/取貨單模式整段隱藏(流程與硬體由雲端固定,現場不可調)。
|
||||
*/
|
||||
private void applyShoppingModeView() {
|
||||
String mode = MyApp.getInstance().getShoppingMode();
|
||||
String label;
|
||||
switch (mode == null ? "" : mode) {
|
||||
case "employee_card":
|
||||
label = "員工卡";
|
||||
break;
|
||||
case "pickup_sheet":
|
||||
label = "取貨單";
|
||||
break;
|
||||
case "basic":
|
||||
default:
|
||||
label = "基礎版";
|
||||
break;
|
||||
}
|
||||
binding.textViewShoppingMode.setText("購物方式:" + label);
|
||||
binding.layoutBasicOnlySettings.setVisibility("basic".equals(mode) ? View.VISIBLE : View.GONE);
|
||||
applyPharmacyPickupView(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 領藥單功能狀態:僅「取貨單(pickup_sheet)」模式才顯示,狀態取自雲端 B014 下發的
|
||||
* FunctionSet.PharmacyPickup(機台唯讀)。啟用→綠色「啟用中」、未啟用→橘色「未啟用」。
|
||||
*/
|
||||
private void applyPharmacyPickupView(String mode) {
|
||||
if (!"pickup_sheet".equals(mode)) {
|
||||
binding.textViewPharmacyPickup.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
boolean enabled = MyApp.getInstance().getDevSet().isPharmacyPickup();
|
||||
binding.textViewPharmacyPickup.setVisibility(View.VISIBLE);
|
||||
binding.textViewPharmacyPickup.setText("領藥單功能:" + (enabled ? "啟用中" : "未啟用"));
|
||||
binding.textViewPharmacyPickup.setTextColor(androidx.core.content.ContextCompat.getColor(
|
||||
getCtx(), enabled ? com.unibuy.smartdevice.R.color.success : com.unibuy.smartdevice.R.color.warning));
|
||||
}
|
||||
|
||||
/** 將記憶體中的設定值套到各 CheckBox。 */
|
||||
private void updateCheckBoxes() {
|
||||
binding.checkboxVMC.setChecked(MyApp.getInstance().getDevSet().isVMC());
|
||||
binding.checkboxElectic.setChecked(MyApp.getInstance().getDevSet().isElectic());
|
||||
binding.checkboxShoppingCar.setChecked(MyApp.getInstance().getDevSet().isShoppingCar());
|
||||
binding.checkboxInvoice.setChecked(MyApp.getInstance().getDevSet().isInvoice());
|
||||
binding.checkboxDevNFCPay.setChecked(MyApp.getInstance().getDevSet().isDevNFCPay());
|
||||
binding.checkboxDevEsunPay.setChecked(MyApp.getInstance().getDevSet().isDevEsunPay());
|
||||
binding.checkboxDevTapPay.setChecked(MyApp.getInstance().getDevSet().isDevTapPay());
|
||||
binding.checkboxDevCash.setChecked(MyApp.getInstance().getDevSet().isDevCash());
|
||||
binding.checkboxDevLinePay.setChecked(MyApp.getInstance().getDevSet().isDevLinePay());
|
||||
binding.cardDevShoppingCar.setVisibility(MyApp.getInstance().getDevSet().isShoppingCar() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevInvoice.setVisibility(MyApp.getInstance().getDevSet().isInvoice() ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean isNfcPayEnabled = MyApp.getInstance().getDevSet().isDevNFCPay();
|
||||
|
||||
if (isNfcPayEnabled) {
|
||||
binding.textViewNFCStatusHint.setText("啟用中");
|
||||
binding.textViewNFCStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.success));
|
||||
binding.gridLayoutNFCPayOptions.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.textViewNFCStatusHint.setText("功能未啟用");
|
||||
binding.textViewNFCStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.warning));
|
||||
binding.gridLayoutNFCPayOptions.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
binding.cardDevCreditCard.setVisibility(MyApp.getInstance().getDevSet().isDevCreditCard() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevMobilePay.setVisibility(MyApp.getInstance().getDevSet().isDevMobilePay() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevCardPay.setVisibility(MyApp.getInstance().getDevSet().isDevCardPay() ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean isScanPayEnabled = MyApp.getInstance().getDevSet().isDevScanPay();
|
||||
binding.checkboxDevScanPay.setChecked(isScanPayEnabled);
|
||||
|
||||
if (isScanPayEnabled) {
|
||||
binding.textViewScanStatusHint.setText("啟用中");
|
||||
binding.textViewScanStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.success));
|
||||
binding.layoutScanPayOptions.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.textViewScanStatusHint.setText("功能未啟用");
|
||||
binding.textViewScanStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.warning));
|
||||
binding.layoutScanPayOptions.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
boolean isEsunEnabled = MyApp.getInstance().getDevSet().isDevEsunPay();
|
||||
binding.checkboxDevEsunPay.setChecked(isEsunEnabled);
|
||||
int esunVisibility = isEsunEnabled ? View.VISIBLE : View.GONE;
|
||||
binding.cardDevEsunPay.setVisibility(esunVisibility);
|
||||
binding.cardDevEsunSubTaiwanPay.setVisibility(esunVisibility);
|
||||
|
||||
boolean isTapPayEnabled = MyApp.getInstance().getDevSet().isDevTapPay();
|
||||
binding.checkboxDevTapPay.setChecked(isTapPayEnabled);
|
||||
binding.cardDevTapPay.setVisibility(isTapPayEnabled ? View.VISIBLE : View.GONE);
|
||||
|
||||
binding.checkboxDevTapPay30.setChecked(MyApp.getInstance().getDevSet().isTapPay30());
|
||||
binding.checkboxDevTapPay31.setChecked(MyApp.getInstance().getDevSet().isTapPay31());
|
||||
binding.checkboxDevTapPay32.setChecked(MyApp.getInstance().getDevSet().isTapPay32());
|
||||
binding.checkboxDevTapPay33.setChecked(MyApp.getInstance().getDevSet().isTapPay33());
|
||||
binding.checkboxDevTapPay34.setChecked(MyApp.getInstance().getDevSet().isTapPay34());
|
||||
|
||||
binding.cardDevTapPay30.setVisibility(isTapPayEnabled && MyApp.getInstance().getDevSet().isTapPay30() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevTapPay31.setVisibility(isTapPayEnabled && MyApp.getInstance().getDevSet().isTapPay31() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevTapPay32.setVisibility(isTapPayEnabled && MyApp.getInstance().getDevSet().isTapPay32() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevTapPay33.setVisibility(isTapPayEnabled && MyApp.getInstance().getDevSet().isTapPay33() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevTapPay34.setVisibility(isTapPayEnabled && MyApp.getInstance().getDevSet().isTapPay34() ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean isLinePayEnabled = MyApp.getInstance().getDevSet().isDevLinePay();
|
||||
binding.checkboxDevLinePay.setChecked(isLinePayEnabled);
|
||||
binding.cardDevLinePay.setVisibility(isLinePayEnabled ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevLineSubLinePay.setVisibility(isLinePayEnabled ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean isCashEnabled = MyApp.getInstance().getDevSet().isDevCash();
|
||||
binding.checkboxDevCash.setChecked(isCashEnabled);
|
||||
|
||||
if (isCashEnabled) {
|
||||
binding.textViewCashStatusHint.setText("啟用中");
|
||||
binding.textViewCashStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.success));
|
||||
binding.layoutCashPayOptions.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.textViewCashStatusHint.setText("功能未啟用");
|
||||
binding.textViewCashStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.warning));
|
||||
binding.layoutCashPayOptions.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
binding.checkboxDevBillF.setChecked(MyApp.getInstance().getCashSetStructure().isBillF1000());
|
||||
binding.checkboxDevBillE.setChecked(MyApp.getInstance().getCashSetStructure().isBillE500());
|
||||
binding.checkboxDevBillD.setChecked(MyApp.getInstance().getCashSetStructure().isBillD100());
|
||||
|
||||
binding.checkboxDevCoinF.setChecked(MyApp.getInstance().getCashSetStructure().isCoinF50());
|
||||
binding.checkboxDevCoinE.setChecked(MyApp.getInstance().getCashSetStructure().isCoinE10());
|
||||
binding.checkboxDevCoinD.setChecked(MyApp.getInstance().getCashSetStructure().isCoinD5());
|
||||
binding.checkboxDevCoinC.setChecked(MyApp.getInstance().getCashSetStructure().isCoinC1());
|
||||
|
||||
binding.cardDevBillF.setVisibility(MyApp.getInstance().getCashSetStructure().isBillF1000() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevBillE.setVisibility(MyApp.getInstance().getCashSetStructure().isBillE500() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevBillD.setVisibility(MyApp.getInstance().getCashSetStructure().isBillD100() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevCoinF.setVisibility(MyApp.getInstance().getCashSetStructure().isCoinF50() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevCoinE.setVisibility(MyApp.getInstance().getCashSetStructure().isCoinE10() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevCoinD.setVisibility(MyApp.getInstance().getCashSetStructure().isCoinD5() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevCoinC.setVisibility(MyApp.getInstance().getCashSetStructure().isCoinC1() ? View.VISIBLE : View.GONE);
|
||||
|
||||
boolean isPickupEnabled = MyApp.getInstance().getDevSet().isPickupModule();
|
||||
binding.checkboxPickupModule.setChecked(isPickupEnabled);
|
||||
|
||||
if (isPickupEnabled) {
|
||||
binding.textViewPickupStatusHint.setText("啟用中");
|
||||
binding.textViewPickupStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.success));
|
||||
binding.chipGroupPickup.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.textViewPickupStatusHint.setText("功能未啟用");
|
||||
binding.textViewPickupStatusHint.setTextColor(androidx.core.content.ContextCompat.getColor(getCtx(), com.unibuy.smartdevice.R.color.warning));
|
||||
binding.chipGroupPickup.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
binding.checkboxPickupCode.setChecked(MyApp.getInstance().getDevSet().isPickupCode());
|
||||
binding.checkboxPassCode.setChecked(MyApp.getInstance().getDevSet().isPassCode());
|
||||
|
||||
binding.cardDevPickupCode.setVisibility(isPickupEnabled && MyApp.getInstance().getDevSet().isPickupCode() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevPassCode.setVisibility(isPickupEnabled && MyApp.getInstance().getDevSet().isPassCode() ? View.VISIBLE : View.GONE);
|
||||
|
||||
binding.checkboxWelcomeGift.setChecked(MyApp.getInstance().getDevSet().isWelcomeGift());
|
||||
binding.checkboxMemberSystem.setChecked(MyApp.getInstance().getDevSet().isMemberSystem());
|
||||
binding.checkboxAmbientTemp.setChecked(MyApp.getInstance().getDevSet().isAmbientTemp());
|
||||
|
||||
binding.cardDevWelcomeGift.setVisibility(MyApp.getInstance().getDevSet().isWelcomeGift() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevMemberSystem.setVisibility(MyApp.getInstance().getDevSet().isMemberSystem() ? View.VISIBLE : View.GONE);
|
||||
binding.cardDevAmbientTemp.setVisibility(MyApp.getInstance().getDevSet().isAmbientTemp() ? View.VISIBLE : View.GONE);
|
||||
|
||||
setupSpringSlotRow(binding.rowSpringSlot110, binding.textSpringSlot110, MyApp.getInstance().getHardwareSetStructure().isSpringSlot1_10());
|
||||
setupSpringSlotRow(binding.rowSpringSlot1120, binding.textSpringSlot1120, MyApp.getInstance().getHardwareSetStructure().isSpringSlot11_20());
|
||||
setupSpringSlotRow(binding.rowSpringSlot2130, binding.textSpringSlot2130, MyApp.getInstance().getHardwareSetStructure().isSpringSlot21_30());
|
||||
setupSpringSlotRow(binding.rowSpringSlot3140, binding.textSpringSlot3140, MyApp.getInstance().getHardwareSetStructure().isSpringSlot31_40());
|
||||
setupSpringSlotRow(binding.rowSpringSlot4150, binding.textSpringSlot4150, MyApp.getInstance().getHardwareSetStructure().isSpringSlot41_50());
|
||||
setupSpringSlotRow(binding.rowSpringSlot5160, binding.textSpringSlot5160, MyApp.getInstance().getHardwareSetStructure().isSpringSlot51_60());
|
||||
}
|
||||
|
||||
private void updateSystemSet() {
|
||||
MyApp.getInstance().getDevSet().setVMC(binding.checkboxVMC.isChecked());
|
||||
MyApp.getInstance().getDevSet().setElectic(binding.checkboxElectic.isChecked());
|
||||
private void setupSpringSlotRow(TableRow row, TextView textMode, boolean isChecked) {
|
||||
textMode.setTag(isChecked);
|
||||
textMode.setText(isChecked ? "彈簧模式" : "履帶模式");
|
||||
}
|
||||
|
||||
/** 依登入身分決定可否編輯:系統方僅可編輯硬體貨道並顯示儲存鈕;其餘全唯讀。 */
|
||||
private void applyRolePermission() {
|
||||
binding.buttonSave.setVisibility(View.GONE);
|
||||
refreshChildStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 統一刷新所有 CheckBox 的 enabled 狀態。機台不可編輯系統設定:
|
||||
* - 所有系統設定項(購物車/發票/各支付方式/刷卡機/掃碼/現金總開關與面額/取貨模組/
|
||||
* 來店禮/會員/環溫)→ 一律唯讀,真相在雲端後台,即使系統方也不可改。
|
||||
* - 唯一例外:硬體貨道(彈簧/履帶)→ 僅系統方可改並寫回雲端,因只有現場技師最清楚
|
||||
* 各貨道的實際機械型式。
|
||||
*/
|
||||
private void refreshChildStates() {
|
||||
// 系統設定項:恆唯讀(與身分、主開關都無關)
|
||||
setEnabled(false,
|
||||
binding.checkboxShoppingCar, binding.checkboxInvoice,
|
||||
binding.checkboxDevScanPay, binding.checkboxDevEsunPay, binding.checkboxDevTapPay,
|
||||
binding.checkboxDevLinePay,
|
||||
binding.checkboxDevTapPay30, binding.checkboxDevTapPay31, binding.checkboxDevTapPay32,
|
||||
binding.checkboxDevTapPay33, binding.checkboxDevTapPay34,
|
||||
binding.checkboxDevCash,
|
||||
binding.checkboxDevBillF, binding.checkboxDevBillE, binding.checkboxDevBillD,
|
||||
binding.checkboxDevCoinF, binding.checkboxDevCoinE, binding.checkboxDevCoinD,
|
||||
binding.checkboxDevCoinC,
|
||||
binding.checkboxPickupModule, binding.checkboxPickupCode, binding.checkboxPassCode,
|
||||
binding.checkboxWelcomeGift, binding.checkboxMemberSystem, binding.checkboxAmbientTemp);
|
||||
|
||||
// 硬體貨道已改為全唯讀
|
||||
}
|
||||
|
||||
private void setEnabled(boolean enabled, View... views) {
|
||||
for (View v : views) {
|
||||
v.setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** 系統方按下儲存:先回寫雲端,成功後才更新本地並關閉。 */
|
||||
private void onSaveClicked() {
|
||||
if (!isSystem) return;
|
||||
binding.buttonSave.setEnabled(false);
|
||||
try {
|
||||
JSONObject settings = buildBackendSettings();
|
||||
new StarCloudAPI(getHandlerMain(), getHandlerMain()).uploadSystemSettings(settings,
|
||||
new StarCloudAPI.SettingsUploadCallback() {
|
||||
@Override
|
||||
public void onSuccess() {
|
||||
commitToLocal();
|
||||
android.widget.Toast.makeText(getCtx(), "設定已儲存並回寫雲端", android.widget.Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String message) {
|
||||
binding.buttonSave.setEnabled(true);
|
||||
android.widget.Toast.makeText(getCtx(), "儲存失敗:" + message, android.widget.Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
} catch (JSONException e) {
|
||||
binding.buttonSave.setEnabled(true);
|
||||
android.widget.Toast.makeText(getCtx(), "設定組裝失敗:" + e.getMessage(), android.widget.Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
/** 將目前 CheckBox 狀態寫回記憶體並持久化(本地)。 */
|
||||
private void commitToLocal() {
|
||||
MyApp.getInstance().getDevSet().setShoppingCar(binding.checkboxShoppingCar.isChecked());
|
||||
MyApp.getInstance().getDevSet().setInvoice(binding.checkboxInvoice.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevNFCPay(binding.checkboxDevNFCPay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevScanPay(binding.checkboxDevScanPay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevEsunPay(binding.checkboxDevEsunPay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevTapPay(binding.checkboxDevTapPay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevCash(binding.checkboxDevCash.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevLinePay(binding.checkboxDevLinePay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setTapPay30(binding.checkboxDevTapPay30.isChecked());
|
||||
MyApp.getInstance().getDevSet().setTapPay31(binding.checkboxDevTapPay31.isChecked());
|
||||
MyApp.getInstance().getDevSet().setTapPay32(binding.checkboxDevTapPay32.isChecked());
|
||||
MyApp.getInstance().getDevSet().setTapPay33(binding.checkboxDevTapPay33.isChecked());
|
||||
MyApp.getInstance().getDevSet().setTapPay34(binding.checkboxDevTapPay34.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevLinePay(binding.checkboxDevLinePay.isChecked());
|
||||
MyApp.getInstance().getDevSet().setDevCash(binding.checkboxDevCash.isChecked());
|
||||
|
||||
MyApp.getInstance().getCashSetStructure().setBillF1000(binding.checkboxDevBillF.isChecked());
|
||||
MyApp.getInstance().getCashSetStructure().setBillE500(binding.checkboxDevBillE.isChecked());
|
||||
@ -109,6 +387,36 @@ public class SystemSetActivity extends AppCompatActivityAbstract {
|
||||
MyApp.getInstance().getCashSetStructure().setCoinD5(binding.checkboxDevCoinD.isChecked());
|
||||
MyApp.getInstance().getCashSetStructure().setCoinC1(binding.checkboxDevCoinC.isChecked());
|
||||
|
||||
MyApp.getInstance().getDevSet().setPickupModule(binding.checkboxPickupModule.isChecked());
|
||||
MyApp.getInstance().getDevSet().setPickupCode(binding.checkboxPickupCode.isChecked());
|
||||
MyApp.getInstance().getDevSet().setPassCode(binding.checkboxPassCode.isChecked());
|
||||
MyApp.getInstance().getDevSet().setWelcomeGift(binding.checkboxWelcomeGift.isChecked());
|
||||
MyApp.getInstance().getDevSet().setMemberSystem(binding.checkboxMemberSystem.isChecked());
|
||||
MyApp.getInstance().getDevSet().setAmbientTemp(binding.checkboxAmbientTemp.isChecked());
|
||||
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot1_10((Boolean) binding.textSpringSlot110.getTag());
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot11_20((Boolean) binding.textSpringSlot1120.getTag());
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot21_30((Boolean) binding.textSpringSlot2130.getTag());
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot31_40((Boolean) binding.textSpringSlot3140.getTag());
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot41_50((Boolean) binding.textSpringSlot4150.getTag());
|
||||
MyApp.getInstance().getHardwareSetStructure().setSpringSlot51_60((Boolean) binding.textSpringSlot5160.getTag());
|
||||
|
||||
MyApp.saveSystemSet(getCtx());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 組出回寫雲端用的 settings JSON。機台僅可編輯硬體貨道,故只送這六個 is_spring_slot_*
|
||||
* 欄位;B016 後端以 array_key_exists 逐欄位守門,未送的欄位保持原值,避免用本地舊值
|
||||
* 覆寫雲端後台可能已更新的其他設定。
|
||||
*/
|
||||
private JSONObject buildBackendSettings() throws JSONException {
|
||||
JSONObject s = new JSONObject();
|
||||
s.put("is_spring_slot_1_10", (Boolean) binding.textSpringSlot110.getTag());
|
||||
s.put("is_spring_slot_11_20", (Boolean) binding.textSpringSlot1120.getTag());
|
||||
s.put("is_spring_slot_21_30", (Boolean) binding.textSpringSlot2130.getTag());
|
||||
s.put("is_spring_slot_31_40", (Boolean) binding.textSpringSlot3140.getTag());
|
||||
s.put("is_spring_slot_41_50", (Boolean) binding.textSpringSlot4150.getTag());
|
||||
s.put("is_spring_slot_51_60", (Boolean) binding.textSpringSlot5160.getTag());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,21 +79,9 @@ public class RecyclerVmcBuyListAdpter extends RecyclerView.Adapter<RecyclerVmcBu
|
||||
} catch (LogsEmptyException e) {
|
||||
this.logs.warning(e);
|
||||
}
|
||||
holder.binding.textSlot.setText(String.format("%03d", slot.getSlot()));
|
||||
// 多國語的判斷
|
||||
String currentLang = LanguageHelper.getSavedLanguage(context);
|
||||
holder.binding.textProductName.setText(slot.getProduct().getProductName());
|
||||
switch (currentLang) {
|
||||
case "tw":
|
||||
holder.binding.textProductName.setText(slot.getProduct().getProductName());
|
||||
break;
|
||||
case "en":
|
||||
holder.binding.textProductName.setText(slot.getProduct().getProductNameEn());
|
||||
break;
|
||||
case "ja":
|
||||
holder.binding.textProductName.setText(slot.getProduct().getProductNameJp());
|
||||
break;
|
||||
}
|
||||
holder.binding.textSlot.setText(context.getString(R.string.slot_label) + slot.getSlot());
|
||||
// 多國語的判斷(統一由 ProductStructure 處理,英/日未翻譯自動 fallback 中文)
|
||||
holder.binding.textProductName.setText(slot.getProduct().getLocalizedName(context));
|
||||
|
||||
int machinePrice = slot.getProduct().getMachinePrice();
|
||||
int sellingPrice = slot.getProduct().getSellingPrice();
|
||||
@ -101,34 +89,57 @@ 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;
|
||||
if (price > 0) {
|
||||
holder.binding.textPrice.setVisibility(View.VISIBLE);
|
||||
holder.binding.textPrice.setText("NT$ " + price);
|
||||
} else {
|
||||
holder.binding.textPrice.setVisibility(View.GONE);
|
||||
}
|
||||
holder.binding.textPrice.setVisibility(View.VISIBLE);
|
||||
holder.binding.textPrice.setText("NT$ " + price);
|
||||
|
||||
// 綁定庫存數量顯示
|
||||
holder.binding.textStock.setText(context.getString(R.string.stock_label) + count);
|
||||
|
||||
holder.binding.mask.setVisibility(View.GONE);
|
||||
holder.binding.textMask.setText("");
|
||||
|
||||
if (count <= 0) {
|
||||
holder.binding.mask.setVisibility(View.VISIBLE);
|
||||
holder.binding.textMask.setText("補貨中");// 已經售完
|
||||
holder.binding.textMask.setText(context.getString(R.string.sold_out));
|
||||
}
|
||||
|
||||
// 效期過期:顯示「暫不販售」遮罩,並一律禁止購買(蓋過售完顯示,主要情境是有貨但過效期)
|
||||
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("暫停販售");
|
||||
holder.binding.textMask.setText(context.getString(R.string.suspended_sales));
|
||||
}
|
||||
|
||||
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));
|
||||
// 整張卡片皆可點開商品詳情(不限圖片),避免點到名稱/價格區沒反應
|
||||
holder.binding.getRoot().setOnClickListener(new imageProductPictureOnClickListener(position));
|
||||
} else {
|
||||
holder.binding.imageProductPicture.setOnClickListener(null);
|
||||
holder.binding.getRoot().setOnClickListener(null);
|
||||
}
|
||||
|
||||
// 商品卡右下角「+」快速加購:僅購物車功能開啟時顯示;無購物車時隱藏(只能點商品開詳情頁)
|
||||
boolean cartOn = MyApp.getInstance().getDevSet().isShoppingCar();
|
||||
if (cartOn && count > 0 && !isLock && !isExpired) {
|
||||
holder.binding.buttonQuickAdd.setVisibility(View.VISIBLE);
|
||||
holder.binding.buttonQuickAdd.setOnClickListener(v -> {
|
||||
if (MyApp.getInstance().isMachineBusy()) return;
|
||||
com.unibuy.v2.UnibuyHelper.INSTANCE.getCartViewModel().addToCart(slot);
|
||||
com.blankj.utilcode.util.ToastUtils.showShort(R.string.cart_added);
|
||||
});
|
||||
} else {
|
||||
holder.binding.buttonQuickAdd.setVisibility(View.GONE);
|
||||
holder.binding.buttonQuickAdd.setOnClickListener(null);
|
||||
}
|
||||
|
||||
// devRunVMC(0);
|
||||
@ -173,10 +184,29 @@ 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) {
|
||||
// 查詢失敗時不阻擋,讓原流程繼續
|
||||
}
|
||||
|
||||
// flavor 路由:若該 flavor 自行消化此次點擊(如 General 多品項購物車「加入購物車」),
|
||||
// 則直接結束;否則走以下既有流程(晟崴員工卡取貨等),行為不變。
|
||||
if (fragment.getActivity() instanceof FontendActivity) {
|
||||
com.unibuy.smartdevice.ui.SaleFlowHandler handler =
|
||||
((FontendActivity) fragment.getActivity()).getSaleFlowHandler();
|
||||
if (handler != null && handler.handleProductSelected(slotProduct)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 點選時即時檢查出貨口狀態(上鎖防止重複點擊)
|
||||
isCheckingSlot = true;
|
||||
checkSlotHaveProduct(slotProduct.getSlot(), 1, () -> {
|
||||
|
||||
@ -19,10 +19,12 @@ import androidx.viewpager.widget.ViewPager;
|
||||
import com.unibuy.smartdevice.R;
|
||||
import com.unibuy.smartdevice.databinding.RecyclerVmcSlotListBinding;
|
||||
import com.unibuy.smartdevice.exception.Logs;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.ProductStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.ProductSearchActivity;
|
||||
import com.unibuy.smartdevice.ui.tools.ImageGlide;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -62,7 +64,7 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
SlotStructure slot = slotList.get(position);
|
||||
holder.binding.textSlot.setText(String.valueOf(slot.getSlot()));
|
||||
holder.binding.textSlot.setText("貨道" + slot.getSlot());
|
||||
String productID = slot.getProduct().getProductID();
|
||||
if (!productID.isEmpty() && !productID.equals("?")) {
|
||||
holder.binding.textProductName.setText(slot.getProduct().getProductName());
|
||||
@ -71,17 +73,69 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
}
|
||||
holder.binding.textCount.setText(String.valueOf(slot.getCount()));
|
||||
holder.binding.editCount.setText(String.valueOf(slot.getCount()));
|
||||
// numberPassword 預設會遮罩輸入,關閉遮罩以顯示明碼數字
|
||||
holder.binding.editCount.setTransformationMethod(null);
|
||||
|
||||
// 效期顯示+編輯:永遠可點擊,補貨員現場輸入;空白顯示「設定效期」,已過期紅字標示
|
||||
String expiry = slot.getExpiryDate();
|
||||
holder.binding.textExpiry.setVisibility(View.VISIBLE);
|
||||
if (expiry.isEmpty()) {
|
||||
holder.binding.textExpiry.setText("設定效期");
|
||||
holder.binding.textExpiry.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.black));
|
||||
} else 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));
|
||||
}
|
||||
holder.binding.textExpiry.setOnClickListener(v -> showExpiryPicker(position));
|
||||
|
||||
if (slot.isLock()) {
|
||||
holder.binding.textLock.setText(context.getString(R.string.lock));
|
||||
holder.binding.textLock.setTextColor(Color.BLACK);
|
||||
holder.binding.textLock.setChecked(false);
|
||||
holder.binding.textLockStatus.setText("停售中");
|
||||
holder.binding.textLockStatus.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.warning));
|
||||
holder.binding.textLock.setThumbTintList(android.content.res.ColorStateList.valueOf(androidx.core.content.ContextCompat.getColor(context, R.color.warning)));
|
||||
holder.binding.textLock.setTrackTintList(android.content.res.ColorStateList.valueOf(androidx.core.graphics.ColorUtils.setAlphaComponent(androidx.core.content.ContextCompat.getColor(context, R.color.warning), 77)));
|
||||
holder.binding.viewProductMask.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.binding.textLock.setText(context.getString(R.string.unlocked));
|
||||
holder.binding.textLock.setTextColor(Color.GRAY);
|
||||
holder.binding.textLock.setChecked(true);
|
||||
holder.binding.textLockStatus.setText("販售中");
|
||||
holder.binding.textLockStatus.setTextColor(androidx.core.content.ContextCompat.getColor(context, R.color.success));
|
||||
holder.binding.textLock.setThumbTintList(android.content.res.ColorStateList.valueOf(androidx.core.content.ContextCompat.getColor(context, R.color.success)));
|
||||
holder.binding.textLock.setTrackTintList(android.content.res.ColorStateList.valueOf(androidx.core.graphics.ColorUtils.setAlphaComponent(androidx.core.content.ContextCompat.getColor(context, R.color.success), 77)));
|
||||
holder.binding.viewProductMask.setVisibility(View.GONE);
|
||||
}
|
||||
holder.binding.textProductName.setOnClickListener(new selectPositionOnClickListener(position));
|
||||
holder.binding.textCount.setOnClickListener(new textCountOnClickListener(holder.binding, slot.getCount()));
|
||||
holder.binding.textLock.setOnClickListener(new textLockOnClickListener(position));
|
||||
|
||||
String productImg = slot.getProduct().getProductImg();
|
||||
if (productImg != null && !productImg.isEmpty() && !productImg.equals("?")) {
|
||||
try {
|
||||
new ImageGlide(context).fileload(productImg, holder.binding.imageProduct);
|
||||
} catch (LogsEmptyException e) {
|
||||
holder.binding.imageProduct.setImageResource(R.drawable.slot_no_product);
|
||||
}
|
||||
} else {
|
||||
holder.binding.imageProduct.setImageResource(R.drawable.slot_no_product);
|
||||
}
|
||||
|
||||
holder.binding.buttonCountMinus.setOnClickListener(v -> {
|
||||
int current = slot.getCount();
|
||||
if (current > 0) {
|
||||
slot.setCount(current - 1);
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
});
|
||||
|
||||
holder.binding.buttonCountPlus.setOnClickListener(v -> {
|
||||
int current = slot.getCount();
|
||||
slot.setCount(current + 1);
|
||||
notifyItemChanged(position);
|
||||
});
|
||||
|
||||
holder.binding.imageProduct.setOnClickListener(new selectPositionOnClickListener(position));
|
||||
holder.binding.textProductName.setOnClickListener(null);
|
||||
holder.binding.textCount.setOnClickListener(new textCountOnClickListener(holder.binding, slot.getCount()));
|
||||
holder.binding.textDelete.setOnClickListener(new textDeleteOnClickListener(position));
|
||||
holder.binding.editCount.setOnEditorActionListener(new editCountOnEditorActionListener(holder.binding, position));
|
||||
holder.binding.editCount.setOnClickListener(new editCountOnClickListener(holder.binding, slot.getCount()));
|
||||
@ -163,7 +217,9 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
binding.textCount.setVisibility(View.GONE);
|
||||
// 改用 INVISIBLE 而非 GONE,避免 buttonCountPlus 的 constraint 因 textCount 折疊
|
||||
// 導致 editCount 可用寬度被壓縮成 0(看不到輸入框、IME 無法接受輸入)
|
||||
binding.textCount.setVisibility(View.INVISIBLE);
|
||||
binding.editCount.setVisibility(View.VISIBLE);
|
||||
if (this.count == 0) {
|
||||
binding.editCount.setText("");
|
||||
@ -240,6 +296,39 @@ public class RecyclerVmcSlotListAdpter extends RecyclerView.Adapter<RecyclerVmcS
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 開效期日期選擇器供補貨員現場輸入;提供「清除效期」表示無效期管控。
|
||||
* 僅改記憶體值(rev 由 VmcSlotListActivity 存檔時與 baseline 比對後統一遞增)。
|
||||
*/
|
||||
private void showExpiryPicker(int position) {
|
||||
final SlotStructure slot = slotList.get(position);
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
String cur = slot.getExpiryDate();
|
||||
try {
|
||||
if (cur != null && cur.length() >= 10) {
|
||||
cal.set(Integer.parseInt(cur.substring(0, 4)),
|
||||
Integer.parseInt(cur.substring(5, 7)) - 1,
|
||||
Integer.parseInt(cur.substring(8, 10)));
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(
|
||||
context,
|
||||
(view, year, month, day) -> {
|
||||
String date = String.format(java.util.Locale.US, "%04d-%02d-%02d", year, month + 1, day);
|
||||
slot.setExpiryDate(date);
|
||||
notifyItemChanged(position);
|
||||
},
|
||||
cal.get(java.util.Calendar.YEAR),
|
||||
cal.get(java.util.Calendar.MONTH),
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH));
|
||||
dialog.setButton(android.app.DatePickerDialog.BUTTON_NEUTRAL, "清除效期", (d, w) -> {
|
||||
slot.setExpiryDate("");
|
||||
notifyItemChanged(position);
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
public void delProductData(int position) {
|
||||
slotList.get(position).setProduct(new ProductStructure("?", "", "", 0, 0, 0, 0, "", "","",""));
|
||||
slotList.get(position).setCount(0);
|
||||
|
||||
@ -163,6 +163,11 @@ public class VmcFragment extends FragmentAbstract {
|
||||
binding.recyclerBuy.setAdapter(recyclerVmcBuyListAdpter);
|
||||
binding.recyclerBuy.setLayoutManager(new GridLayoutManager(getContext(), 3));
|
||||
|
||||
// pickup_sheet(原 Cmuh)模式:顯示大掃描單據按鈕、隱藏商品清單;其餘模式相反。
|
||||
boolean isPickupSheet = "pickup_sheet".equals(com.unibuy.smartdevice.MyApp.getInstance().getShoppingMode());
|
||||
binding.layoutScanBarcode.setVisibility(isPickupSheet ? View.VISIBLE : View.GONE);
|
||||
binding.recyclerBuy.setVisibility(isPickupSheet ? View.GONE : View.VISIBLE);
|
||||
|
||||
// for (int i = 0; i < MyApp.getInstance().getSlotList(SlotField.VMC.getField()).size(); i++) {
|
||||
// SlotStructure slot = MyApp.getInstance().getSlotList(SlotField.VMC.getField()).get(i);
|
||||
// if (!slot.getProduct().getProductID().isEmpty() && !slot.getProduct().getProductName().isEmpty() && slot.getProduct().getSellingPrice() > 0) {
|
||||
@ -262,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());
|
||||
|
||||
@ -9,7 +9,7 @@ import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultCallback;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
|
||||
import com.unibuy.smartdevice.AppCompatActivityAbstract;
|
||||
import com.unibuy.smartdevice.MyApp;
|
||||
@ -38,6 +38,9 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
private RecyclerVmcSlotListAdpter recyclerVmcSlotListAdpter;
|
||||
private HttpAPI httpAPI;
|
||||
private StarCloudAPI starCloudAPI;
|
||||
// rev 遞增 baseline:進畫面時快照各貨道 lock/效期,存檔時與此比對,「實際變更」才遞增對應 rev。
|
||||
private final Map<Integer, Boolean> baselineLock = new HashMap<>();
|
||||
private final Map<Integer, String> baselineExpiry = new HashMap<>();
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
@ -127,7 +130,8 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
|
||||
recyclerVmcSlotListAdpter = new RecyclerVmcSlotListAdpter(this, resultLauncher, MyApp.getInstance().getSlotList(SlotField.VMC.getField()));
|
||||
binding.recyclerSlotList.setAdapter(recyclerVmcSlotListAdpter);
|
||||
binding.recyclerSlotList.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.recyclerSlotList.setLayoutManager(new GridLayoutManager(this, 3));
|
||||
captureRevBaseline();
|
||||
|
||||
// dataShow();
|
||||
|
||||
@ -185,9 +189,11 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
|
||||
// 判斷有沒有設定商品
|
||||
if (product != null && !product.getProductID().isEmpty() && !product.getProductID().equals("?")) {
|
||||
int upperLimit = product.getSlotUpperLimit();
|
||||
slot.setCount(upperLimit); // 設定數量 = 貨道上限
|
||||
getLogs().info("補貨完成:貨道 " + slot.getSlot() + " → " + upperLimit);
|
||||
// 依貨道型式(履帶/彈簧)取對應補貨上限:彈簧用 spring_limit、履帶用 track_limit。
|
||||
boolean isSpring = MyApp.getInstance().getHardwareSetStructure().isSpringSlot(slot.getSlot());
|
||||
int upperLimit = isSpring ? product.getSpringLimit() : product.getTrackLimit();
|
||||
slot.setCount(upperLimit); // 設定數量 = 依型式取的貨道上限
|
||||
getLogs().info("補貨完成:貨道 " + slot.getSlot() + (isSpring ? "(彈簧)" : "(履帶)") + " → " + upperLimit);
|
||||
recyclerVmcSlotListAdpter.notifyItemChanged(i); // 更新 UI
|
||||
}
|
||||
}
|
||||
@ -214,11 +220,37 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
finish();
|
||||
}
|
||||
|
||||
/** 進畫面/存檔後快照各貨道 lock 與效期,作為下次存檔判斷「是否實際變更」的 baseline。 */
|
||||
private void captureRevBaseline() {
|
||||
baselineLock.clear();
|
||||
baselineExpiry.clear();
|
||||
for (SlotStructure s : MyApp.getInstance().getSlotList(SlotField.VMC.getField())) {
|
||||
baselineLock.put(s.getSlot(), s.isLock());
|
||||
baselineExpiry.put(s.getSlot(), s.getExpiryDate());
|
||||
}
|
||||
}
|
||||
|
||||
/** 存檔時:與 baseline 比對,鎖定或效期「實際變更」才遞增對應 rev(每次存檔至多 +1,避免重複切換虛增)。 */
|
||||
private void applyRevBumps() {
|
||||
for (SlotStructure s : MyApp.getInstance().getSlotList(SlotField.VMC.getField())) {
|
||||
Boolean bLock = baselineLock.get(s.getSlot());
|
||||
if (bLock != null && bLock != s.isLock()) {
|
||||
s.setLockRev(Math.max(s.getLockRev(), s.getLockSyncedRev()) + 1);
|
||||
}
|
||||
String bExp = baselineExpiry.get(s.getSlot());
|
||||
String curExp = s.getExpiryDate();
|
||||
if (bExp != null && !bExp.equals(curExp)) {
|
||||
s.setExpiryRev(Math.max(s.getExpiryRev(), s.getExpirySyncedRev()) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveData() {
|
||||
applyRevBumps();
|
||||
SlotsDao slotsDao = new SlotsDao(this);
|
||||
slotsDao.deleteAll();
|
||||
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
|
||||
slotsDao.replaceAll(MyApp.getInstance().getSlotMap()); // 單一交易清空重建,避免與背景下行寫入 race / 讀到空表
|
||||
slotsDao.close();
|
||||
captureRevBaseline(); // 存檔後重設 baseline,避免同次連續存檔重複遞增 rev
|
||||
getLogs().info("save count:"+ MyApp.getInstance().getSlotList(SlotField.VMC.getField()).size());
|
||||
}
|
||||
|
||||
@ -281,6 +313,9 @@ public class VmcSlotListActivity extends AppCompatActivityAbstract {
|
||||
}
|
||||
|
||||
private String validateMaterialCodeConsistency() {
|
||||
if (!com.unibuy.smartdevice.AppEntry.isCmuh()) {
|
||||
return "";
|
||||
}
|
||||
for (SlotStructure slot : MyApp.getInstance().getSlotList(SlotField.VMC.getField())) {
|
||||
if (slot == null || slot.getProduct() == null) continue;
|
||||
ProductStructure product = slot.getProduct();
|
||||
|
||||
@ -10,6 +10,7 @@ import com.unibuy.smartdevice.databinding.DialogBuyBinding;
|
||||
import com.unibuy.smartdevice.devices.SlotField;
|
||||
import com.unibuy.smartdevice.exception.LogsEmptyException;
|
||||
import com.unibuy.smartdevice.structure.BuyStructure;
|
||||
import com.unibuy.smartdevice.structure.DevSetStructure;
|
||||
import com.unibuy.smartdevice.structure.ReportFreeGiftCodeStructure;
|
||||
import com.unibuy.smartdevice.structure.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMainCountdown;
|
||||
@ -17,7 +18,6 @@ import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.tools.Tools;
|
||||
import com.unibuy.smartdevice.ui.FontendActivity;
|
||||
import com.unibuy.smartdevice.ui.recycler.RecyclerDialogBuyListAdpter;
|
||||
import com.unibuy.smartdevice.utils.LanguageHelper;
|
||||
|
||||
import java.util.HashMap;
|
||||
@ -83,21 +83,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case SET_PRICE:
|
||||
String status = "";
|
||||
if (!MyApp.getInstance().getReportFreeGiftCodeStructure().getFreeGiftId().isEmpty()) {
|
||||
switch (MyApp.getInstance().getReportFreeGiftCodeStructure().getMode()) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
status = "("+context.getString(R.string.discounted)+MyApp.getInstance().getReportFreeGiftCodeStructure().getDiscountAmount()+")";
|
||||
break;
|
||||
case 2:
|
||||
status = "("+context.getString(R.string.discounted1)+MyApp.getInstance().getReportFreeGiftCodeStructure().getDiscountPercentage()+")";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
binding.textPrice.setText(getTotalPrice()+status);
|
||||
refreshAmountBar();
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM:
|
||||
@ -117,7 +103,6 @@ public class BuyDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
private DialogBuyBinding binding;
|
||||
private RecyclerDialogBuyListAdpter recyclerDialogBuyListAdpter;
|
||||
private CloseDialogOnThreadHandler closeDialogCountdown;
|
||||
|
||||
public BuyDialog(Context context, HandlerMain srcHandlerMain) {
|
||||
@ -129,38 +114,24 @@ public class BuyDialog extends DialogAbstract {
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogBuyBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// 後台「UI元素設定」支付方式按鈕圖(P1~P6),未設定則保留各自預設圖
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P1", binding.imgPayCredit); // 信用卡
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P2", binding.imgPaySmartCard); // 電子票證/卡片
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P3", binding.imgPayEsun); // 玉山掃碼
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P4", binding.imgPayMobile); // 手機支付
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P5", binding.imgPayCash); // 現金
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("P6", binding.imgPayTapPay); // TapPay 掃碼
|
||||
|
||||
// 讀取最後設定語言的設定
|
||||
// LanguageHelper.applySavedLanguage(context);
|
||||
|
||||
this.recyclerDialogBuyListAdpter = new RecyclerDialogBuyListAdpter(getCtx(), MyApp.getInstance().getBuyList(), getHandlerMain());
|
||||
this.binding.recyclerDialogBuyList.setAdapter(recyclerDialogBuyListAdpter);
|
||||
this.binding.recyclerDialogBuyList.setLayoutManager(new LinearLayoutManager(context));
|
||||
|
||||
MyApp.getInstance().initialReportData();
|
||||
MyApp.getInstance().getReportFlowInfoData().setOrderId(Tools.generateTimeBasedUUID());
|
||||
MyApp.getInstance().getPostInvoiceByGreenData().setOrderId(MyApp.getInstance().getReportFlowInfoData().getOrderId());
|
||||
|
||||
this.binding.layoutPaymentTransactions.setVisibility(View.VISIBLE);
|
||||
this.binding.layoutVerificationPickup.setVisibility(View.GONE);
|
||||
this.binding.layoutMemberTransactions.setVisibility(View.GONE);
|
||||
|
||||
this.binding.buttonPaymentTransactions.setOnClickListener(view -> {
|
||||
this.binding.layoutPaymentTransactions.setVisibility(View.VISIBLE);
|
||||
this.binding.layoutVerificationPickup.setVisibility(View.GONE);
|
||||
this.binding.layoutMemberTransactions.setVisibility(View.GONE);
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
this.binding.buttonVerificationPickup.setOnClickListener(view -> {
|
||||
this.binding.layoutPaymentTransactions.setVisibility(View.GONE);
|
||||
this.binding.layoutVerificationPickup.setVisibility(View.VISIBLE);
|
||||
this.binding.layoutMemberTransactions.setVisibility(View.GONE);
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
this.binding.buttonMemberTransactions.setOnClickListener(view -> {
|
||||
|
||||
});
|
||||
|
||||
this.binding.buttonNfcCard.setOnClickListener(v -> {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(1);
|
||||
@ -188,7 +159,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();
|
||||
});
|
||||
|
||||
@ -207,171 +178,222 @@ public class BuyDialog extends DialogAbstract {
|
||||
gotoDialog();
|
||||
});
|
||||
|
||||
this.binding.buttonAddProduct.setOnClickListener(v -> gotoAddProduct());
|
||||
// 來店禮入口:獨立判斷,由雲端 B014 FunctionSet.WelcomeGift 下發控制(DevSet.isWelcomeGift)。
|
||||
// 與取貨模組無關;關閉時隱藏「來店禮」按鈕,避免進入 flowType 7。
|
||||
// 防呆:本筆已套用過來店禮(freeGiftId 非空)即隱藏入口,禁止重複掃描覆蓋/疊加折扣。
|
||||
boolean welcomeGiftApplied =
|
||||
!MyApp.getInstance().getReportFreeGiftCodeStructure().getFreeGiftId().isEmpty();
|
||||
this.binding.buttonFreeGift.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().isWelcomeGift() && !welcomeGiftApplied)
|
||||
? View.VISIBLE : View.GONE);
|
||||
|
||||
// 通行碼入口(付款格內 buttonAccessCode,flowType 5):權威判斷,涵蓋購物車/單品所有模式。
|
||||
// 從屬取貨模組,PickupModule 或 PassCode 任一關閉即隱藏;購物車模式下方不會再覆寫此值。
|
||||
this.binding.buttonAccessCode.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().isPickupModule()
|
||||
&& MyApp.getInstance().getDevSet().isPassCode()) ? View.VISIBLE : View.GONE);
|
||||
|
||||
this.binding.buttonCancel.setOnClickListener(v -> dialogCancel());
|
||||
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
String status = "";
|
||||
if (!MyApp.getInstance().getReportFreeGiftCodeStructure().getFreeGiftId().isEmpty()) {
|
||||
switch (MyApp.getInstance().getReportFreeGiftCodeStructure().getMode()) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
status = "("+context.getString(R.string.discounted)+MyApp.getInstance().getReportFreeGiftCodeStructure().getDiscountAmount()+")";
|
||||
break;
|
||||
case 2:
|
||||
status = "("+context.getString(R.string.discounted1)+MyApp.getInstance().getReportFreeGiftCodeStructure().getDiscountPercentage()+")";
|
||||
break;
|
||||
}
|
||||
}
|
||||
binding.textPrice.setText(getTotalPrice()+status);
|
||||
refreshAmountBar();
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
binding.buttonAddProduct.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonAddProduct.setVisibility(View.GONE);
|
||||
}
|
||||
// 刷卡機(母 isDevNFCPay)三子項:各自 母 && 子 判斷,母關則三者全隱藏。
|
||||
// 信用卡=NfcCard(canCreditCard)、電子票證/卡片=NfcRecharge(canCardPay)、手機=NfcPhone(canMobilePay)
|
||||
binding.buttonNfcCard.setVisibility(MyApp.getInstance().getDevSet().canCreditCard() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcRecharge.setVisibility(MyApp.getInstance().getDevSet().canCardPay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcPhone.setVisibility(MyApp.getInstance().getDevSet().canMobilePay() ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
if (MyApp.getInstance().getDevSet().isDevNFCPay()) {
|
||||
// binding.layoutNfcPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
// binding.layoutNfcPay.setVisibility(View.GONE);
|
||||
}
|
||||
// 掃碼(母 isDevScanPay)子項:玉山 / TapPay,母關則隱藏。
|
||||
binding.buttonEsunPay.setVisibility(MyApp.getInstance().getDevSet().canEsunPay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonTapPay.setVisibility(MyApp.getInstance().getDevSet().canTapPay() ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevEsunPay()) {
|
||||
binding.buttonEsunPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonEsunPay.setVisibility(View.GONE);
|
||||
}
|
||||
// Line 官方支付:獨立大項,不從屬掃碼。
|
||||
binding.buttonLinePay.setVisibility(MyApp.getInstance().getDevSet().canLinePay() ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevLinePay()) {
|
||||
binding.buttonLinePay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonLinePay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevCash()) {
|
||||
binding.buttonCash.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonCash.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevTapPay()) {
|
||||
binding.buttonTapPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonTapPay.setVisibility(View.GONE);
|
||||
}
|
||||
// 現金:獨立大項。
|
||||
binding.buttonCash.setVisibility(MyApp.getInstance().getDevSet().canCash() ? View.VISIBLE : View.GONE);
|
||||
} else {
|
||||
BuyStructure buyData = MyApp.getInstance().getBuyList().get(0);
|
||||
|
||||
try {
|
||||
SlotField slotField = SlotField.getSlotField(buyData.getField());
|
||||
// 貨道層級是否支援 NFC(VMC/ELECTRIC 不同);三個刷卡機子項共用這個貨道旗標。
|
||||
boolean slotNfc = slotField.isDevNFCPay();
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevNFCPay()) {
|
||||
if (slotField.isDevNFCPay()) {
|
||||
getLogs().info("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
|
||||
// binding.layoutNfcPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
// binding.layoutNfcPay.setVisibility(View.GONE);
|
||||
}
|
||||
// 刷卡機開啟:三子項各自 (母&&子) 且貨道支援 NFC 才顯示。
|
||||
binding.buttonNfcCard.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canCreditCard() && slotNfc) ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcRecharge.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canCardPay() && slotNfc) ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcPhone.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canMobilePay() && slotNfc) ? View.VISIBLE : View.GONE);
|
||||
} else {
|
||||
getLogs().info("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2");
|
||||
binding.buttonPaymentTransactions.setVisibility(View.GONE);
|
||||
// 刷卡機關閉:三子項全隱藏,並啟用通行碼備援入口(仍須從屬取貨模組且通行碼開啟,
|
||||
// 否則 NFC 不支援時會把已關閉的通行碼漏出來)。
|
||||
binding.buttonNfcCard.setVisibility(View.GONE);
|
||||
binding.buttonNfcPhone.setVisibility(View.GONE);
|
||||
binding.buttonNfcRecharge.setVisibility(View.GONE);
|
||||
binding.buttonAccessCode.setVisibility(View.VISIBLE);
|
||||
binding.buttonVerificationPickup.setVisibility(View.GONE);
|
||||
binding.layoutVerificationPickup.setVisibility(View.VISIBLE);
|
||||
// binding.layoutNfcPay.setVisibility(View.GONE);
|
||||
boolean passCodeOn = MyApp.getInstance().getDevSet().isPickupModule()
|
||||
&& MyApp.getInstance().getDevSet().isPassCode();
|
||||
binding.buttonAccessCode.setVisibility(passCodeOn ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevEsunPay()) {
|
||||
if (slotField.isDevEsunpay()) {
|
||||
binding.buttonEsunPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonEsunPay.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.buttonEsunPay.setVisibility(View.GONE);
|
||||
}
|
||||
// 掃碼子項:母(isDevScanPay)&&子 且貨道支援。
|
||||
binding.buttonEsunPay.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canEsunPay() && slotField.isDevEsunpay()) ? View.VISIBLE : View.GONE);
|
||||
binding.buttonTapPay.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canTapPay() && slotField.isDevTapPay()) ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevLinePay()) {
|
||||
if (slotField.isDevLinePay()) {
|
||||
binding.buttonLinePay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonLinePay.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.buttonLinePay.setVisibility(View.GONE);
|
||||
}
|
||||
// Line 官方支付:獨立大項,僅貨道支援與自身開關。
|
||||
binding.buttonLinePay.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canLinePay() && slotField.isDevLinePay()) ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevTapPay()) {
|
||||
if (slotField.isDevTapPay()) {
|
||||
binding.buttonTapPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonTapPay.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.buttonTapPay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevCash()) {
|
||||
if (slotField.isDevCash()) {
|
||||
binding.buttonCash.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonCash.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.buttonCash.setVisibility(View.GONE);
|
||||
}
|
||||
// 現金:獨立大項。
|
||||
binding.buttonCash.setVisibility(
|
||||
(MyApp.getInstance().getDevSet().canCash() && slotField.isDevCash()) ? View.VISIBLE : View.GONE);
|
||||
|
||||
} catch (LogsEmptyException e) {
|
||||
if (MyApp.getInstance().getDevSet().isDevNFCPay()) {
|
||||
// binding.layoutNfcPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
// binding.layoutNfcPay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevEsunPay()) {
|
||||
binding.buttonEsunPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonEsunPay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevLinePay()) {
|
||||
binding.buttonLinePay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonLinePay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevTapPay()) {
|
||||
binding.buttonTapPay.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonTapPay.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (MyApp.getInstance().getDevSet().isDevCash()) {
|
||||
binding.buttonCash.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.buttonCash.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// 無貨道資訊:略過貨道層級限制,只依「母&&子」開關判斷。
|
||||
binding.buttonNfcCard.setVisibility(MyApp.getInstance().getDevSet().canCreditCard() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcRecharge.setVisibility(MyApp.getInstance().getDevSet().canCardPay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonNfcPhone.setVisibility(MyApp.getInstance().getDevSet().canMobilePay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonEsunPay.setVisibility(MyApp.getInstance().getDevSet().canEsunPay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonTapPay.setVisibility(MyApp.getInstance().getDevSet().canTapPay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonLinePay.setVisibility(MyApp.getInstance().getDevSet().canLinePay() ? View.VISIBLE : View.GONE);
|
||||
binding.buttonCash.setVisibility(MyApp.getInstance().getDevSet().canCash() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
// 準備付款時檢查取貨口(basic 模式):取貨口有東西(0x05)/門沒關(0x06、0x09) 就提示並關閉付款頁回銷售頁。
|
||||
// 員工卡(點擊已自檢)、取貨碼(自有檢查)、pickup_sheet 不在此檢查。
|
||||
// 直購路徑已在「點選商品」當下檢查過取貨口(BasicSaleFlow.checkPickupOnly)→ 旗標仍新鮮時略過重驗,
|
||||
// 只有其他入口(購物車結帳、發票對話框重開等未預檢者)才在此做取貨口檢查。(比照 LockBoard-Honlifeng)
|
||||
if ("basic".equals(MyApp.getInstance().getShoppingMode())
|
||||
&& !com.unibuy.smartdevice.ui.BasicSaleFlow.consumeFreshPickupPreCheck()) {
|
||||
checkPickupPortBeforePay();
|
||||
}
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().isEmpty()) {
|
||||
return totalPrice;
|
||||
// 準備付款時檢查取貨口(basic 模式)。送 checkSlot 後 800ms 讀回;此時距實際出貨還有數秒
|
||||
// (使用者尚未選付款),不會與出貨流程搶同一條 DevXinYuanController。機器無回應(3000ms 超時)視為正常放行。
|
||||
private void checkPickupPortBeforePay() {
|
||||
java.util.concurrent.atomic.AtomicBoolean isHandled = new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(
|
||||
() -> isHandled.getAndSet(true), 3000);
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(
|
||||
MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlotPre(1, 1));
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(
|
||||
MyApp.getInstance().getDevXinYuanController().getDevXinYuan().checkSlot(1));
|
||||
MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(
|
||||
new DevCheckPickupByVMC(getHandlerMain(), isHandled), 800);
|
||||
}
|
||||
|
||||
private class DevCheckPickupByVMC extends com.unibuy.smartdevice.controller.DevController.ReadBufferOnScheduler {
|
||||
private final java.util.concurrent.atomic.AtomicBoolean isHandled;
|
||||
private final HandlerMain dialogHandler;
|
||||
|
||||
DevCheckPickupByVMC(HandlerMain handlerMain, java.util.concurrent.atomic.AtomicBoolean isHandled) {
|
||||
super(handlerMain);
|
||||
this.dialogHandler = handlerMain;
|
||||
this.isHandled = isHandled;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return dialogHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readBufferOnScheduler(com.unibuy.smartdevice.controller.DevController devController, HandlerMain handlerMain) {
|
||||
boolean haveThings = false;
|
||||
boolean doorError = false;
|
||||
for (byte[] buffer : devController.getReadBufferByMax()) {
|
||||
if (buffer[0] == 0x02) {
|
||||
if (buffer[1] == 0x05) {
|
||||
haveThings = true;
|
||||
break;
|
||||
} else if (buffer[1] == 0x06 || buffer[1] == 0x09) {
|
||||
doorError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 後備判斷(移植自 LockBoard-Honlifeng):主動查詢的 800ms 讀窗常錯過下位機自發廣播,
|
||||
// 或緩衝被清/狂送非標準幀導致讀不到乾淨回應。退回讀 controller 背景快取的 VMC_02 廣播
|
||||
// (有東西時約每 4 秒一次,取 4000ms 內),避免「取貨口有東西」漏判。
|
||||
if (!haveThings && !doorError) {
|
||||
int cached = MyApp.getInstance().getDevXinYuanController().getRecentSlotStatus(4000);
|
||||
if (cached == 0x05) {
|
||||
haveThings = true;
|
||||
} else if (cached == 0x06 || cached == 0x09) {
|
||||
doorError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 狀態安全:放行付款
|
||||
if (!haveThings && !doorError) return;
|
||||
// 已被超時或前一筆處理過,避免重複跳窗
|
||||
if (isHandled.getAndSet(true)) return;
|
||||
|
||||
final String message = doorError
|
||||
? BuyDialog.this.getContext().getString(R.string.pickup_door_not_closed)
|
||||
: BuyDialog.this.getContext().getString(R.string.there_is_something_at_the_pickup_port);
|
||||
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||
android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(BuyDialog.this.getContext())
|
||||
.setTitle(R.string.abnormal_reminder)
|
||||
.setMessage(message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.i_knew, (d, which) -> {
|
||||
d.dismiss();
|
||||
// 關閉付款頁回銷售頁
|
||||
dialogHandler.start("BuyDialogPickupCheck",
|
||||
Option.FORCE_CLOSE.getOption(), "pickup port blocked");
|
||||
})
|
||||
.create();
|
||||
dialog.show();
|
||||
android.widget.TextView mv = dialog.findViewById(android.R.id.message);
|
||||
if (mv != null) {
|
||||
mv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 24);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品原始總額(未套用來店禮折扣),不含免費商品。 */
|
||||
public int getOriginalPrice() {
|
||||
int totalPrice = 0;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
if (!buy.isFreeGift()) totalPrice += buy.getFullPrice();
|
||||
}
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
/** 商品總件數(不含免費商品),用於支付頁顯示「共 X 件」。 */
|
||||
public int getTotalCount() {
|
||||
int count = 0;
|
||||
for(BuyStructure buy: MyApp.getInstance().getBuyList()) {
|
||||
if (!buy.isFreeGift()) count += buy.getCount();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
if (MyApp.getInstance().getBuyList().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalPrice = getOriginalPrice();
|
||||
if (!MyApp.getInstance().getReportFreeGiftCodeStructure().getFreeGiftId().isEmpty()) {
|
||||
switch (MyApp.getInstance().getReportFreeGiftCodeStructure().getMode()) {
|
||||
case 0:
|
||||
@ -384,55 +406,86 @@ public class BuyDialog extends DialogAbstract {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (totalPrice == 0) totalPrice = 1;
|
||||
if (totalPrice < 1) totalPrice = 1; // 折抵後至少 1 元(避免 0 元交易)
|
||||
return totalPrice;
|
||||
}
|
||||
|
||||
public void gotoAddProduct() {
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.ADD_PRODUCT.getOption(), "@請40秒內完成加購動作");
|
||||
closeDialogCountdown.shutdown();
|
||||
cancel();
|
||||
/**
|
||||
* 刷新支付頁上方「件數 / 應付金額」列。
|
||||
* 金額已含來店禮折抵;有套用來店禮時於後方標示實際折抵金額(折抵/打折統一以省下的錢顯示)。
|
||||
*/
|
||||
private void refreshAmountBar() {
|
||||
binding.textCount.setText(getContext().getString(R.string.pay_item_count, getTotalCount()));
|
||||
|
||||
int payable = getTotalPrice();
|
||||
String priceText = "NT$" + payable;
|
||||
android.text.SpannableStringBuilder spannable = new android.text.SpannableStringBuilder(priceText);
|
||||
spannable.setSpan(
|
||||
new android.text.style.ForegroundColorSpan(androidx.core.content.ContextCompat.getColor(getContext(), R.color.primary)),
|
||||
0,
|
||||
priceText.length(),
|
||||
android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
);
|
||||
|
||||
if (!MyApp.getInstance().getReportFreeGiftCodeStructure().getFreeGiftId().isEmpty()) {
|
||||
int saved = getOriginalPrice() - payable;
|
||||
if (saved > 0) {
|
||||
String discountPart = " (" + getContext().getString(R.string.pay_discounted_format, saved) + ")";
|
||||
int start = spannable.length();
|
||||
spannable.append(discountPart);
|
||||
spannable.setSpan(
|
||||
new android.text.style.ForegroundColorSpan(androidx.core.content.ContextCompat.getColor(getContext(), R.color.success)),
|
||||
start,
|
||||
spannable.length(),
|
||||
android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
);
|
||||
}
|
||||
}
|
||||
binding.textPrice.setText(spannable);
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
MyApp.getInstance().setReportFreeGiftCodeStructure(new ReportFreeGiftCodeStructure("", 0, "", 0, 0, ""));
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.STOP_COUNTDOWN.getOption(), "");
|
||||
closeDialogCountdown.shutdown();
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void cancelOnThreadCountdown() {
|
||||
MyApp.getInstance().setReportFreeGiftCodeStructure(new ReportFreeGiftCodeStructure("", 0, "", 0, 0, ""));
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), FontendActivity.Option.STOP_COUNTDOWN.getOption(), "");
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
if (buyListSize > 0) {
|
||||
recyclerDialogBuyListAdpter.notifyItemRangeRemoved(0, buyListSize);
|
||||
}
|
||||
getTools().dialogClose();
|
||||
cancel();
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText(getContext().getString(R.string.cancel)+"("+countdown+")");//取消
|
||||
binding.buttonCancel.setText(getContext().getString(R.string.cancel) + "(" + countdown + "秒)");//取消
|
||||
}
|
||||
|
||||
public void gotoDialog() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
getLogs().info("aaaaaaaaaaaaaaaaaaaaaaaa"+flowType);
|
||||
getLogs().debug("buy flowType:" + flowType);
|
||||
// 非現金付款(flowType != 9):此刻機台剛進收款模式、還在 POLL,立即送 inComeByIPC 給足額 credit
|
||||
// (沿用開機已驗證的 0x08/9999),讓「收款式」下位機結束收款、後續正常出貨;
|
||||
// 否則機台會卡在等錢(狂送 VMC_29 金錢開關)、不送 POLL → checkSlot 送不出去 → 出貨失敗。
|
||||
// 現金(9)不送:由現金流程依實收金額自行送 inComeByIPC,避免預付 credit 造成免費出貨。
|
||||
if (flowType != 9) {
|
||||
try {
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(
|
||||
MyApp.getInstance().getDevXinYuanController().getDevXinYuan().inComeByIPC((byte) 0x08, 9999));
|
||||
getLogs().info("非現金付款(flowType=" + flowType + ") → 送 inComeByIPC 給下位機收款 credit");
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
// if (flowType == 5 || flowType == 7 || flowType == 30) {
|
||||
if (flowType == 5 || flowType == 7) {
|
||||
changeCheckout();
|
||||
} else {
|
||||
if (MyApp.getInstance().getDevSet().isShoppingCar()) {
|
||||
if (MyApp.getInstance().getDevSet().isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
changeDialog(InvoiceTypeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
@ -442,12 +495,12 @@ public class BuyDialog extends DialogAbstract {
|
||||
try {
|
||||
SlotField slotField = SlotField.getSlotField(buyData.getField());
|
||||
if (slotField.isInvoice()) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
changeDialog(InvoiceTypeDialog.class);
|
||||
} else {
|
||||
changeCheckout();
|
||||
}
|
||||
} catch (LogsEmptyException e) {
|
||||
changeDialog(InvoiceBarcodeDialog.class);
|
||||
changeDialog(InvoiceTypeDialog.class);
|
||||
}
|
||||
} else {
|
||||
changeCheckout();
|
||||
@ -459,6 +512,28 @@ public class BuyDialog extends DialogAbstract {
|
||||
public void changeCheckout() {
|
||||
closeDialogCountdown.shutdown();
|
||||
int flowType = MyApp.getInstance().getReportFlowInfoData().getFlowType();
|
||||
// 入口防呆:即使按鈕被繞過(殘留事件/外部觸發),進入支付 dialog 前再以同一份「母&&子」
|
||||
// 開關權威判斷一次;母開關或子項任一關閉即擋下,避免已停用的支付方式漏出。
|
||||
if (!isPaymentFlowAllowed(flowType)) {
|
||||
getLogs().info("⛔ 支付方式已停用,封堵入口 flowType=" + flowType);
|
||||
dialogCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// 進入付款:先清掉上一筆殘留的 MQTT 交易流水號,避免被本筆誤用(flow_id 撞單)。
|
||||
com.unibuy.smartdevice.structure.ReportFlowInfoStructure flowInfo =
|
||||
MyApp.getInstance().getReportFlowInfoData();
|
||||
flowInfo.setMqttFlowId("");
|
||||
// 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 == 10
|
||||
|| flowType == 9 || flowType == 30 || flowType == 70);
|
||||
if ("basic".equals(MyApp.getInstance().getShoppingMode()) && realPayment) {
|
||||
flowInfo.setMqttFlowId(com.unibuy.smartdevice.tools.FlowIdGenerator.next(MyApp.getInstance()));
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishPending();
|
||||
}
|
||||
|
||||
switch (flowType) {
|
||||
case 1:
|
||||
changeDialog(CheckoutNfcCardDialog.class);
|
||||
@ -469,7 +544,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
@ -479,7 +554,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
changeDialog(CheckoutFreeGiftCodeDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
changeDialog(CashPayDialog.class);
|
||||
changeDialog(cashDialogClass());
|
||||
break;
|
||||
case 30:
|
||||
changeDialog(CheckoutTapPayDialog.class);
|
||||
@ -490,6 +565,24 @@ public class BuyDialog extends DialogAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口防呆:依 flowType 對應的支付方式,判斷其「母&&子」開關是否允許。
|
||||
* flowType 5(通行碼)、7(來店禮)非支付方式,由取貨模組/來店禮各自把關,此處放行。
|
||||
*/
|
||||
private boolean isPaymentFlowAllowed(int flowType) {
|
||||
DevSetStructure d = MyApp.getInstance().getDevSet();
|
||||
switch (flowType) {
|
||||
case 1: return d.canCreditCard(); // 信用卡(刷卡機)
|
||||
case 2: return d.canCardPay(); // 電子票證/卡片支付(刷卡機)
|
||||
case 10: return d.canMobilePay(); // 手機支付(刷卡機)
|
||||
case 3: return d.canEsunPay(); // 玉山(掃碼)
|
||||
case 30: return d.canTapPay(); // TapPay(掃碼)
|
||||
case 70: return d.canLinePay(); // Line 官方支付(獨立)
|
||||
case 9: return d.canCash(); // 現金(獨立)
|
||||
default: return true; // 5/7 等非支付流程放行
|
||||
}
|
||||
}
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
public CloseDialogOnThreadHandler(HandlerMain handlerMain) {
|
||||
super(handlerMain);
|
||||
|
||||
@ -81,7 +81,6 @@ public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
setButtonCancelText(Integer.valueOf(message));
|
||||
break;
|
||||
case SET_PRICE:
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
break;
|
||||
case RESET_COUNTDOWM:
|
||||
@ -130,7 +129,6 @@ public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
closeDialogCountdown = new CloseDialogOnThreadHandler(getHandlerMain());
|
||||
closeDialogCountdown.start(40);
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
@ -220,14 +218,14 @@ public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
changeDialog(CheckoutAccessCodeDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
changeDialog(CashPayDialog.class);
|
||||
changeDialog(cashDialogClass());
|
||||
break;
|
||||
case 70:
|
||||
changeDialog(CheckoutLinePayDialog.class);
|
||||
File diff suppressed because it is too large
Load Diff
@ -46,6 +46,7 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
CHANGE_DISPATCH(6),
|
||||
CHANGE_BUY(7),
|
||||
SET_STATUS_TEXT(8),
|
||||
SET_SCAN_INPUT(9),
|
||||
;
|
||||
|
||||
private int option;
|
||||
@ -113,6 +114,11 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
case SET_STATUS_TEXT:
|
||||
setStatusText(message);
|
||||
break;
|
||||
case SET_SCAN_INPUT:
|
||||
// 掃碼即時回饋:把掃到的內容顯示在輸入框,讓使用者確認有掃到
|
||||
binding.editTextNumber.setText(message);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,6 +144,18 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
openBarcode = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
|
||||
setInputMode(true);
|
||||
|
||||
binding.btnModeKeypad.setOnClickListener(v -> {
|
||||
setInputMode(true);
|
||||
if (closeDialogCountdown != null) closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.btnModeScan.setOnClickListener(v -> {
|
||||
setInputMode(false);
|
||||
if (closeDialogCountdown != null) closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.buttonFinish.setOnClickListener(v -> changeOk());
|
||||
binding.buttonCancel.setOnClickListener(v -> changeBuy());
|
||||
|
||||
@ -241,7 +259,8 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(5, TimeUnit.SECONDS);
|
||||
// 立即開始聽掃描器(原本延遲 5 秒,導致對話框剛彈出時掃碼被整個丟掉 → 時好時壞、無回饋)
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(0, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public class ReadBarCodeOnScheduler extends HandlerMainScheduler {
|
||||
@ -265,19 +284,14 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100);
|
||||
for (int i=0; i<7; i++) {
|
||||
for (int i=0; i<9; i++) { // 約 45 秒讀取窗口,覆蓋 40 秒對話框存活時間
|
||||
if (isRun() && openBarcode) {
|
||||
int size = usbDev.read(buffer, 5000);
|
||||
if (size > 5) {
|
||||
String barCodeString = new String(buffer, 0, size);
|
||||
getHandlerMain().start(getLogs().getClassName(), getContext().getString(R.string.receive_barcode)+":"+ Tools.randomReplace(barCodeString, 0.3)+","+getContext().getString(R.string.send_transaction));//收到條碼,送出交易
|
||||
|
||||
try {
|
||||
MyApp.getInstance().getAccessCodeStructure().setPassCode(barCodeString);
|
||||
httpAPI.checkAccessCode(MyApp.getInstance().getAccessCodeStructure());
|
||||
} catch (LogsParseException | LogsSettingEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
String barCodeString = new String(buffer, 0, size).trim();
|
||||
// 掃碼即時回饋:先把掃到的內容顯示在輸入框,再送出驗證
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_SCAN_INPUT.getOption(), barCodeString);
|
||||
submitPassCode(barCodeString);
|
||||
|
||||
openBarcode = false;
|
||||
}
|
||||
@ -295,6 +309,14 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
private void setInputMode(boolean isKeyboard) {
|
||||
binding.btnModeKeypad.setSelected(isKeyboard);
|
||||
binding.btnModeScan.setSelected(!isKeyboard);
|
||||
|
||||
binding.linearNumpad.setVisibility(isKeyboard ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||
binding.imageViewScanHint.setVisibility(isKeyboard ? android.view.View.GONE : android.view.View.VISIBLE);
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
@ -307,26 +329,70 @@ public class CheckoutAccessCodeDialog extends DialogAbstract {
|
||||
public void changeDispatch() {
|
||||
if (isChangeDispatch) {
|
||||
closeDialogCountdown.shutdown();
|
||||
// 進入出貨前,務必關閉 FontendActivity 的「加購倒數」(AddProductOnCountdown)。
|
||||
// 否則該 40 秒倒數會在出貨中途到期 → 自動發 BUY_DIALOG_OPEN → 支付畫面疊在出貨畫面上跳出。
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(),
|
||||
FontendActivity.Option.STOP_COUNTDOWN.getOption(), "");
|
||||
isChangeDispatch = false;
|
||||
changeDialog(DispatchDialog.class);
|
||||
}
|
||||
}
|
||||
|
||||
private long lastChangeOkClickTime = 0;
|
||||
|
||||
public void changeOk() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
// 長度檢查
|
||||
if (number.length() < 8) {
|
||||
getHandlerMain().start(getClass().getSimpleName(), "統一編號長度不能少於 8 碼");
|
||||
// 防連點:3 秒內重複點擊忽略(自 Luzui 移植)
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastChangeOkClickTime < 3000) {
|
||||
return;
|
||||
}
|
||||
MyApp.getInstance().getAccessCodeStructure().setPassCode(number);
|
||||
httpAPI.checkAccessCode(MyApp.getInstance().getAccessCodeStructure());
|
||||
} catch (LogsParseException | LogsSettingEmptyException e) {
|
||||
lastChangeOkClickTime = now;
|
||||
|
||||
String number = binding.editTextNumber.getText().toString().trim();
|
||||
// 長度檢查
|
||||
if (number.isEmpty() || number.length() < 8) {
|
||||
getHandlerMain().start(getClass().getSimpleName(), getCtx().getString(R.string.passcode_min_8));
|
||||
return;
|
||||
}
|
||||
submitPassCode(number);
|
||||
} catch (Exception e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通行碼驗證改走 StarCloudAPI B670(邏輯比照員工卡 B680)。
|
||||
* 成功後保存 code_id 供 MQTT finalize(payment_type=5 通行碼),再進入出貨。
|
||||
*/
|
||||
private void submitPassCode(String code) {
|
||||
// 防護:通行碼從屬「取貨模組」,任一關閉即不開放呼叫 B670(即使 UI 漏出入口也擋下)
|
||||
if (!(MyApp.getInstance().getDevSet().isPickupModule() && MyApp.getInstance().getDevSet().isPassCode())) {
|
||||
openBarcode = true;
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getCtx().getString(R.string.passcode_not_enabled));
|
||||
return;
|
||||
}
|
||||
// 送出前顯示「驗證中…」,避免使用者以為沒反應
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getCtx().getString(R.string.verifying));
|
||||
new com.unibuy.smartdevice.external.StarCloudAPI(getHandlerMain(), getHandlerMain())
|
||||
.verifyPassCode(code, new com.unibuy.smartdevice.external.StarCloudAPI.VerificationCallback() {
|
||||
@Override
|
||||
public void onSuccess(String codeId) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(5); // 通行碼 → payment_type 5
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowBarCode(code);
|
||||
MyApp.getInstance().getReportFlowInfoData().setReportFlowId(code);
|
||||
MyApp.getInstance().getReportAccessCodeStructure().setAccessCodeId(codeId);
|
||||
getHandlerMain().send(getClass().getSimpleName(), Option.CHANGE_DISPATCH.getOption(), "change dispatch");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String message) {
|
||||
openBarcode = true; // 允許重新掃碼/輸入
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void changeBuy() {
|
||||
closeDialogCountdown.shutdown();
|
||||
changeDialog(BuyDialog.class);
|
||||
|
||||
@ -637,7 +637,7 @@ public class CheckoutCashDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
binding.buttonCancel.setText(getCtx().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public static class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
|
||||
@ -100,25 +100,22 @@ public class CheckoutEsunPayDialog extends DialogAbstract {
|
||||
case TRANSACTION_SUCCESSFUL:
|
||||
if (isTransaction) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
// exception(context, e.getErrorCode(), e.getLocalizedMessage());
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
// payment_type=3(掃碼支付)已於 BuyDialog 設定。
|
||||
isTransaction = false;
|
||||
changeDispatch();
|
||||
}
|
||||
break;
|
||||
case TRANSACTION_FAILED:
|
||||
if (isTransaction) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
// exception(context, e.getErrorCode(), e.getLocalizedMessage());
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 交易失敗不再經 B600;直接提示並返回購買頁,不進出貨。
|
||||
// [交易生命週期] 掃碼支付失敗:上報 failed(flag 開才送),救回原本默默消失的失敗單。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed("scan_failed");
|
||||
isTransaction = false;
|
||||
start(getSrcClassName(), "@" + getContext().getString(R.string.transaction_failure));
|
||||
changeBuy();
|
||||
}
|
||||
break;
|
||||
case CHANGE_DISPATCH:
|
||||
@ -179,6 +176,11 @@ public class CheckoutEsunPayDialog extends DialogAbstract {
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogCheckoutEsunPayBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// M3 掃碼支付示意圖(上,此檔的 imageCheckout 才是示意圖)/ R3 實體示意圖(下,imgPayPhysical)
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("M3", binding.imageCheckout);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("R3", binding.imgPayPhysical);
|
||||
|
||||
openBarcode = true;
|
||||
isTransaction = true;
|
||||
isChangeDispatch = true;
|
||||
|
||||
@ -39,6 +39,7 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
CHANGE_DISPATCH(6),
|
||||
CHANGE_BUY(7),
|
||||
SET_STATUS_TEXT(8),
|
||||
SET_SCAN_INPUT(9),
|
||||
;
|
||||
|
||||
private int option;
|
||||
@ -106,6 +107,11 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
case SET_STATUS_TEXT:
|
||||
setStatusText(message);
|
||||
break;
|
||||
case SET_SCAN_INPUT:
|
||||
// 掃碼即時回饋:把掃到的內容顯示在輸入框,讓使用者確認有掃到
|
||||
binding.editTextNumber.setText(message);
|
||||
binding.editTextNumber.setSelection(binding.editTextNumber.getText().length());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -131,6 +137,18 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
openBarcode = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
|
||||
setInputMode(true);
|
||||
|
||||
binding.btnModeKeypad.setOnClickListener(v -> {
|
||||
setInputMode(true);
|
||||
if (closeDialogCountdown != null) closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.btnModeScan.setOnClickListener(v -> {
|
||||
setInputMode(false);
|
||||
if (closeDialogCountdown != null) closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
binding.buttonFinish.setOnClickListener(v -> changeOk());
|
||||
binding.buttonCancel.setOnClickListener(v -> changeBuy());
|
||||
|
||||
@ -232,7 +250,8 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
closeDialogCountdown.setCountdown(40);
|
||||
});
|
||||
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(5, TimeUnit.SECONDS);
|
||||
// 立即開始聽掃描器(原本延遲 5 秒,導致對話框剛彈出時掃碼被整個丟掉 → 時好時壞、無回饋)
|
||||
new ReadBarCodeOnScheduler(getHandlerMain()).start(0, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public class ReadBarCodeOnScheduler extends HandlerMainScheduler {
|
||||
@ -256,20 +275,14 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
UsbDev usbDev = new UsbDev(DeviceType.QRCODE_SCANNER);
|
||||
byte[] buffer = new byte[1000];
|
||||
usbDev.read(buffer, 100);
|
||||
for (int i=0; i<7; i++) {
|
||||
for (int i=0; i<9; i++) { // 約 45 秒讀取窗口,覆蓋 40 秒對話框存活時間
|
||||
if (isRun() && openBarcode) {
|
||||
int size = usbDev.read(buffer, 5000);
|
||||
if (size > 5) {
|
||||
String barCodeString = new String(buffer, 0, size);
|
||||
getHandlerMain().start(getLogs().getClassName(), getCtx().getString(R.string.receive_barcode)+":"+ Tools.randomReplace(barCodeString, 0.3)+","+getCtx().getString(R.string.send_transaction));//收到條碼,送出交易
|
||||
|
||||
try {
|
||||
MyApp.getInstance().getFreeGiftStructure().setPassCode(barCodeString);
|
||||
httpAPI.freeGiftCode(MyApp.getInstance().getFreeGiftStructure());
|
||||
} catch (LogsParseException | LogsSettingEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
|
||||
String barCodeString = new String(buffer, 0, size).trim();
|
||||
// 掃碼即時回饋:先把掃到的內容顯示在輸入框,再送出驗證
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_SCAN_INPUT.getOption(), barCodeString);
|
||||
submitWelcomeGift(barCodeString);
|
||||
openBarcode = false;
|
||||
}
|
||||
}
|
||||
@ -286,6 +299,14 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
private void setInputMode(boolean isKeyboard) {
|
||||
binding.btnModeKeypad.setSelected(isKeyboard);
|
||||
binding.btnModeScan.setSelected(!isKeyboard);
|
||||
|
||||
binding.linearNumpad.setVisibility(isKeyboard ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||
binding.imageViewScanHint.setVisibility(isKeyboard ? android.view.View.GONE : android.view.View.VISIBLE);
|
||||
}
|
||||
|
||||
public int getTotalPrice() {
|
||||
int totalPrice = 0;
|
||||
if (MyApp.getInstance().getBuyList().size() == 0) return totalPrice;
|
||||
@ -304,15 +325,57 @@ public class CheckoutFreeGiftCodeDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
public void changeOk() {
|
||||
try {
|
||||
String number = binding.editTextNumber.getText().toString();
|
||||
MyApp.getInstance().getFreeGiftStructure().setPassCode(number);
|
||||
httpAPI.freeGiftCode(MyApp.getInstance().getFreeGiftStructure());
|
||||
} catch (LogsParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (LogsSettingEmptyException e) {
|
||||
throw new RuntimeException(e);
|
||||
String number = binding.editTextNumber.getText().toString().trim();
|
||||
if (number.isEmpty() || number.length() < 8) {
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getCtx().getString(R.string.gift_code_min_8));
|
||||
return;
|
||||
}
|
||||
submitWelcomeGift(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 來店禮驗證改走 StarCloudAPI B690(取代舊 HttpAPI.freeGiftCode)。
|
||||
* B690 只回折扣(amount/percentage),無「免費商品」型;成功即套折扣並返回 BuyDialog 結帳。
|
||||
*/
|
||||
private void submitWelcomeGift(String code) {
|
||||
// 防護:來店禮關閉即不開放呼叫 B690(即使 UI 漏出入口也擋下)
|
||||
if (!MyApp.getInstance().getDevSet().isWelcomeGift()) {
|
||||
openBarcode = true;
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getCtx().getString(R.string.gift_not_enabled));
|
||||
return;
|
||||
}
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getCtx().getString(R.string.verifying));
|
||||
new com.unibuy.smartdevice.external.StarCloudAPI(getHandlerMain(), getHandlerMain())
|
||||
.verifyWelcomeGift(code, new com.unibuy.smartdevice.external.StarCloudAPI.WelcomeGiftCallback() {
|
||||
@Override
|
||||
public void onSuccess(String codeId, String discountType, int discountValue, String discountLabel, String name) {
|
||||
// 映射到既有折扣機制:mode 1=折抵金額、mode 2=折扣百分比(乘數)。
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure().setFreeGiftId(codeId);
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure().setPassCode(code);
|
||||
MyApp.getInstance().getReportFlowInfoData().setReportFlowId(code);
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowBarCode(code);
|
||||
if ("amount".equals(discountType)) {
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure().setMode(1);
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure().setDiscountAmount(discountValue);
|
||||
} else if ("percentage".equals(discountType)) {
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure().setMode(2);
|
||||
// discountValue 為折扣趴數(八折=20)→ 換算成總價乘數 0.8
|
||||
MyApp.getInstance().getReportFreeGiftCodeStructure()
|
||||
.setDiscountPercentage((100 - discountValue) / 100f);
|
||||
} else {
|
||||
openBarcode = true;
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), getContext().getString(R.string.unsupported_discount_type));
|
||||
return;
|
||||
}
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.CHANGE_BUY.getOption(), "change buy");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(String message) {
|
||||
openBarcode = true; // 允許重新掃碼/輸入
|
||||
getHandlerMain().start(getClass().getSimpleName(), Option.SET_STATUS_TEXT.getOption(), message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void changeBuy() {
|
||||
|
||||
@ -101,25 +101,22 @@ public class CheckoutLinePayDialog extends DialogAbstract {
|
||||
case TRANSACTION_SUCCESSFUL:
|
||||
if (isTransaction) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
// exception(context, e.getErrorCode(), e.getLocalizedMessage());
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
// payment_type=70(LINE Pay 官方)已於 BuyDialog 設定。
|
||||
isTransaction = false;
|
||||
changeDispatch();
|
||||
}
|
||||
break;
|
||||
case TRANSACTION_FAILED:
|
||||
if (isTransaction) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
// exception(context, e.getErrorCode(), e.getLocalizedMessage());
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 交易失敗不再經 B600;直接提示並返回購買頁,不進出貨。
|
||||
// [交易生命週期] LINE Pay 失敗:上報 failed(flag 開才送),救回原本默默消失的失敗單。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed("scan_failed");
|
||||
isTransaction = false;
|
||||
start(getSrcClassName(), "@" + getContext().getString(R.string.transaction_failure));
|
||||
changeBuy();
|
||||
}
|
||||
break;
|
||||
case CHANGE_DISPATCH:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
import com.unibuy.smartdevice.R;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
@ -109,18 +110,21 @@ public class CheckoutNfcCardDialog extends DialogAbstract {
|
||||
if (message.equals("0000")) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
// payment_type=1(信用卡)已於 BuyDialog 設定。
|
||||
changeDispatch();
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回非 0000:上報 failed(帶代碼),救回原本默默消失的失敗單(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回空電文:視為失敗,上報 failed(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
|
||||
// binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
@ -194,6 +198,10 @@ public class CheckoutNfcCardDialog extends DialogAbstract {
|
||||
binding = DialogCheckoutNfcCardBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// M1 信用卡支付示意圖(上)/ R1 實體示意圖(下,imageCheckout);未設定則保留預設圖
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("M1", binding.imgPayMethod);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("R1", binding.imageCheckout);
|
||||
|
||||
isResponse = true;
|
||||
isChangeDispatch = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
@ -206,7 +214,7 @@ public class CheckoutNfcCardDialog extends DialogAbstract {
|
||||
closeDialogCountdown.start(30);
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
binding.textStatus.setText("交易讀取中,請將卡片/手機停留片刻,\n勿快速移動,系統完成後會自動返回。");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.reading_transaction));
|
||||
|
||||
ProductStructure buyProduct = MyApp.getInstance().getBuyList().get(0).getProduct();
|
||||
MyApp.getInstance().getReportFlowInfoData().setProductId(buyProduct.getProductID());
|
||||
@ -256,7 +264,7 @@ public class CheckoutNfcCardDialog extends DialogAbstract {
|
||||
nfcPayController.start(1, getTotalPrice());
|
||||
} catch (LogsIOException e) {
|
||||
getLogs().warning(e);
|
||||
binding.textStatus.setText("刷卡機無法開啟");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.card_reader_cannot_open));
|
||||
getHandlerMain().getHandler().postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@ -345,7 +353,7 @@ public class CheckoutNfcCardDialog extends DialogAbstract {
|
||||
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
binding.buttonCancel.setText(getCtx().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
import com.unibuy.smartdevice.R;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
@ -108,18 +109,22 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
if (message.equals("0000")) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// NfcPhone(手機支付):flowType 自 BuyDialog 按鈕起即為 10(手機支付),
|
||||
// 成功/失敗/取消各出口全程一致上報,不再於此覆寫(4 已不再代表手機感應)。
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
changeDispatch();
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回非 0000:上報 failed(帶代碼),救回原本默默消失的失敗單(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回空電文:視為失敗,上報 failed(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
|
||||
// binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
@ -194,6 +199,10 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
binding = DialogCheckoutNfcPhoneBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// M2 手機支付示意圖(上)/ R2 實體示意圖(下,imageCheckout);未設定則保留預設圖
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("M2", binding.imgPayMethod);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("R2", binding.imageCheckout);
|
||||
|
||||
isResponse = true;
|
||||
isChangeDispatch = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
@ -209,7 +218,7 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
|
||||
binding.textStatus.setText("交易讀取中,請將卡片/手機停留片刻,\n勿快速移動,系統完成後會自動返回。");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.reading_transaction));
|
||||
|
||||
ProductStructure buyProduct = MyApp.getInstance().getBuyList().get(0).getProduct();
|
||||
MyApp.getInstance().getReportFlowInfoData().setProductId(buyProduct.getProductID());
|
||||
@ -259,7 +268,7 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
nfcPayController.start(4, getTotalPrice());
|
||||
} catch (LogsIOException e) {
|
||||
getLogs().warning(e);
|
||||
binding.textStatus.setText("刷卡機無法開啟");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.card_reader_cannot_open));
|
||||
getHandlerMain().getHandler().postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@ -348,7 +357,7 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
binding.buttonCancel.setText(getCtx().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
import com.unibuy.smartdevice.R;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
@ -108,18 +109,21 @@ public class CheckoutNfcRechargeDialog extends DialogAbstract {
|
||||
if (message.equals("0000")) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
// payment_type=2(悠遊卡/一卡通)已於 BuyDialog 設定。
|
||||
changeDispatch();
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回非 0000:上報 failed(帶代碼),救回原本默默消失的失敗單(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
} else {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// [交易生命週期] 刷卡機回空電文:視為失敗,上報 failed(flag 開才送)。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed(message);
|
||||
}
|
||||
|
||||
// binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
@ -194,6 +198,10 @@ public class CheckoutNfcRechargeDialog extends DialogAbstract {
|
||||
binding = DialogCheckoutNfcRechargeBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// M4 卡片/電子票證示意圖(上)/ R4 實體示意圖(下,imageCheckout);未設定則保留預設圖
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("M4", binding.imgPayMethod);
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("R4", binding.imageCheckout);
|
||||
|
||||
isResponse = true;
|
||||
isChangeDispatch = true;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
@ -207,7 +215,7 @@ public class CheckoutNfcRechargeDialog extends DialogAbstract {
|
||||
|
||||
binding.textPrice.setText(String.valueOf(getTotalPrice()));
|
||||
|
||||
binding.textStatus.setText("交易讀取中,請將卡片/手機停留片刻,\n勿快速移動,系統完成後會自動返回。");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.reading_transaction));
|
||||
|
||||
ProductStructure buyProduct = MyApp.getInstance().getBuyList().get(0).getProduct();
|
||||
MyApp.getInstance().getReportFlowInfoData().setProductId(buyProduct.getProductID());
|
||||
@ -257,7 +265,7 @@ public class CheckoutNfcRechargeDialog extends DialogAbstract {
|
||||
nfcPayController.start(2, getTotalPrice());
|
||||
} catch (LogsIOException e) {
|
||||
getLogs().warning(e);
|
||||
binding.textStatus.setText("刷卡機無法開啟");
|
||||
binding.textStatus.setText(getCtx().getString(R.string.card_reader_cannot_open));
|
||||
getHandlerMain().getHandler().postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@ -346,7 +354,7 @@ public class CheckoutNfcRechargeDialog extends DialogAbstract {
|
||||
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
binding.buttonCancel.setText(getCtx().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public class CloseDialogOnThreadHandler extends HandlerMainCountdown {
|
||||
|
||||
@ -123,22 +123,21 @@ public class CheckoutTapPayDialog extends DialogAbstract {
|
||||
if (isTransaction) {
|
||||
isTransaction = false; // 🚨 馬上關閉,避免送出第二筆
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
// payment_type=30~34(TapPay 子項)已於本 Dialog 選擇時設定。
|
||||
changeDispatch();
|
||||
}
|
||||
break;
|
||||
case TRANSACTION_FAILED:
|
||||
if (isTransaction) {
|
||||
isTransaction = false; // 🚨 馬上關閉
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(0);
|
||||
try {
|
||||
httpAPI.reportFlowInfo(MyApp.getInstance().getReportFlowInfoData());
|
||||
} catch (LogsSettingEmptyException | LogsParseException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// [MQTT Finalize 重構] 交易失敗不再經 B600;直接提示並返回購買頁,不進出貨。
|
||||
// [交易生命週期] 掃碼支付失敗:上報 failed(flag 開才送),救回原本默默消失的失敗單。
|
||||
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed("scan_failed");
|
||||
start(getSrcClassName(), "@" + getContext().getString(R.string.transaction_failure));
|
||||
changeBuy();
|
||||
}
|
||||
break;
|
||||
case TRANSACTION_AGAIN:
|
||||
@ -595,7 +594,7 @@ public class CheckoutTapPayDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
public void setButtonCancelText(long countdown) {
|
||||
binding.buttonCancel.setText("取消("+countdown+")");
|
||||
binding.buttonCancel.setText(getCtx().getString(R.string.cancel)+"("+countdown+")");
|
||||
}
|
||||
|
||||
public class ReadBarCodeOnScheduler extends HandlerMainScheduler {
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
package com.unibuy.smartdevice.ui.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.blankj.utilcode.util.ToastUtils;
|
||||
import com.unibuy.smartdevice.DialogAbstract;
|
||||
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.SlotStructure;
|
||||
import com.unibuy.smartdevice.tools.HandlerMain;
|
||||
import com.unibuy.smartdevice.tools.ToastHandlerMain;
|
||||
import com.unibuy.smartdevice.ui.FontendActivity;
|
||||
import com.unibuy.smartdevice.ui.SaleFlowHandler;
|
||||
import com.unibuy.smartdevice.ui.tools.ImageGlide;
|
||||
import com.unibuy.v2.UnibuyHelper;
|
||||
|
||||
/**
|
||||
* 商品詳情對話框。
|
||||
* 點選商品後一律先彈出此頁,依雲端 B014 DevSet.ShoppingCar 決定按鈕:
|
||||
* - 購物車開啟:顯示「數量調整 + 加入購物車 + 直接購買」。
|
||||
* - 購物車關閉(一般版):隱藏「數量調整 + 加入購物車」,只留「直接購買」(數量固定 1)。
|
||||
* 「直接購買」沿用既有單品直購流程(清空 buyList → BuyDialog 選付款)。
|
||||
*/
|
||||
public class DialogProductDetail extends DialogAbstract {
|
||||
|
||||
private DialogProductDetailBinding binding;
|
||||
private final SlotStructure slot;
|
||||
// 建構子傳入的原始 Context(真正的 FontendActivity)。
|
||||
// 不可用 onCreate 的 getContext(),那是 AlertDialog 包裝過的 ContextThemeWrapper,
|
||||
// instanceof FontendActivity 會是 false,導致購物車頁/結帳入口取不到 SaleFlowHandler。
|
||||
private final Context hostContext;
|
||||
private int quantity = 1;
|
||||
|
||||
public DialogProductDetail(Context context, HandlerMain srcHandlerMain, SlotStructure slot) {
|
||||
super(context, srcHandlerMain);
|
||||
this.hostContext = context;
|
||||
if (slot == null || slot.getProduct() == null) {
|
||||
this.slot = createMockSlot();
|
||||
} else {
|
||||
this.slot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
private SlotStructure createMockSlot() {
|
||||
com.unibuy.smartdevice.structure.ProductStructure mockProduct = new com.unibuy.smartdevice.structure.ProductStructure(
|
||||
"mock_001",
|
||||
"",
|
||||
"模擬商品名稱 (Mock)",
|
||||
30,
|
||||
30,
|
||||
30,
|
||||
10,
|
||||
"",
|
||||
"MOCK_CODE",
|
||||
"Mock Product Name",
|
||||
"模擬の商品"
|
||||
);
|
||||
mockProduct.setSpecI18nJson("{\"zh_TW\":\"此為模擬商品描述,用於當系統尚未寫入真實商品資料時,可供開發者與測試人員模擬查看詳細頁面的版面配置與樣式呈現。\\n支援換行與多種排版。\",\"en\":\"This is a mock product description used for simulating layout and styling before real data is populated.\"}");
|
||||
return new SlotStructure(1, 99, mockProduct, 5);
|
||||
}
|
||||
|
||||
private boolean isMock() {
|
||||
return slot != null && slot.getProduct() != null && "mock_001".equals(slot.getProduct().getProductID());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context setCtx() {
|
||||
return getContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<? extends DialogAbstract> setCls() {
|
||||
return getClass();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerMain setHandlerMain() {
|
||||
return new ToastHandlerMain(getContext(), getLogs()) {
|
||||
@Override
|
||||
protected void execute(Context context, int commandCode, String message) {
|
||||
// 本對話框不需處理回呼指令
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogProductDetailBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// 商品圖片
|
||||
try {
|
||||
new ImageGlide(context).fileload(slot.getProduct().getProductImg(), binding.imageProduct);
|
||||
} catch (LogsEmptyException e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
|
||||
// 商品名稱(多語)、貨道、庫存
|
||||
binding.textProductName.setText(slot.getProduct().getLocalizedName(context));
|
||||
binding.slotNumber.setText(context.getString(R.string.slot_label) + slot.getSlot());
|
||||
binding.inventoryNumber.setText(context.getString(R.string.stock_label) + slot.getCount());
|
||||
|
||||
// 商品規格/描述(多語,純文字含換行;後台空白則隱藏整塊)。
|
||||
// setText 原樣呈現換行與空格/縮排;版面已設 gravity=start,故縮排不會被置中打亂。
|
||||
String spec = slot.getProduct().getLocalizedSpec(context);
|
||||
if (spec == null || spec.trim().isEmpty()) {
|
||||
binding.textProductDescription.setVisibility(android.view.View.GONE);
|
||||
} else {
|
||||
binding.textProductDescription.setText(spec);
|
||||
}
|
||||
|
||||
// 依購物車開關決定按鈕呈現
|
||||
boolean cartOn = MyApp.getInstance().getDevSet().isShoppingCar();
|
||||
if (!cartOn) {
|
||||
// 一般版:隱藏數量調整與加入購物車,只留直接購買,數量固定 1
|
||||
binding.layoutQuantityControl.setVisibility(android.view.View.GONE);
|
||||
binding.btnAddToCart.setVisibility(android.view.View.GONE);
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
refreshQuantity();
|
||||
|
||||
binding.ivCancel.setOnClickListener(v -> cancel());
|
||||
|
||||
binding.buttonMinus.setOnClickListener(v -> {
|
||||
if (quantity > 1) {
|
||||
quantity--;
|
||||
refreshQuantity();
|
||||
}
|
||||
});
|
||||
|
||||
binding.buttonPlus.setOnClickListener(v -> {
|
||||
// 上限:貨道庫存與購物車總量上限(3 件)
|
||||
if (quantity >= slot.getCount()) {
|
||||
ToastUtils.showShort(R.string.cart_out_of_stock);
|
||||
return;
|
||||
}
|
||||
if (quantity >= 3) {
|
||||
ToastUtils.showShort(R.string.cart_max_items);
|
||||
return;
|
||||
}
|
||||
quantity++;
|
||||
refreshQuantity();
|
||||
});
|
||||
|
||||
// 加入購物車:以選定數量加入購物車後停留商品列表(CartViewModel 內建庫存/8 件上限防呆)
|
||||
binding.btnAddToCart.setOnClickListener(v -> {
|
||||
if (isMock()) {
|
||||
ToastUtils.showShort("此為模擬商品,無法加入購物車");
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
// 效期過期商品一律暫不販售
|
||||
if (slot.isExpired()) {
|
||||
ToastUtils.showShort(R.string.product_expired);
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
addSelectedQuantityToCart();
|
||||
ToastUtils.showShort(R.string.cart_added);
|
||||
cancel();
|
||||
});
|
||||
|
||||
// 直接購買:
|
||||
// - 購物車開啟:以選定數量加入購物車後,前往購物車列表頁(數量帶入,不直接結帳)。
|
||||
// - 購物車關閉(一般版):單品直購,清空 buyList 放入本商品後彈 BuyDialog 選付款。
|
||||
binding.btnBuyNow.setOnClickListener(v -> {
|
||||
if (isMock()) {
|
||||
ToastUtils.showShort("此為模擬商品,無法購買");
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (cartOn) {
|
||||
addSelectedQuantityToCart();
|
||||
// 先切到購物車品項頁(於詳情頁視窗後方),再關閉詳情頁顯示出來,
|
||||
// 避免「關閉對話框」與「fragment 切換」時序互相干擾導致沒跳頁。
|
||||
openCartList();
|
||||
cancel();
|
||||
} else {
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
// 依可賣貨道把數量拆成多筆 count=1,規避 VMC 出貨狀態機不支援單筆 quantity>1(會卡 40 秒看門狗)。
|
||||
MyApp.getInstance().getBuyList().addAll(
|
||||
MyApp.getInstance().allocateBuyStructures(slot, quantity));
|
||||
cancel();
|
||||
requestBuy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 以詳情頁選定數量加入購物車(逐件累加,數量確實帶入;受庫存/8 件上限限制)。 */
|
||||
private void addSelectedQuantityToCart() {
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
UnibuyHelper.INSTANCE.getCartViewModel().addToCart(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** 前往購物車列表頁(沿用各 flavor 的購物車入口)。 */
|
||||
private void openCartList() {
|
||||
if (hostContext instanceof FontendActivity) {
|
||||
SaleFlowHandler handler = ((FontendActivity) hostContext).getSaleFlowHandler();
|
||||
if (handler != null) {
|
||||
handler.onShoppingCartRequested();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshQuantity() {
|
||||
binding.textQuantity.setText(String.valueOf(quantity));
|
||||
int total = slot.getProduct().getRealPrice() * quantity;
|
||||
binding.textPrice.setText("$" + total);
|
||||
}
|
||||
|
||||
/** 沿用各 flavor 的結帳入口(basic→BuyDialog);非 FontendActivity 時直接開 BuyDialog。 */
|
||||
private void requestBuy() {
|
||||
if (hostContext instanceof FontendActivity) {
|
||||
SaleFlowHandler handler = ((FontendActivity) hostContext).getSaleFlowHandler();
|
||||
if (handler != null) {
|
||||
handler.onBuyRequested();
|
||||
return;
|
||||
}
|
||||
}
|
||||
new BuyDialog(hostContext, getSrcHandlerMain()).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品詳情僅為瀏覽性質,非交易/出貨忙碌狀態。
|
||||
* 標記為非忙碌,閒置逾時時可自動關閉返回首頁,且不會擋掉遠端重啟([Dialog Open])。
|
||||
*/
|
||||
@Override
|
||||
public boolean isBusyDialog() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -188,8 +188,9 @@ public class DispatchDialog extends DialogAbstract {
|
||||
setTextViewSmall(message);
|
||||
setButtonOkText(0);
|
||||
|
||||
// 顯示錯誤圖片
|
||||
// 顯示錯誤圖片(客服 QR):優先讀裝置上的替換檔,沒有才用內建預設。
|
||||
binding.linearLayout4.setVisibility(View.GONE);
|
||||
applyDeliveryFailedImage();
|
||||
binding.imgDeliveryFailed.setVisibility(View.VISIBLE);
|
||||
binding.tvCountdown.setVisibility(View.VISIBLE);
|
||||
// 啟動倒數計時
|
||||
@ -253,14 +254,34 @@ public class DispatchDialog extends DialogAbstract {
|
||||
protected void onCreate(Context context) {
|
||||
binding = DialogDispatchBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// 進入出貨統一保險:關閉 FontendActivity 的「加購倒數」(AddProductOnCountdown)。
|
||||
// 任一支付方式只要走到出貨,就不該再被 40 秒加購倒數到期觸發 BUY_DIALOG_OPEN,
|
||||
// 避免支付畫面在出貨/取貨倒數中途疊出(通行碼、TapPay 等成功路徑原本未送 STOP_COUNTDOWN)。
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(),
|
||||
FontendActivity.Option.STOP_COUNTDOWN.getOption(), "");
|
||||
|
||||
isReportDispatch = true;
|
||||
tvCountdown = binding.tvCountdown;
|
||||
httpAPI = new HttpAPI(getHandlerMain());
|
||||
// 初始化 Finalize Builder(在支付完成、進入出貨流程時建立)
|
||||
finalizeBuilder = new TransactionFinalizeBuilder();
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
// 進入出貨流程當下 buyList 必為完整(下方即用它組 dispatchList),先擷取商品明細快照。
|
||||
// 之後結案 build() 會優先採用此快照,避免掃碼支付(玉山)等慢流程在結案前 buyList
|
||||
// 被取消/倒數路徑清空,導致 order.items 變空(無商品紀錄)但出貨其實成功。
|
||||
finalizeBuilder.captureItems();
|
||||
if (com.unibuy.smartdevice.AppEntry.isCmuh()) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(42);
|
||||
}
|
||||
// 快照訂單層級欄位(flowType / 金額 / 找零 / 點數)。須在上方 isCmuh→42 覆寫「之後」、
|
||||
// 且趁全域 flowInfo 仍正確時擷取;結案 build() 與「通行碼(5)不上報」判斷都改用此快照,
|
||||
// 不再讀結案當下的全域即時值——避免出貨中途任何路徑(如殘留加購倒數彈出 BuyDialog →
|
||||
// initialReportData())把全域洗回 0,導致通行碼誤報、payment_type/total 落地成 0「未知」。
|
||||
finalizeBuilder.captureOrderInfo();
|
||||
|
||||
// [MQTT Finalize 重構] 電子發票改由「後台」開立:機台不再直連綠界,
|
||||
// 僅把發票輸入(載具/愛心碼/統編/金額)隨 finalize 的 invoice 物件上報(TransactionFinalizeBuilder 組裝)。
|
||||
// 後台收到 finalize 後建 pending 發票並派 Job 去綠界開立,避免機台網路不確定性與等待。
|
||||
|
||||
MyApp.getInstance().setReportFreeGiftCodeStructure(new ReportFreeGiftCodeStructure("", 0, "", 0, 0, ""));
|
||||
|
||||
@ -311,7 +332,7 @@ public class DispatchDialog extends DialogAbstract {
|
||||
|
||||
getLogs().info("DispatchList size:" + this.dispatchList.size());
|
||||
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
if (com.unibuy.smartdevice.AppEntry.isCmuh()) {
|
||||
try {
|
||||
String patName = "未知患者";
|
||||
String sn = "";
|
||||
@ -413,15 +434,74 @@ public class DispatchDialog extends DialogAbstract {
|
||||
currentlyProcessingGoods();
|
||||
}
|
||||
|
||||
/**
|
||||
* 出貨失敗時顯示的「客服 QR 圖」。為了「換客戶不用重編 APK」,改成執行期優先讀取
|
||||
* 裝置上的替換檔;找不到才用內建預設圖 {@code R.drawable.qrcode_msg}。
|
||||
*
|
||||
* 替換檔路徑(用 USB/檔案管理員/adb 放到這裡,檔名固定):
|
||||
* Android/data/com.unibuy.smartdevice/files/cs_qrcode.jpg(或 cs_qrcode.png)
|
||||
* adb 範例:adb push cs_qrcode.jpg /sdcard/Android/data/com.unibuy.smartdevice/files/
|
||||
* 換另一個客戶只要覆蓋這個檔即可,不用重編、不用改程式。
|
||||
*/
|
||||
private void applyDeliveryFailedImage() {
|
||||
// 最優先:後台「UI元素設定」E1(交易失敗/客服圖)。未設定才退回裝置替換檔 cs_qrcode / 內建預設。
|
||||
String e1Url = com.unibuy.smartdevice.ui.tools.UiSkin.getUrl(getContext(), "E1");
|
||||
if (e1Url != null) {
|
||||
com.unibuy.smartdevice.ui.tools.UiSkin.apply("E1", binding.imgDeliveryFailed);
|
||||
getLogs().info("出貨失敗客服圖:使用後台 UI元素設定 E1");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
java.io.File dir = getContext().getExternalFilesDir(null);
|
||||
if (dir != null) {
|
||||
java.io.File[] candidates = {
|
||||
new java.io.File(dir, "cs_qrcode.jpg"),
|
||||
new java.io.File(dir, "cs_qrcode.png"),
|
||||
};
|
||||
for (java.io.File f : candidates) {
|
||||
if (f.exists() && f.length() > 0) {
|
||||
android.graphics.Bitmap bmp = decodeSampledBitmap(f);
|
||||
if (bmp != null) {
|
||||
binding.imgDeliveryFailed.setImageBitmap(bmp);
|
||||
getLogs().info("出貨失敗客服圖:使用裝置替換檔 " + f.getAbsolutePath());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
// 找不到替換檔(或解碼失敗)→ 用內建預設圖
|
||||
binding.imgDeliveryFailed.setImageResource(R.drawable.qrcode_msg);
|
||||
}
|
||||
|
||||
/** 依螢幕尺寸做降採樣解碼,避免大圖 OOM。 */
|
||||
private android.graphics.Bitmap decodeSampledBitmap(java.io.File f) {
|
||||
android.graphics.BitmapFactory.Options opts = new android.graphics.BitmapFactory.Options();
|
||||
opts.inJustDecodeBounds = true;
|
||||
android.graphics.BitmapFactory.decodeFile(f.getAbsolutePath(), opts);
|
||||
int reqW = getContext().getResources().getDisplayMetrics().widthPixels;
|
||||
int reqH = getContext().getResources().getDisplayMetrics().heightPixels;
|
||||
if (reqW <= 0) reqW = 1080;
|
||||
if (reqH <= 0) reqH = 1920;
|
||||
int sample = 1;
|
||||
int halfW = opts.outWidth / 2, halfH = opts.outHeight / 2;
|
||||
while (halfW / sample >= reqW && halfH / sample >= reqH) sample *= 2;
|
||||
opts.inSampleSize = sample;
|
||||
opts.inJustDecodeBounds = false;
|
||||
return android.graphics.BitmapFactory.decodeFile(f.getAbsolutePath(), opts);
|
||||
}
|
||||
|
||||
private void startCountdown() {
|
||||
countDownTimer = new CountDownTimer(15_000, 1_000) { // 10 秒,每秒 tick 一次
|
||||
public void onTick(long millisUntilFinished) {
|
||||
int seconds = (int) (millisUntilFinished / 1000);
|
||||
tvCountdown.setText("(" + seconds + "s)後關閉");
|
||||
tvCountdown.setText(getCtx().getString(R.string.close_after_seconds, seconds));
|
||||
}
|
||||
|
||||
public void onFinish() {
|
||||
tvCountdown.setText("(0s)後關閉");
|
||||
tvCountdown.setText(getCtx().getString(R.string.close_after_seconds, 0));
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
@ -443,6 +523,15 @@ public class DispatchDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
public void dialogCancel() {
|
||||
// 安全網:出貨流程已開始(dispatchList 非空)但尚未結案(既非成功 finalize、也非錯誤 finalize)時,
|
||||
// 在關閉前補送一次 MQTT Finalize(部分出貨/未完成),避免走「倒數結束 / dispatchIndex 越界 / 中止」
|
||||
// 等非錯誤關閉路徑時整筆交易完全不上報。isFinalizing 擋住正常成功/錯誤路徑,不會重複上報。
|
||||
// 注意:須在下方 buyList.clear() 之前執行,因 finalizeBuilder.build() 會讀 buyList 組出商品明細。
|
||||
if (!isFinalizing && finalizeBuilder != null && dispatchList != null && !dispatchList.isEmpty()) {
|
||||
getLogs().info("dialogCancel:偵測到尚未結案的出貨流程,補送 MQTT Finalize(部分/未完成)");
|
||||
finalizeFailedDispatch("出貨流程關閉前補送結案");
|
||||
}
|
||||
|
||||
closeDialogCountdown.shutdown();
|
||||
int buyListSize = MyApp.getInstance().getBuyList().size();
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
@ -458,9 +547,35 @@ public class DispatchDialog extends DialogAbstract {
|
||||
cancel();
|
||||
}
|
||||
|
||||
// 有待機畫面機台:出貨完成後停留幾秒取貨提示再跳待機畫面(可調)。
|
||||
private static final int STANDBY_PICKUP_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* 出貨完成後跳「待機畫面」(僅有待機的機台,即 !AppEntry.isCmuh)。
|
||||
* 交易已於出貨成功處 publishFinalize 結案;此處收掉本對話框計時器並導向 AppEntry.initialActivity()
|
||||
* (有待機機台即 HomeActivity)。取貨門已開,跳待機不影響客人取貨,只是縮短等待感。
|
||||
*/
|
||||
private void goToStandby() {
|
||||
try { closeDialogCountdown.shutdown(); } catch (Exception ignore) {}
|
||||
try { if (countDownTimer != null) countDownTimer.cancel(); } catch (Exception ignore) {}
|
||||
MyApp.getInstance().getBuyList().clear();
|
||||
try {
|
||||
Intent intent = new Intent(getCtx(), com.unibuy.smartdevice.AppEntry.initialActivity());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
getCtx().startActivity(intent);
|
||||
} catch (Exception e) {
|
||||
getLogs().warning(e);
|
||||
}
|
||||
cancel(); // 關閉本對話框
|
||||
}
|
||||
|
||||
private boolean isDispatchingNext = false;
|
||||
private boolean isCheckSlotByVMC = false;
|
||||
private int checkSlotRunCount = 0;
|
||||
// 非 Cmuh 出貨遇「下位機未連線」時,做一輪「重連→重新出貨」的計數(限一輪,避免無限迴圈)。
|
||||
// 機台一旦有回應(isCMD)即歸零,讓後續若再度失聯能再救一輪。
|
||||
private int dispatchReconnectRound = 0;
|
||||
|
||||
public class DevCheckSlotByVMC extends DevController.ReadBufferOnScheduler {
|
||||
private int slot;
|
||||
@ -485,7 +600,7 @@ public class DispatchDialog extends DialogAbstract {
|
||||
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
|
||||
if (isCheckSlotByVMC)
|
||||
return;
|
||||
getLogs().info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA006");
|
||||
getLogs().debug("dispatch flow 006");
|
||||
isCheckSlotByVMC = true; // 進來立即設定true,可以判斷掉重複出貨
|
||||
|
||||
boolean isCMD = false;
|
||||
@ -512,9 +627,10 @@ public class DispatchDialog extends DialogAbstract {
|
||||
getLogs().info("vmc isCMD:" + isCMD + " isStatus:" + isStatus);
|
||||
|
||||
if (isCMD) {
|
||||
dispatchReconnectRound = 0; // 機台有回應=已連線,重置重連輪數
|
||||
if (isStatus) {
|
||||
getLogs().info("-----------------商品出貨中-----------------");
|
||||
setTextViewCountdown("商品出貨中", 65);
|
||||
setTextViewCountdown(getCtx().getString(R.string.dispensing_product), 65);
|
||||
MyApp.getInstance().getDevXinYuanController()
|
||||
.getReadBufferByNow(new DevGetProductByVMC(handlerMain, slot), 5000);
|
||||
MyApp.getInstance().getDevXinYuanController().addSendBuffer(
|
||||
@ -529,7 +645,8 @@ public class DispatchDialog extends DialogAbstract {
|
||||
R.string.check_whether_goods_have_been_taken_out_before_confirming_collection));// 請檢查商品有無取出再確認取貨
|
||||
}
|
||||
} else {
|
||||
boolean isCmuh = "Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project);
|
||||
boolean isCmuh = com.unibuy.smartdevice.AppEntry.isCmuh();
|
||||
// 非 Cmuh 重試 3 次仍無回應就進入下方「重連→重新出貨」那一輪(give-up 分支),早點救援較快。
|
||||
int maxRetries = isCmuh ? 10 : 3;
|
||||
if (checkSlotRunCount >= maxRetries) {
|
||||
buttonControlCountdown.shutdown();
|
||||
@ -541,12 +658,29 @@ public class DispatchDialog extends DialogAbstract {
|
||||
}
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), Option.ERROR_PROCESS_GOODS.getOption(),
|
||||
"領藥有問題 請等3秒後再重掃");
|
||||
checkSlotRunCount = 0;
|
||||
} else if (dispatchReconnectRound < 1) {
|
||||
// 非 Cmuh:重試用盡仍「下位機未連線」→ 重連救活控制器後「重新出貨一輪」(限一輪,避免無限迴圈)。
|
||||
dispatchReconnectRound++;
|
||||
getLogs().info("下位機未連線 → 重連後重新出貨 (第 " + dispatchReconnectRound + " 輪)");
|
||||
try { MyApp.checkAndReconnectDev(); } catch (Exception ignore) {}
|
||||
checkSlotRunCount = 0;
|
||||
isCheckSlotByVMC = false;
|
||||
// 給重連後的控制器時間重開串口/重啟讀取迴圈,再重跑一次出貨
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (!isFinalizing) {
|
||||
try { currentlyProcessingGoods(); } catch (Exception e) { getLogs().warning(e); }
|
||||
}
|
||||
}, 2500);
|
||||
} else {
|
||||
// 已重連重試一輪仍未回應 → 真的失敗
|
||||
getLogs().info("重連後下位機仍未回應,出貨作業中止");
|
||||
getSrcHandlerMain().start(getClass().getSimpleName(), Option.ERROR_PROCESS_GOODS.getOption(),
|
||||
getCtx().getString(R.string.slave_computer_did_not_respond_wait_3_seconds_try_again));
|
||||
MyApp.getInstance().getDevXinYuanController().start();
|
||||
checkSlotRunCount = 0;
|
||||
dispatchReconnectRound = 0;
|
||||
}
|
||||
checkSlotRunCount = 0;
|
||||
} else {
|
||||
getLogs().info("機台未回應,第 " + (checkSlotRunCount + 1) + " 次重試 checkSlot..."
|
||||
+ (isCmuh ? " (溫和模式,不發送指令)" : ""));
|
||||
@ -660,7 +794,7 @@ public class DispatchDialog extends DialogAbstract {
|
||||
getSrcHandlerMain().send(getClass().getSimpleName(), Option.NEXT_PROCESS_GOODS.getOption(),
|
||||
"next process goods");
|
||||
} else {
|
||||
getLogs().info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA002");
|
||||
getLogs().debug("dispatch flow 002");
|
||||
MyApp.getInstance().getDevXinYuanController()
|
||||
.getReadBufferByNow(new DevGetProductByVMC(handlerMain, slot), 5000L);
|
||||
}
|
||||
@ -673,7 +807,7 @@ public class DispatchDialog extends DialogAbstract {
|
||||
getCtx().getString(R.string.product_delivery_issues_contact_the_counter_window));
|
||||
}
|
||||
} else {
|
||||
getLogs().info("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA003");
|
||||
getLogs().debug("dispatch flow 003");
|
||||
MyApp.getInstance().getDevXinYuanController()
|
||||
.getReadBufferByNow(new DevGetProductByVMC(handlerMain, slot), 5000L);
|
||||
MyApp.getInstance().getDevXinYuanController()
|
||||
@ -732,7 +866,7 @@ public class DispatchDialog extends DialogAbstract {
|
||||
if (isShipped) { // 正在要出貨的話
|
||||
if (count < quantity) { // count 目前出了多少貨 Quantity 總貨物數量
|
||||
// 設定商品名稱和圖片
|
||||
binding.textProductName.setText(product.getProductName());
|
||||
binding.textProductName.setText(product.getLocalizedName(getContext()));
|
||||
|
||||
try {
|
||||
new ImageGlide(getContext()).fileload(product.getProductImg(), binding.imageProductPicture);
|
||||
@ -907,6 +1041,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);
|
||||
@ -940,10 +1079,13 @@ public class DispatchDialog extends DialogAbstract {
|
||||
isFinalizing = true;
|
||||
|
||||
getLogs().info("所有商品出貨完畢,發送 MQTT Finalize 結案訊息。");
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
// 整筆交易出貨成功結案:清空 v2 多品項購物車(單例),避免下一位客人看到上一筆殘留商品。
|
||||
// 僅在「成功結案」清,取消結帳不清(保留購物車供客人重試)。
|
||||
com.unibuy.v2.UnibuyHelper.INSTANCE.getCartViewModel().clearCart();
|
||||
if (com.unibuy.smartdevice.AppEntry.isCmuh()) {
|
||||
insertDrgnoToMemo();
|
||||
}
|
||||
MqttManager.getInstance().publishTransactionFinalize(finalizeBuilder.build());
|
||||
publishFinalize();
|
||||
getSrcHandlerMain().send(getClass().getSimpleName(), FontendActivity.Option.RESET_DATA.getOption(),
|
||||
"update data");
|
||||
saveData();
|
||||
@ -971,24 +1113,33 @@ public class DispatchDialog extends DialogAbstract {
|
||||
binding.buttonOk.setTextColor(Color.BLACK);
|
||||
setButtonOkText(0); // 顯示「取貨確認」不帶倒數
|
||||
|
||||
// 顯示取貨倒數 15 秒後自動關閉對話框
|
||||
// 顯示取貨倒數後自動結束:
|
||||
// - 有待機畫面的機台(!isCmuh):縮短取貨提示秒數(STANDBY_PICKUP_SECONDS)後「跳待機畫面」,
|
||||
// 減少客人等待感(取貨門已開,跳待機不影響取貨)。
|
||||
// - 無待機(中國醫/台大北護等 pickup_sheet=isCmuh):維持原本 15 秒→關閉回原流程。
|
||||
isChangeStatus = true;
|
||||
// 💡 統一都有括號,消除初始與倒數的格式不一致閃爍
|
||||
final boolean hasStandby = !com.unibuy.smartdevice.AppEntry.isCmuh();
|
||||
final int pickupSeconds = hasStandby ? STANDBY_PICKUP_SECONDS : 15;
|
||||
setTextViewCountdown(
|
||||
getCtx().getString(R.string.pickup_port_open) + "(15)"
|
||||
getCtx().getString(R.string.pickup_port_open) + "(" + pickupSeconds + ")"
|
||||
+ getCtx().getString(R.string.pick_up_within),
|
||||
65);
|
||||
countDownTimer = new CountDownTimer(15_000, 1_000) {
|
||||
countDownTimer = new CountDownTimer(pickupSeconds * 1000L, 1_000) {
|
||||
public void onTick(long millisUntilFinished) {
|
||||
int seconds = (int) (millisUntilFinished / 1000);
|
||||
setTextViewCountdown(seconds);
|
||||
}
|
||||
|
||||
public void onFinish() {
|
||||
dialogCancel();
|
||||
if (hasStandby) {
|
||||
goToStandby();
|
||||
} else {
|
||||
dialogCancel();
|
||||
}
|
||||
}
|
||||
}.start();
|
||||
getLogs().info("📢 出貨完畢,取貨倒數 15 秒後關閉對話框");
|
||||
getLogs().info("📢 出貨完畢,取貨倒數 " + pickupSeconds + " 秒後"
|
||||
+ (hasStandby ? "跳待機畫面" : "關閉對話框"));
|
||||
|
||||
isDispatchingNext = false;
|
||||
isGetProductByVMC = false;
|
||||
@ -1052,19 +1203,32 @@ public class DispatchDialog extends DialogAbstract {
|
||||
|
||||
addFailedDispenseRecordsFromCurrent();
|
||||
|
||||
if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project) && hasAnySuccessfulDispatch()) {
|
||||
if (com.unibuy.smartdevice.AppEntry.isCmuh() && hasAnySuccessfulDispatch()) {
|
||||
insertDrgnoToMemo();
|
||||
} else if ("Cmuh".equalsIgnoreCase(com.unibuy.smartdevice.BuildConfig.FLAVOR_project)) {
|
||||
} else if (com.unibuy.smartdevice.AppEntry.isCmuh()) {
|
||||
getLogs().info("CMUH 整單未出貨成功,不寫入 SN/DRGNO Memo,允許重新掃碼領藥");
|
||||
}
|
||||
|
||||
getLogs().info("出貨失敗,發送 MQTT Finalize 結案訊息: " + message);
|
||||
MqttManager.getInstance().publishTransactionFinalize(finalizeBuilder.build());
|
||||
publishFinalize();
|
||||
getSrcHandlerMain().send(getClass().getSimpleName(), FontendActivity.Option.RESET_DATA.getOption(),
|
||||
"update data");
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 發送 MQTT 交易結案。
|
||||
* 通行碼(flowType 5)現在也一併上報:依需求「完整交易進銷售紀錄」,與一般銷售相同處理
|
||||
* (會建立訂單、記出貨明細/貨道、扣雲端庫存、計入交易筆數)。單次通行碼在 B670 驗證時已消耗,
|
||||
* 後台結案 case 5 只記 log,不會雙扣。
|
||||
*/
|
||||
private void publishFinalize() {
|
||||
if (finalizeBuilder == null) {
|
||||
return;
|
||||
}
|
||||
MqttManager.getInstance().publishTransactionFinalize(finalizeBuilder.build());
|
||||
}
|
||||
|
||||
private void addFailedDispenseRecordsFromCurrent() {
|
||||
if (dispatchList == null || dispatchList.isEmpty()) {
|
||||
return;
|
||||
@ -1132,6 +1296,10 @@ public class DispatchDialog extends DialogAbstract {
|
||||
buttonControlCountdown.setCountdown(25);
|
||||
}
|
||||
|
||||
// 出貨前「不」做預先重連:機台通常是好的,直接送 checkSlot。
|
||||
// (曾加過出貨前主動重連,但 isXinYuanAlive 在 NFC 付款期間 VMC 這條安靜 >4s 時會誤判失連,
|
||||
// 對健康機台做破壞性重連,觸發 checkAndReconnectDev 雙讀取執行緒 bug → VMC_52 洪水、POLL 認不出、
|
||||
// 出貨指令送不出去 → 出貨失敗。改回直接送;真的失連交由下方 checkSlot 重試輪的後備重連處理。)
|
||||
getLogs().info("11111");
|
||||
MyApp.getInstance().getDevXinYuanController().getReadBufferByNow(new DevCheckSlotByVMC(getHandlerMain(), slot));
|
||||
getLogs().info("22222");
|
||||
@ -1259,9 +1427,23 @@ public class DispatchDialog extends DialogAbstract {
|
||||
}
|
||||
|
||||
public void setTextViewCountdown(String message, int size) {
|
||||
if (isChangeStatus) {
|
||||
binding.dispatchtips.setText(message);
|
||||
binding.dispatchtips.setTextSize(size);
|
||||
// 一律回主執行緒更新 UI:本方法常被 checkSlot/getProduct 的背景排程回調呼叫,
|
||||
// 若直接在背景執行緒碰 View 會丟 CalledFromWrongThreadException(「Only the original thread…」),
|
||||
// 該例外被上層 catch 後,會連帶跳過緊接其後的 getProduct(吐貨指令)送出 → 出貨永遠卡在「出貨中」。
|
||||
// (Android 9 機台時序上剛好沒觸發,Android 7 上必現;改為 post 到主執行緒即可根治。)
|
||||
android.app.Activity act = (getCtx() instanceof android.app.Activity) ? (android.app.Activity) getCtx() : null;
|
||||
Runnable r = () -> {
|
||||
if (isChangeStatus) {
|
||||
try {
|
||||
binding.dispatchtips.setText(message);
|
||||
binding.dispatchtips.setTextSize(size);
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
};
|
||||
if (act != null) {
|
||||
act.runOnUiThread(r);
|
||||
} else {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(r);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user