Compare commits

...

135 Commits

Author SHA1 Message Date
terrylee
3f569645a2 feat(app-ui): 接部位 R1~R5(各支付進行中畫面下方的實體示意圖,1280x720)
R1 NfcCard/R2 NfcPhone/R4 NfcRecharge = imageCheckout(既有 id);R3 Esun/R5 CashPayGeneral 補 id imgPayPhysical

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:33:26 +08:00
terrylee
9e78119c0a feat(app-ui): 接部位 B1/B4(取貨碼/購物車浮動鈕) + M1~M5(各支付進行中示意圖)
- 浮動鈕/示意圖 ImageView 補 id(imgFloatPickupCode/imgFloatShoppingCart/imgPayMethod)
- M1 NfcCard / M2 NfcPhone / M3 EsunPay(imageCheckout) / M4 NfcRecharge / M5 CashPayDialogGeneral(工廠實際選用)
- 注意:nfc_card/phone/recharge 的 imageCheckout 是通道圖非示意圖,故另補 imgPayMethod
- UiSkin.apply 套後台圖時清 setImageTintList(null),避免浮動鈕被 app:tint 染色

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:14:49 +08:00
terrylee
5c4ac16d48 fix(app-ui): BG0 待機圖改貼待機廣告區(relativeLayout2)背景 + videoView shutter 透明
貼根版面會被滿版廣告區黑底蓋住看不到;改貼廣告區背景,並把 PlayerView 黑遮板設透明,未播待機廣告時 BG0 才透出

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:57:47 +08:00
terrylee
fa5525ad87 fix(app-ui): 整頁背景(BG0) applyBackground 改用 Glide 載入(本地檔優先/URL 網路 fallback,正確解 webp)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:39:48 +08:00
terrylee
d224354c23 fix(mqtt/ota): 修同一遠端指令被投遞多次致 OTA 下載互相取消而失敗
- MqttManager: 收訊改每個 client 註冊一次的全域 publishes 回呼;subscribeToCommandTopic 只送 SUBSCRIBE 不再掛 callback(每次重連累積 callback=元凶);加 command_id 去重(30s)
- OtaManager: startUpdate 加同 cmdId 防重入 guard,避免並行 cancelExisting 互相取消下載

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:07:05 +08:00
terrylee
bfaad4ea3b feat(app-ui): Phase3 換膚 — UiSkin 加 applyBackground/applyBgColor/applyTextColor + 接 BG0/C0/T0
HomeActivity 根背景=BG0;FontendActivity applyUiSkin():container=BG0、machineInfoBar=C0、textMachineInfoName=T0(切語言重建後重套)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:07:04 +08:00
terrylee
2fa44dfb01 feat(app-ui): 接部位 E1(交易失敗/客服圖) + P1~P6(支付方式按鈕圖)
- dialog_buy 六支付卡片圖補 id,BuyDialog 套 UiSkin P1~P6
- DispatchDialog.applyDeliveryFailedImage 最優先讀後台 E1,退回 cs_qrcode/預設
- verPatch 維持 42

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:41:15 +08:00
terrylee
0e1a6cbdf5 fix(orientation): 移除維修/鎖定頁 screenOrientation=portrait,改跟隨系統方向
倒裝面板機台上,被鎖 portrait 的 Maintenance/MaintenanceSerial 相對系統反向而倒180度;
其餘畫面本就未鎖方向、顯示正常。移除後一致跟隨系統,正常安裝機台無影響。(verPatch 維持 42)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:15:15 +08:00
terrylee
e1a2a45810 feat(app-ui): A3(異常鎖定/遠端鎖定頁)接後台換膚 — MaintenanceActivity 滿版圖套 UiSkin.apply("A3")
activity_maintenance.xml 加 lockImage ImageView(預設 a3_errorlock_aat_01);verPatch 42

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:58:13 +08:00
terrylee
569cfeac09 feat(app-ui): APP UI元素設定 Phase 1 app 端 — 解析 B014 UISet + A1 滿版套圖
- UiSkin:解析/落地(SettingsDao UISkin)/預抓(ImageGlide.preload)/套用(fileload)
- StarCloudAPI.saveSettings 加 UISet 解析
- A1(頁面跳轉中)套到 restart 畫面 restartImage(RestartHost/Restart)
- A2/A3 已在下發+落地管線,待定位對應畫面再接 UiSkin.apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:37:56 +08:00
terrylee
b3258c2b0d feat(cash): 現金連線方案選擇 — 串口/連線設定與相關調整
MainActivity/MyApp/PortProfile/Rs232Dev/DispatchDialog + build.gradle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:59:54 +08:00
terrylee
0c303ffccb build(dailemei): 岱樂妹專版預設 — 串口星科技(ttyS7/ttyS6) + 出貨失敗圖 qrcode_msg_dailemei
- PortProfile.DEFAULT_PROFILE = STARTECH:裝上岱樂妹星科技主板即用,免進後台手動切
- DispatchDialog 出貨失敗客服圖預設改 qrcode_msg_dailemei

⚠️ 專版設定,勿合併進通用主線;出通用版時 revert 本 commit 即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:21:33 +08:00
terrylee
1d11367066 feat: 出貨自救、登入gate、全站i18n補翻、硬幣修正、待機優化 (verPatch 40)
出貨/連線
- 出貨當下加連線閘門:送出貨指令前先確認下位機,失連則反覆自動重連
  直到活著或逾時(ensureXinYuanConnectedForDispatch),再送指令 → 真正補出貨
- 保留 checkSlot 重試輪當第二道保險(出貨中途掉線)
- 非現金付款進出貨前補送 inComeByIPC
- PortProfile 重構:引入 DEFAULT_PROFILE 常數(通用預設 LEGACY)

後台登入
- 進 MainActivity(含長按序號後門)一律先跳登入視窗,帳密正確才進主控台,
  取消則 finish() 返回前台

多國語(中/英/日)
- 商品名改用 getLocalizedName:出貨中畫面、取貨確認清單、取貨碼確認清單
- 補翻取貨口提示(門未關/升降/狀態確認中/檢查中)
- 補翻取貨碼頁(主提示/取消倒數/員工卡hint/驗證中/過效期/折扣類型)
- 補翻出貨列狀態(已出貨數量/出貨中X件/All)
- 發票方式頁「捐贈發票」→「捐贈發票/下一步」(新key,僅該卡片)

現金/硬幣
- 1元虛擬庫存封頂實體上限200(增量與載入均夾住)
- 投幣測試面板解析 COIN_STOCK/NOTE_STOCK 十進位(修1元>255破版)

出貨完成
- 有待機機台出貨完成後留數秒取貨提示即跳待機(CMUH/北護無待機維持原流程)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:20:58 +08:00
terrylee
954d4a3716 feat: 串口方案設定、取貨口/連線判斷重構、下位機當掉修正、客服圖外部化 (verPatch 37)
- 版號 verPatch 36 → 37
- 串口方案(本機設定):後台系統設定可切換 舊版本(下位機 ttyS1/刷卡機 ttyS2) ↔
  星科技(ttyS7/ttyS6),因應不同 IPC 主板串口位置不一致;切換後重啟套用。
  (PortProfile / ComPort.getFilepath / SystemSetActivity / activity_system_set.xml)
- 進銷售平台不再檢查/擋導航列(隱藏提示)。(MainActivity)
- 取貨口+連線判斷改為「點選商品時一次主動問答」(比照舊專案 DaiLemei):
  早退、~3.5 秒封頂;收到 05/門/升降立即擋、01 立即放行;拿不到 VMC_02 但機台
  仍在線(近期有任何 VMC 通訊)→保守擋住請客人稍候再點,避免漏放有東西;完全無通訊
  才自動重連重試 3 次。加單飛守衛、查詢前清殘留佇列。
  (BasicSaleFlow / DevXinYuanController: getSlotStatusSince/isRecentlyAlive/lastVmcCommTime)
- 修下位機「連續判斷後整個當掉」:送出佇列過載(>50)由 setRun(false) 停掉讀取迴圈
  改為「丟最舊、留最新」(load shedding),讀取迴圈持續運作、過載緩解自動恢復。
  (DevController.addSendBuffer)
- 出貨失敗客服 QR 圖改為執行期優先讀裝置替換檔(/sdcard 或 App 外部目錄的 cs_qrcode.jpg/png),
  找不到才用內建預設;換客戶不用重編。本版預設圖設為 qrcode_msg_dailemei(專給代樂美)。
  (DispatchDialog / dialog_dispatch.xml / qrcode_msg_dailemei.jpg)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:56:20 +08:00
terrylee
436e9b8328 fix(comport): 下位機/刷卡機串口統一為 ttyS1/ttyS2
移除依 hardware flavor(S12XYU/S9XYU)判斷串口的邏輯,兩個 flavor 一律使用
ttyS1(下位機 XinYuan)/ttyS2(刷卡機 NFCPay)。原 S12XYU 的 ttyS7/ttyS6 自
2026-07 起統一,簡化接線並消除板型差異。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:37:37 +08:00
terrylee
9112f46f4e [FEAT] 現金支付串口移植+面額明細上報+取貨口點選判斷+下位機維修模式(verPatch 36)
現金支付(串口 Modbus 移植自 Yueji,後台全 MQTT):
- 抽出 ICashAcceptorHandler 介面 + CashAcceptorFactory 工廠(三連線 USB/ComPort3/ttyS9
  可於現金機設定頁切換);SerialCashAcceptorHandler(Modbus+CRC,含使能/即時偵測/退幣退鈔)
  與既有 SimpleCashAcceptorHandler(USB) 並存;MdbEventListener/DebugLogListener 事件橋接。
- 前台現金入口統一到 CashPayDialogGeneral(SaleFlowHandler/DialogAbstract/BuyPickupCodeDialog/
  InvoiceDonationDialog/CashPayoutManager 一併改由工廠取得 handler)。
- CashMachineSettings 測試頁:連線選擇 UI + 螢幕除錯 log + 退幣/退鈔。
- 修正退幣 50 元:0D 退幣翻譯的硬幣通道 type 應為 4(與投幣 08 54 一致),原誤判為 3 致 50 元不動作。

現金面額明細回傳後台:
- order 新增巢狀 cash_detail{b1000,b500,b100,c50,c10,c5,c1}(僅 payment_type=9 帶),
  找零沿用 change_amount。CashPayDialogGeneral 於 calculate_amount_update_UI 依面額累計張數,
  經 ReportFlowInfoStructure → TransactionFinalizeBuilder 組 TransactionFinalizePayload.CashDetail。

取貨口(主櫃 XinYuan)偵測:
- DevXinYuanController 快取 VMC_02 自發廣播狀態(lastSlotStatus/getRecentSlotStatus)+補長度防護,
  避免錯位幀越界漏收。
- BasicSaleFlow 點選商品當下先判斷取貨口(checkPickupOnly,聆聽廣播快取,異常擋不開詳情頁);
  BuyDialog 付款前檢查退回讀廣播快取當後備。
- 修正:預檢成功(機台已在線)後按「直接購買」,跳過重複的下位機連線判斷,避免預檢佔串口後
  的 300ms 探詢誤判「未連線」並觸發不必要的重連。

下位機軟體→維修/設置模式(釋放串口,移植自 StarApp-LockBoard):
- 新增 MaintenanceMode(持久化旗標) + MaintenanceSerialActivity(維修畫面)。
- MainActivity「下位機軟體」改為進維修模式(釋放串口)而非開原廠 app+killProcess,避免 kiosk
  自動重啟後又搶回串口;MyApp 啟動略過佔串口/不自動重連 + releaseAllDeviceSerialPorts;
  DevXinYuanController 新增 releasePort()+維修模式建構子略過開串口+close null 防護。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:16:29 +08:00
sky121113
f46dbe0b51 [FEAT] 取貨碼改綁商品:App 刷碼依即時庫存自挑可出貨道(升版號 verPatch 29)
1. StarCloudAPI.verifyPickupCode 解析 B660 回傳的 product_id(數字且可能為 JSON null,改用 optInt 避免 optString 把 null 轉成 "null" 字串),PickupVerificationCallback.onSuccess 新增 productId 參數。
2. FontendPickupCodeDialog.onSuccess 依回傳分流:有 product_id(綁商品碼)→ getSlotDataByPid 取代表貨道 + allocateBuyStructures 自挑可出貨道(已濾 count>0 && !lock && !expired,壞一條自動換同商品下一條);無 product_id(舊綁貨道碼)→ 沿用原 slot_no 路徑,向後相容。
3. 綁商品碼若挑不到可出貨道(全缺貨/鎖定/過期)→ 提示缺貨並取消。
4. 升 verPatch 26→29(S9XYU OTA:versionName 21_02_29、versionCode 210229)。
2026-06-26 17:54:05 +08:00
terrylee
8dc0433e66 chore(version): 升 verPatch 至 26
購物車3件 + 進付款前下位機連線判斷 的測試版號(S9=21_02_26 / S12=10_02_26)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:18:53 +08:00
terrylee
c1da062d1d feat(sale): 進付款前確認下位機(XinYuan)連線,失連自動重連 (移植 Honlifeng)
點商品進付款(onBuyRequested)前先 ping 主櫃下位機;失連→checkAndReconnectDev 重連→再確認,
連上才開 BuyDialog、連不上跳大字提示擋住。檢查期間顯示「下位機連線判斷中」轉圈框。
MyApp 新增 pingXinYuanOnce/isXinYuanAlive/ensureXinYuanConnected。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:45:51 +08:00
terrylee
54b44f5cf4 feat(cart): 購物車上限 8->3
CartViewModel.hasMinimumCartItems() 與 DialogProductDetail +數量上限 由 8 改為 3,
購物車整車最多 3 件。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:45:51 +08:00
sky121113
e6ebac8dc1 [FEAT] 升版號至 verPatch 24(S9/S12 共用簽章 OTA 驗證版)
1. app/build.gradle:verPatch 22 → 24,觸發機台 OTA 升級(S12 versionCode 100224 / S9 versionCode 210224)。
2. 本版為導入共用簽章金鑰後首批產出,已編譯 S12XYU 與 S9XYU 兩支 Debug APK 並驗證憑證 SHA-1 與現場機台一致(23:57:65:A1...),確認可直接 OTA 覆蓋安裝。
2026-06-26 15:40:58 +08:00
sky121113
e4b095b971 [FIX] 固定共用簽章金鑰確保跨電腦 OTA 更新不失效
1. 新增 repo 內固定簽章檔 app/signing/taiwanstar.keystore(即現場機台沿用的金鑰,SHA1 23:57:65:A1...,有效至 2056)。
2. app/build.gradle 新增 signingConfigs.debug 明確指向上述 keystore,取代過去依賴各電腦自動生成的 ~/.android/debug.keystore。
3. 修正根因:原本 release build type 用 signingConfig signingConfigs.debug=各電腦互不相同的 debug 金鑰,換電腦/換人編 APK 簽章不符 → INSTALL_FAILED_UPDATE_INCOMPATIBLE → 無法 OTA。
4. compiler-apk(debug) 與 release-apk(release) 兩個打包指令共用同一把 signingConfigs.debug,一次修好;任何 build 機 pull repo 後即可產出可 OTA 更新現場機台的 APK,現場毋須重裝。
5. 已編譯 S12XYU Debug 驗證產出 APK 簽章 SHA1 與現場一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:21:44 +08:00
sky121113
e4e62de5b8 [FIX] 修復遠端重啟刷卡機/結帳失敗(指令名稱不符)並補刷卡機序列埠健壯性
1. 指令名稱對齊後台:MqttService 的 nfc_reset 相容 reboot_card、nfc_checkout 相容 checkout。
   後台(star-cloud)按鈕送出 reboot_card / checkout,App 原本只認 nfc_reset / nfc_checkout,
   導致一律落入 Unknown command → failed,此功能自串接首日(df8dae9)即從未成功;改別名後相容兩種。
2. 遠端 nfc_reset / nfc_checkout 執行前加 Busy Check:getBusyReason() 非空(現場交易中)即拒收並回報
   Machine busy,避免與現場刷卡爭用 comPort2。
3. 遠端指令送完電文改用 finally 強制 comPort2.closePort(),杜絕序列埠洩漏害下一筆現場交易開不了埠。
4. Rs232Dev 的 isMockMode 由 static 改為 instance 欄位,另立 static suMissing 表「su 缺失」全域事實;
   單一埠連線失敗只標記該實例,不再污染全 App 的 send 全部空轉(原 bug 須重啟才復原)。
5. verPatch 21→22(versionName 21_02_22_D / versionCode 210222;21 已 Release,避免 OTA 撞號)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:11:55 +08:00
sky121113
377da0a2da [FEAT] 進購物平台前強制關閉導航列 + 序號版本號字體放大加深
1. MainActivity「銷售平台」按鈕點擊改為先檢查導航列狀態:
   以 isNavigationReallyHidden() 判斷,未真正關閉時跳出
   showMustHideSystemBarsDialog() 提醒並擋下,符合販賣機機台不應出現系統導航列的規範。
2. 開發者後門(長按 2 秒強制進入)維持不變,刻意不受導航列檢查限制,方便手機測試。
3. 購物平台機台資訊列 textMachineInfoName(序號 + 版本號,亦為長按進主控台後門的觸發點)
   字體 14sp→16sp、alpha 0.7→0.9,放大加深以提升可讀性與長按命中率。
4. verPatch 20→21(versionName 21_02_21_D / versionCode 210221)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:14:07 +08:00
sky121113
a24bdfdc0e [FEAT] 貨道效期+鎖定 App↔後台雙向同步(LWW)+ 現場效期輸入
1. SlotStructure 新增 lock_rev/lock_synced_rev/expiry_rev/expiry_synced_rev(per-field LWW 序號)與 getExpiryDate/setExpiryDate/getBatchNo helper;clearExpiry 明定不可遞增 rev。
2. DatabaseHelper:slot 表新增 4 個 rev 欄位;onUpgrade 由「DROP 全表重建」改為「ALTER ADD COLUMN」增量升級保留既有資料;並把現場已鎖貨道 lock_rev 設 1 以遷移上雲。
3. SlotsDao:新增單行 updateSlot 與單一交易 replaceAll;insert/read 帶 4 個 rev,取代 deleteAll+insertAll 全表重寫避免 race。
4. StarCloudAPI B009:每筆 data[] 加 is_locked/lock_rev/expiry_date/batch_no/expiry_rev;被鎖空貨道(有 lock/rev>0)也上報;上報成功以快照 rev 回填 synced_rev。
5. MqttService update_inventory 下行:解析 is_locked/lock_rev/expiry_rev,依 synced_rev 拒絕覆蓋未上報的現場編輯,只動「下行確實帶的鍵」,改用單行 updateSlot 寫入。
6. VmcSlotListActivity:進頁拍 lock/效期 baseline,存檔時實際變更才遞增對應 rev;saveData 改用 replaceAll 交易。
7. RecyclerVmcSlotListAdpter:效期欄位改為永遠可點,開 DatePicker 供補貨員現場輸入效期(含「清除效期」)。
8. build.gradle:verPatch 19→20。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:58:10 +08:00
78f497d3f8 [MERGE] feat/general-cart-and-payment:整合領藥單功能與遠端鎖貨道/效期/交易上報更新 2026-06-24 11:29:06 +08:00
efd6d62449 [FEAT] 領藥單(雲端建單)模式與相關修正
- PharmacyPickupSaleFlow:雲端領藥單銷售流程(B660 驗證→組購物車→出貨)
- SaleFlowHandler 依 shopping_mode + FunctionSet.PharmacyPickup 分流
- 系統設定頁顯示領藥單功能狀態;DevSet 解析 PharmacyPickup
- 領藥明細/出貨畫面商品圖片改以 product_id 從本地商品清單補回
- 商品列表下載商品圖片時保留返回/下載按鈕並停用、進度遮罩不再蓋住按鈕
- 確認領藥畫面備註欄顯示批價單號(pricing_slip_no)
- 一併提交其餘待提交變更(OTA/付款/通訊等既有調整)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:24:09 +08:00
sky121113
90f86cf3c9 [FIX] 鎖貨道/效期只鎖該貨道不再鎖整商品,同商品多貨道改逐道出貨
1. VmcFragment.updateFlowerBuyList():銷售卡合併同商品多貨道改為「任一可賣貨道(count>0且未鎖未過期)即可售」,庫存只計可賣貨道加總;全部不可賣才依 鎖定>過期>售完 顯示對應遮罩。修正鎖一個貨道或單一貨道過期就把整個商品擋掉的問題。
2. MyApp 新增 allocateBuyStructures():把購買數量依可賣貨道 greedy 拆成多筆 count=1 的 BuyStructure,規避 VMC 出貨狀態機不支援單筆 quantity>1(出完第一個會卡 40 秒看門狗);庫存不足才溢位回代表道,交既有「出貨失敗→後台依 dispense 退款」保護網。
3. CartViewModel.convertToBuyStructure():多品項購物車改用 allocateBuyStructures 拆道,避免同商品買多個時重複出同一貨道而卡頓/出貨失敗。
4. DialogProductDetail 直購:改走 allocateBuyStructures 拆道。
5. PickupSheetSaleFlow:中國醫領藥 buildMaterialSlotMap 與 isAvailableCmuhStock 排除鎖定/過期藥道,避免藥單驗證通過卻在出貨時失敗。
6. build.gradle:verPatch 18→19(versionName 依硬體規格產生)。

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:08:29 +08:00
sky121113
ed9b05636c [FIX] 修復掃碼槍HID幽靈輸入卡死商品視窗、遠端重啟被誤判忙碌拒絕與執行緒洩漏卡頓
現場 M000008 經 log 確認三症狀同一根因:掃碼槍(HID鍵盤)按鍵穿透 FLAG_NOT_FOCUSABLE
對話框打進 FontendActivity,觸發 onUserInteraction 不斷把閒置時間歸零,導致待機看門狗
與重啟條件永遠達不到,且 [Dialog Open] 讓遠端 reboot 被當忙碌拒絕(指令484實證)。

1. FontendActivity.onUserInteraction 不再更新閒置時間/不呼叫 super,改在
   dispatchTouchEvent 更新;閒置一律以「實體觸控」為準,HID 按鍵不再算互動。
2. DialogAbstract 新增 isBusyDialog/isInputNeededDialog 分類、hasBusyDialogShowing/
   hasInputNeededDialogShowing 查詢、dismissAll 統一關窗,並加 dispatchTouchEvent
   讓對話框上的真人觸控也算互動(避免操作中被誤關)。
3. DialogProductDetail 標記 isBusyDialog=false:商品詳情不再卡成 [Dialog Open] 忙碌,
   閒置可自動返回首頁、亦不再擋掉遠端重啟。
4. FontendPickupCodeDialog / MemberCardPickupDialog 標記 isInputNeededDialog=true,
   保留取貨碼/會員卡的掃碼槍/讀卡機輸入。
5. MyApp.getBusyReason 改用 hasBusyDialogShowing(),只計入付款/出貨等真正忙碌對話框。
6. FontendActivity.dispatchKeyEvent 同時攔截 ENTER 與 NUMPAD_ENTER,僅放行正在顯示
   需要 HID 輸入的對話框,阻止幽靈輸入反覆開關商品視窗。
7. FontendActivity.idleRunnable 返回首頁前呼叫 dismissAll 清除殘窗。
8. FontendActivity.onDestroy 各釋放步驟全包 try-catch 並把 super.onDestroy 移到最後,
   確保 stopHeaderReportScheduler 一定執行,修正 ClockTickScheduler 執行緒洩漏卡頓。
9. MqttService 遠端 reboot 新增 force(command:"reboot_force" 或 payload {"force":true}),
   現場卡死時可無視忙碌強制重啟,作為最後逃生口。
10. 升版號 verPatch 至 16(正式版 21_02_16_R / versionCode 210216)以觸發 OTA。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:20:25 +08:00
sky121113
ebfa25da72 [FEAT] 強化交易上報正確性:手機支付路由鍵全程一致 + 結案發件箱防漏送
1. 手機支付 flowType 自按鈕起即為 10(原 4):BuyDialog 手機感應鈕、BuyDialog/
   BuyPickupCodeDialog/InvoiceBarcodeDialog/InvoiceCustomerIdentifierDialog/
   InvoiceDonationDialog 的支付路由 case 4→10,realPayment 判斷一併更新;
   DevSetStructure.canMobilePay 與 TransactionFinalizePayload 註解同步標為 10。
2. CheckoutNfcPhoneDialog 成功時不再覆寫 flowType(原 4→10),改為成功/失敗/取消
   各出口全程一致上報 10,修正失敗手機感應誤標後台 4(紙鈔機/Bill Acceptor)的問題。
3. 新增 TransactionOutbox:結案 payload 先持久化(SharedPreferences per-key)再送出,
   broker PUBACK 才移除;連線恢復與心跳每 60s 自動補送,解決斷線/斷電/App 被殺時 finalize 漏送。
4. MqttManager.publishTransactionFinalize 改走 Outbox,斷線不再直接丟棄;新增
   rawPublishTransaction(成功/失敗雙回呼)供 Outbox 實際送出。
5. Outbox 全程單一背景執行緒處理不卡 UI;in-flight 標記避免同筆重複送;連續失敗達
   上限移入死信(保留資料、出聲告警);addConnectedListener 連線恢復清 in-flight 並補送。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:09:10 +08:00
sky121113
8d764ba388 [FIX] 修復遠端 OTA 更新失敗並依環境區分 APK 版本後綴
1. 新增 DOWNLOAD_WITHOUT_NOTIFICATION 權限:OtaManager 改用 DownloadManager 後以 VISIBILITY_HIDDEN 靜默下載,缺此權限會在 enqueue 時丟出「Invalid value for visibility: 2」導致每次遠端更新一啟動就失敗。
2. versionName 後綴依 build type 動態切換:release → _R(正式)、debug → _D(測試),現場可一眼辨識 APK 來源;versionCode 不含後綴、兩者相同不影響 OTA 比版。
3. compiler-apk workflow 的後台 keyin 列印由寫死 _R 改為 _D,與實際 debug 檔名一致。
4. verPatch 升至 13。
5. 新增 docs/network-signal-reporting-spec.md 網路訊號上報規格文件。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:16:47 +08:00
sky121113
c7da4d1a12 [FEAT] 刷卡逾時/使用者終止改判未完成,不再誤報付款失敗
1. TransactionFinalizeBuilder.publishFailed:刷卡機回應碼 0003(交易逾時)/ 0006(使用者終止)改以 status=abandoned 上報且不帶 code,後台立即將該筆 pending 轉為「未完成 abandoned」,列表不再顯示為付款失敗,也不產生刷卡機硬體警告噪音。
2. 空電文與真實拒絕碼(0001/0002、0005 通訊失敗、0015/0016 餘額不足、1001~1003 等,卡片已讀到或可能已扣款)維持上報 failed(帶 code),保留對帳軌跡。
3. 邏輯集中於 publishFailed 一處,三個 NFC 結帳 Dialog(信用卡/電子票證/手機支付)與後台皆不需變更。
4. 升版號 verPatch 10→11(S9XYU:versionName 21_02_11_R、versionCode 210211)以利 OTA 更新。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:01:28 +08:00
sky121113
6dfadbe246 [FEAT] 重構 OTA 升級管理器以改用 DownloadManager 支援背景續傳與進度回報
1. 升級 `app/build.gradle` 頂部修補版號為 10 (verPatch = 10)。
2. 重構 `OtaManager.java`:
   - 捨棄舊版自架 Thread 下載,改用系統 `DownloadManager` 進行背景下載,防止 App 重啟被殺中斷。
   - 下載期間定期透過 MQTT 發送進度 ACK (`result="progress"`)。
   - 語意修正:不再一開始就回報 `success`,僅在終態回報成敗。因靜默安裝成功會重啟 App,故於新版啟動後透過 `resumeIfPending` 比對預期版本並回報 `success`。
   - 新增閒置安裝機制,若機台忙碌中則持續等待並定期重試。
   - 提供無 `DownloadManager` 時的舊版直連下載退回方案。
3. 修改 `MqttManager.java`:在 MQTT 連線就緒後呼叫 `OtaManager.resumeIfPending` 接手未完成的 OTA 任務,避免狀態遺失。
4. 修改 `MqttService.java`:解析 MQTT `update_app` 指令 payload 中的 `version` 欄位並帶入 `OtaManager` 進行版本確認。
2026-06-18 09:10:21 +08:00
sky121113
272164e0ed [STYLE] 品牌更名為 Star Cloud、修正圖片快取並升版號至 _9
1. 品牌字串統一更名為「Star Cloud」:首頁歡迎標題(登入前/後)由「AAT」改為「Star Cloud」、標題副字串「智能販賣機系統」精簡為「智能系統」(MainActivity、activity_main.xml);刷卡收據抬頭「AAT」改為「Star Cloud」(dialog_credit_card_receipt.xml)。
2. 首頁主視覺由 vending_machine_scenario_v1 改用 starcloud_logo_circle,並新增 starcloudlogo.png 素材。
3. 修正 ImageGlide 圖片快取鍵:本機檔案以 lastModified() 為簽章(檔案未變則命中快取、避免滑動每格重抓;圖片更新時自動失效重載),URL 載入與例外路徑移除 currentTimeMillis() 簽章,修掉永遠顯示 loading 的問題。
4. build.gradle 版號 verPatch 由 6 升至 9(對應 21_02_9_R)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:36:27 +08:00
sky121113
16420ae5a3 [FEAT] 一鍵補貨依貨道型式分流履帶/彈簧補貨上限
1. StarCloudAPI.saveProducts() 解析 B012 新欄位 spring_limit,設入 ProductStructure 並另存持久化 blob(履帶上限沿用 t060v11=track_limit,不動 products 表 schema 避免升 DB 版本洗掉貨道綁定)。
2. MyApp 新增 productSpringLimitBlob 之 get/set/saveProductSpringLimit 與離線重啟載入,供從 DB 重載商品(springLimit=-1)時 fallback。
3. ProductStructure 新增 springLimit 欄位與 getTrackLimit()/getSpringLimit();springLimit 缺時向 MyApp blob 取,再缺則退回履帶上限,維持舊行為不會把數量補成 0。
4. HardwareSetStructure 新增 isSpringSlot(slotNo),依 10 槽分組對齊後台 SPRING_SLOT_FLAGS,超出 1–60 視為履帶。
5. VmcSlotListActivity 一鍵補貨改依貨道型式取上限:彈簧用 getSpringLimit()、履帶用 getTrackLimit(),並於 log 標註(彈簧)/(履帶)。
6. build.gradle verPatch 5→6。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:22:27 +08:00
sky121113
258aa2f87b [FIX] 修復返回首頁時廣告播放器 NPE 導致廣告消失
1. HomeActivity.startOnSwitchFragment() 的 500ms 延遲重播工作,於執行時補上 videoEoxPlayer == null 判斷。
2. 修正閒置 5 分鐘自動返回首頁時,stopOnSwitchFragment() 在延遲內先清空 static 播放器,使延遲工作呼叫 playMediaByFlag 踩到 null 而拋出 NPE、廣告區從此空白不再恢復的競態問題。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:22:11 +08:00
sky121113
84c7ac8933 [REFACTOR] 取貨口閘門由進場改為付款前檢查,並修正發版腳本與語系下發
1. 取貨口閘門時機調整(basic 模式):移除「進入銷售頁」的總閘門,改在「準備付款時」於 BuyDialog 檢查。
   - VmcFragment:移除 maybeRunPickupPortEntryGate 與 entryGateChecked,startOnSwitchFragment 還原為僅更新清單。
   - RecyclerVmcBuyListAdpter:移除 checkPickupPortOnEntry 與 DevEntryGateByVMC 整段(進場閘門)。
   - BuyDialog:新增 checkPickupPortBeforePay 與 DevCheckPickupByVMC,取貨口有東西(0x05)或門沒關(0x06/0x09)時提示,
     使用者確認後以 Option.FORCE_CLOSE 關閉付款頁回銷售頁;機器無回應(3000ms 超時)視為正常放行。
2. StarCloudAPI 語系下發改為後台優先:每次下發機台預設語系即立即套用,覆蓋使用者先前手動選擇
   (設定下發頻率低且皆在無人時段,不會打斷操作)。
3. dialog_buy.xml:選擇支付方式頁的 Line Pay 按鈕文字由「掃碼支付」改為「Line Pay 支付」。
4. app/build.gradle:verPatch 4 → 5(S9XYU 正式發版,versionCode 210205 / versionName 21_02_5_R)。
5. release-apk workflow:修正終止 Gradle Daemon 段落的腳本自殺 bug,比對字串改為拆字串拼接並以 grep -vx "$$"
   排除自身 PID,避免背景任務中 pgrep -f 命中腳本本身導致 exit 144。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:43:09 +08:00
sky121113
f905380597 [FEAT] 取貨口入口閘門放寬至整個 basic 模式並調整對話框文字
1. VmcFragment 取貨口「進銷售頁總閘門」條件由 (isBasic && cartOn) 放寬為僅判斷 isBasic:basic 模式不論購物車開關,進銷售頁都先檢查取貨口,避免「購物車關閉」時點商品完全無上游取貨口檢查。
2. 員工卡(employee_card)流程仍於點擊時自檢、pickup_sheet 維持不檢查,皆不重複;同步更新方法註解。
3. dialog_buy.xml 對話框文字「贈品兌換」改為「來店禮」。
4. app/build.gradle verPatch 升至 3(versionName 21_02_3_R / versionCode 210203)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:15:59 +08:00
sky121113
42184c7077 [FEAT] 購物車模式進入銷售頁時新增取貨口總閘門
1. RecyclerVmcBuyListAdpter 新增獨立的 checkPickupPortOnEntry() 與 DevEntryGateByVMC 讀取器,於進入銷售頁時檢查取貨口狀態(0x05 有東西 / 0x06、0x09 門未關),不影響員工卡點擊用的 checkSlotHaveProduct,維持線上 main 流程逐字等價。
2. 取貨口異常時跳提示(放大字級),使用者確認或 10 秒後自動執行 onBlocked 回待機;機器無回應 3000ms 超時則放行購物(fail-open,與既有行為一致)。
3. VmcFragment.startOnSwitchFragment 觸發 maybeRunPickupPortEntryGate,僅在 shopping_mode==basic 且購物車開啟時啟用,員工卡流程不重複檢查。
4. 以 entryGateChecked 旗標確保每輪購物只檢查一次(切購物車頁再切回不重跳);延遲 2500ms 送出等下位機連線就緒;被擋下時呼叫 FontendActivity.ReSetAPP() 回待機,使三個加購入口(加入購物車/直接購買/快速加購)在源頭一併被擋。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:31:22 +08:00
TaiwanStar Developer
4afc3a59d4 refactor(ui): 優化支付選項對話框之版面配置與應付金額顯示樣式 2026-06-16 17:25:12 +08:00
sky121113
309518f3ca [FEAT] 選擇支付方式頁顯示件數與應付金額,並防止來店禮重複掃描
1. dialog_buy.xml:將原本隱藏的金額列改為可見,新增「共 X 件」件數顯示與「應付金額」標題,金額靠右呈現。
2. BuyDialog.java:新增 refreshAmountBar() 統一刷新件數與應付金額,onCreate 與 SET_PRICE 事件皆改走此方法。
3. BuyDialog.java:抽出 getOriginalPrice()(未折扣原價)與 getTotalCount()(總件數,排除免費商品),getTotalPrice() 改用 getOriginalPrice() 並沿用折抵後至少 1 元保護。
4. BuyDialog.java:來店禮折抵改以「實際省下的金額」顯示(NT$XXX (已折抵 NT$YY)),取代原本 mode 2 顯示折扣乘數 0.8 的難懂呈現。
5. BuyDialog.java:防呆——本筆已套用來店禮(freeGiftId 非空)即隱藏來店禮入口按鈕,禁止重複掃描覆蓋/疊加折扣。
6. strings.xml:新增 pay_item_count、pay_payable_amount、pay_discounted_format 字串。
7. app/build.gradle:verPatch 1 → 2。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:01:45 +08:00
sky121113
c986b69e16 [FIX] 修復出貨中途加購倒數疊畫面、結案欄位被洗成 0、對話框誤判待機,並修打包腳本自殺
1. 結案上報穩定化:TransactionFinalizeBuilder 新增 captureOrderInfo() 快照(flowType/總金額/找零/點數),DispatchDialog 進入出貨時(isCmuh→42 覆寫後)擷取;build() 與「通行碼(5)不上報」判斷改用快照(effectiveXxx/getOrderFlowType),避免出貨中途全域 flowInfo 被洗回 0 導致 payment_type/total 落地成 0「未知」或通行碼被誤報。
2. 加購倒數疊畫面:DispatchDialog.onCreate 與 CheckoutAccessCodeDialog.changeDispatch 進入出貨前統一補送 STOP_COUNTDOWN,關閉 FontendActivity 的 40 秒加購倒數,避免出貨/取貨倒數中途到期觸發 BUY_DIALOG_OPEN、支付畫面疊在出貨畫面上跳出(通行碼、TapPay 等成功路徑原本未送)。
3. 移除加購功能殘留:BuyDialog 刪除 buttonAddProduct 監聽與 gotoAddProduct(),dialog_buy.xml 移除加購按鈕。
4. 待機誤判修復:FontendActivity.onUserInteraction 一併 resetIdleTimer(對話框觸控不會觸發 dispatchTouchEvent),idleRunnable 以全域最後互動時間二次確認,剩餘時間不足才真正踢回待機,避免操作付款/詳情等對話框時被誤判閒置。
5. 打包腳本修復:compiler-apk.md 的 daemon-kill 段把 pgrep/pkill 比對字串拆開拼接並排除自身 PID,修正背景任務情境下腳本命令列含完整 GradleDaemon class 字串導致 pgrep 比對到自己、pkill 自殺、編譯 exit 144 未啟動的問題。
6. 升版號:build.gradle verMinor=2、verPatch=1(S9XYU=21_02_1_R/210201)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:32:13 +08:00
sky121113
9a4f68455d [FIX] 修復發票統編「下一步」失效與待機死結,並調整銷售平台資訊欄
1. 修復發票統編對話框「下一步」按鈕未綁定點擊事件,導致統編→電話兩步驟流程卡死、統編永遠送不到後台;新增 changeNextStep() 驗證統編長度並切換至電話輸入步驟。
2. 修復購物車殘留造成「永遠忙碌→永不待機→永不清車」死結:忙碌判定移除「購物車非空」條件,進行中狀態改由對話框開啟(isShowDialog)與感應支付(isNfcPaymentActive)涵蓋。
3. 銷售平台移除上、下方灰色資訊欄(linearLayout5、bottom_bar),改於 applySaleOverlayVisibility 一律隱藏。
4. 機台資訊列新增時間顯示(置於網路前),並以獨立的 ClockTickScheduler 每秒刷新,與 10 秒上報重活迴圈分離,避免將上報/重啟判斷拉到每秒。
5. 版號遞增 verPatch 97→98。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:26:07 +08:00
TaiwanStar Developer
dd87f1ca6e Merge remote-tracking branch 'origin/feat/general-cart-and-payment' into ui-design 2026-06-16 13:52:36 +08:00
TaiwanStar Developer
eae0342ab8 refactor(ui): 優化商品詳情對話框與列表樣式,並抽離共用文字尺寸配置 2026-06-16 13:51:57 +08:00
sky121113
c7bc554eca [FIX] 修復掃碼支付(玉山)結案前購物車被清空導致「無商品紀錄」
1. 根因:order.items 在結案 build() 當下才即時讀 live buyList,掃碼支付(玉山)流程較慢(掃碼+玉山API約20s),結案前 buyList 會被取消/倒數路徑清空,造成 order.items 變空陣列(無商品紀錄),但出貨其實成功(dispense 有商品)。
2. TransactionFinalizeBuilder 新增 itemsSnapshot 商品明細快照欄位與 captureItems() 方法;buyList 為空時不覆寫既有快照。
3. 抽出共用 buildItemRecords() helper,captureItems() 與 build() fallback 共用。
4. build()/buildOrderPayload() 改為優先採用快照,無快照時才 fallback 讀 live buyList。
5. DispatchDialog onCreate 建立 finalizeBuilder 後立即 captureItems(),此時 buyList 必完整。
6. 升版 verPatch 95 → 96 (S9XYU versionName 21_01_96_R / versionCode 210196)。

正常情況快照==live,逐字等價;僅在 buyList 被清空的 bug 情境修正。純 App 端,不影響後台與線上 main。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:13:46 +08:00
TaiwanStar Developer
647e72aa9b refactor(ui): 優化支付選項對話框樣式與倒數計時文字 2026-06-16 11:30:51 +08:00
sky121113
89c7d04f7a [FIX] 修復遠端更新設定誤刪廣告,並完善機台資訊列顯示
1. [FIX] persistCurrentSettings() 原以 deleteAll() 清空整張 SettingsDao,
   連坐刪除廣告(VideoEoxPlayer)等持久資料;改為僅 deleteWhere 逐筆刪除
   接下來要重寫的 4 個設定 key(Machine/Esunpay/GreenInvoice/Greenpaysetting)。
   修正「遠端更新設定→重啟 App 後廣告消失,須再更新廣告才出現」問題。
2. [FEAT] 機台資訊列左側改顯示「序號 + 版本號(Ver versionName)」。
3. [FEAT] 機台資訊列網路訊號改為動態:依 WiFi RSSI / 行動網路 dBm 切換訊號格數
   icon 與文字(無網路/WiFi/行動/有線),並於 SET_NETWORK 與輪詢時即時更新。
4. [REFACTOR] 進站後門觸控由舊 textNetwork 遷移至機台資訊列的「序號+版本號」元素
   (舊系統列已隱藏不再接收觸控)。
5. [STYLE] activity_fontend 機台資訊名稱樣式調整(灰色/14sp/透明度 0.7)。
6. 版號 verPatch 93 → 94。
2026-06-16 11:09:45 +08:00
TaiwanStar Developer
f700881788 Merge remote-tracking branch 'origin/feat/general-cart-and-payment' into ui-design 2026-06-16 09:53:26 +08:00
TaiwanStar Developer
7ec2d3916a feat(ui): 實作首頁機台資訊列,包含網路、溫度及多國語言切換功能 2026-06-16 09:51:47 +08:00
sky121113
6caaf0890e [FEAT] 交易生命週期上報(pending/failed)與發布 v93
1. app/build.gradle:verPatch 91 → 93(高於機台已 OTA 的 v92,確保修正上得了機台)。
2. structure/ReportFlowInfoStructure.java:新增 mqttFlowId 欄位,作為一次交易的 MQTT 流水號,pending → completed/failed 全程共用,與取貨碼/通行碼用的 reportFlowId 分離。
3. structure/mqtt/TransactionFinalizePayload.java:OrderPayload 新增 status 欄位(pending/completed/failed/abandoned),未帶時後台預設 completed,確保線上 main 機台行為不變。
4. tools/TransactionFinalizeBuilder.java:改用 mqttFlowId(無則生成並回存)、新增可變 status/payment_status 與失敗代碼;新增 publishPending()/publishFailed() 靜態上報;以 shopping_mode == "basic" 判斷是否上報(晟崴 employee_card/中國醫 pickup_sheet 無實際付款不送);pending/failed 不開立發票。
5. ui/dialog/BuyDialog.java:changeCheckout 進入付款時為實際付款方式(1/2/3/4/9/30/70)生成新 mqttFlowId 並上報 pending(進付款一律留底),先清除上一筆避免 flow_id 撞單。
6. ui/dialog/CheckoutNfcCardDialog、CheckoutNfcRechargeDialog、CheckoutNfcPhoneDialog:刷卡機回非 0000 或空電文的失敗分支補上 publishFailed(代碼),救回原本默默消失的失敗單。
7. ui/dialog/CheckoutEsunPayDialog、CheckoutTapPayDialog、CheckoutLinePayDialog:掃碼/官方支付 TRANSACTION_FAILED 分支補上 publishFailed。

過渡期保護:晟崴/中國醫 跑 main 分支、無實際付款,且後台四鐵律(無 status 預設 completed、終態冪等、pending 才轉移、abandoned 只掃 pending)保證其後台資料逐字不變。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:54:42 +08:00
sky121113
9bfb429392 [FIX] 修復刷卡機交易成功卻不繼續出貨流程
1. 修正 DevNFCPayController.execute():刷卡機(NFC/悠遊卡/手機/信用卡)回傳的 404 byte 電文本身已含 responseCode(成功為 0000),直接以 devNFCPay.read() 解析該段並送出 RESPONSE。
2. 移除先前誤加的「再以 readListen 等待第二段電文」邏輯:刷卡機僅送一段,等不到第二段導致倒數逾時、RESPONSE 從未送達對話框、changeDispatch() 無法觸發,造成「刷卡機顯示成功卻不出貨」。
3. 保留 isShowDialog / isShuttingDown 生命週期防護;此 controller 為所有感應付款共用,一次修復全部感應類付款。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:54:19 +08:00
TaiwanStar Developer
317dec1084 feat(ui): 優化取貨碼相關對話框與列表之 UI 排版 2026-06-15 16:43:58 +08:00
TaiwanStar Developer
be3c25bbf5 Merge remote-tracking branch 'origin/feat/general-cart-and-payment' into ui-design 2026-06-15 16:24:58 +08:00
sky121113
ff65559b4c [FEAT] 電子發票改由後台開立:機台只隨 finalize 上報發票輸入
1. TransactionFinalizePayload 新增 InvoicePayload 物件(status/amount/carrier_id/business_tax_id/love_code 等),讓 finalize 一併帶上發票開立輸入。
2. TransactionFinalizeBuilder:有發票需求(invoiceInfo 非空)時,從 InvoiceData 組發票輸入並固定 status=pending 帶進 finalize;機台不再判定開立結果。
3. DispatchDialog:移除機台直連綠界開票,改為進出貨流程僅上報;發票由後台收到 finalize 後建 pending 並去綠界開立(避免機台網路不確定性與等待)。
4. ReportInvoiceInfoStructure:保留 relateNumber 欄位(冪等鍵相關)。
5. 開票邏輯不放在即將淘汰的 HttpAPI(先前暫放 StarCloudAPI 的機台開票方法亦已移除)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:18:11 +08:00
TaiwanStar Developer
092c0e9a16 feat(ui): 調整系統設定頁面排版與玉山支付模組設定
- 將「購物方式」資訊移至 Header 區域並移除身份說明文字

- 將機台貨道顯示方式改為 TableLayout 並設為唯讀狀態,隱藏儲存按鈕

- 為大卡片容器增加 app:cardUseCompatPadding=true 並取消父層剪裁,修復卡片陰影遭裁切問題

- 修改玉山支付模組,將台灣Pay子標籤改為「玉山支付模組」,並徹底移除街口支付、LinePay、全支付、PiPay 等子項目及其連動邏輯

- 統一設定項目文字顏色,並新增相關支付圖示資源
2026-06-15 16:07:05 +08:00
sky121113
7875db4c7d [FIX] 升版 v91(OTA 版號遞增,內容同 v90 支付 MQTT finalize 修正)
1. build.gradle verPatch 90 → 91,供實體機台 OTA 升級。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:03:17 +08:00
TaiwanStar Developer
b32f76c753 style: 調整結帳對話框交易金額冒號與按鈕顏色
- 將Line Pay、NFC卡片、NFC手機、NFC加值對話框中的交易金額冒號改為全形

- 調整NFC卡片對話框取消按鈕文字色為onWarning,修改支付方式背景色為gray
2026-06-15 13:57:41 +08:00
sky121113
3fd34becb6 [FIX] 支付成功改走 MQTT finalize、修正卡在倒數不出貨;升版 v90
真因:各支付 Dialog 成功後呼叫已停用的 B600(reportFlowInfo),再靠 B600 的 HTTP 回應回呼 REPORT_FLOW 才進 DispatchDialog;B600 已失敗 → 永遠不出貨,卡到倒數 COUNTDOWN_CANCEL(症狀:刷卡機顯示成功但流程不繼續)。

1. CheckoutNfcCardDialog / CheckoutNfcRechargeDialog:支付成功(0000)不再呼叫 B600,直接 changeDispatch() 進 DispatchDialog,由 MQTT publishTransactionFinalize 統一上報(payload 已含 invoice_info,B600/B650 皆不需要)。
2. CheckoutNfcPhoneDialog:同上,且進 dispatch 前覆寫 payment_type=10(手機支付);刷卡機 NCCC 電文無法區分手機錢包/實體卡,依使用者所選按鈕分類。
3. CheckoutEsunPayDialog / CheckoutLinePayDialog / CheckoutTapPayDialog:交易成功直接 changeDispatch;交易失敗改為提示「交易失敗」並返回購買頁(changeBuy),不再卡住或誤出貨。
4. LinePay 維持 payment_type=70(官方直連),後台另補定義;TapPay 子項 30~34 沿用。
5. 舊 CheckoutCashDialog/CashPayDialog 為 legacy,basic shopping_mode 不使用(現金走 CashPayDialogGeneral),本次未動。
6. build.gradle 版號 verPatch 89 → 90。

注意:後台 star-cloud 需同步新增 payment_type 10(手機支付)/70(LINE Pay 官方) 與翻譯(另一 repo,另行提交)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:56:56 +08:00
sky121113
a99f7b0572 [FEAT] 雲端日誌分級(LogLevel)與除錯雜訊清理
1. MyApp 新增 logLevel 欄位(預設 INFO),由雲端 B014 LogLevel 下發並回送,現場除錯硬體可設 DEBUG。
2. Logs.debug()/ignore() 改為僅在 LogLevel=DEBUG 時寫入 debug.txt;logcat 一律照印不受影響。
3. Logs 新增單行寫檔上限 MAX_LINE(1000),超長 JSON/payload dump 截斷並保留原始長度,避免淹沒 debug.txt。
4. 將 HttpAPI/HttpTapPay/DevXinYuan/DevXinYuanController/DevNFCPayController/BuyDialog/DispatchDialog 大量 "AAAA…/bbbb…" 開發雜訊 logs.info 改為 logs.debug 並補上具體訊息。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:56:32 +08:00
sky121113
7cb9039a50 [FEAT] 商品詳情頁接 B012 t060v03_i18n 規格多語(純文字含換行原樣呈現)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:50:00 +08:00
sky121113
d7bef7b827 [FIX] 修正直接購買(購物車版)未跳購物車品項頁的真因;升版 v89
1. 真因:DialogProductDetail 取 SaleFlowHandler 時用 onCreate 的 getContext(),那是 AlertDialog 包裝過的 ContextThemeWrapper,instanceof FontendActivity 為 false,導致 openCartList 靜默 return(一般版因 requestBuy 有 new BuyDialog 後路才照常開支付頁,故僅購物車版受影響)。
2. 修法:建構子保存原始傳入的 Context(真正的 FontendActivity)為 hostContext,openCartList/requestBuy 改用 hostContext 做 instanceof 與取 SaleFlowHandler。
3. 升版號 verPatch 88 → 89。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:24:28 +08:00
sky121113
1c9022cc5e [FIX] 修正商品格線右緣觸控失效與直接購買未跳購物車頁;升版 v88
1. 修正 fragment_vmc.xml 商品格線 recyclerBuy 在 ConstraintLayout 缺水平約束(原 match_parent 僅綁上下)導致右側佈局/觸控異常:改為 0dp + start/end 綁 parent 確保全寬與右緣可點。
2. RecyclerVmcBuyListAdpter 讓整張商品卡片皆可點開詳情頁(原僅圖片可點),避免點到名稱/價格區無反應。
3. 商品詳情頁「直接購買」(購物車版)改為先切換至購物車品項頁、再關閉詳情頁,避免關閉對話框與 fragment 切換時序互相干擾導致未跳頁。
4. 升版號 verPatch 87 → 88。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:13:36 +08:00
sky121113
df96f33d5f [FEAT] 商品詳情頁直接購買分流與數量帶入;升版 v87
1. 詳情頁「直接購買」依購物車開關分流:購物車開啟時以詳情頁選定數量加入購物車後前往購物車列表頁(不直接結帳);購物車關閉(一般版)維持單品直購彈 BuyDialog 選付款。
2. 修正數量未帶入問題:加入購物車與直接購買統一走 addSelectedQuantityToCart(),依詳情頁數量逐件加入,點 N 件購物車即為 N 件。
3. 升版號 verPatch 86 → 87。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:44:19 +08:00
sky121113
bd23c31ba1 [FEAT] 商品詳情頁接線:點商品開詳情、依購物車開關呈現按鈕
1. 新增 DialogProductDetail:把原本只有佈局的商品詳情頁接活,顯示商品圖、多語名稱、貨道、庫存、數量與金額小計。
2. 依雲端 DevSet.ShoppingCar 呈現按鈕:購物車開啟顯示「數量調整+加入購物車+直接購買」;購物車關閉(一般版)隱藏「數量調整+加入購物車」,只留「直接購買」且數量固定 1。
3. 加入購物車以選定數量逐件加入(沿用 CartViewModel 庫存/8 件上限防呆);直接購買清空 buyList 放入本商品後走既有 BuyDialog 選付款。
4. BasicSaleFlow.handleProductSelected 改為點商品一律先彈出詳情頁。
5. RecyclerVmcBuyListAdpter 商品卡右下角「+」(buttonQuickAdd) 接線:僅購物車開啟時顯示並快速加購一件,關閉時隱藏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:16:29 +08:00
sky121113
0900dc20f9 Merge branch 'feat/general-cart-and-payment' into ui-design 2026-06-15 09:47:41 +08:00
sky121113
35af9fec8f [FEAT] 升版 v85 並打包 S9XYU(多語系 11 語+後台 API 對接+ui-design 合併)
1. verPatch 84 → 85,對應自 v84 以來累積的多語系擴充(法德西)、後台 B014 LangSet/B012 i18n 對接、ui-design UI 改版合併與 10 語系字串對齊等變更,供 OTA 版號對齊。
2. 產出 S9XYU Debug APK:versionName 21_01_85_R、versionCode 210185。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:36:57 +08:00
TaiwanStar Developer
eb0b9c8271 feat(ui): 遷移商品詳情對話框佈局並修復編譯資源錯誤
- 加入 dialog_product_detail.xml

- 移除本專案未支援的 RoundLinearLayout 與 RoundTextView 等自訂元件與屬性

- 替換缺漏的圖示、文字與顏色資源以維持正常編譯
2026-06-15 09:35:53 +08:00
sky121113
8e02e7adeb [FEAT] 補齊 10 語系合併後缺漏字串,全語系對齊主檔
1. ui-design 合併後主檔 values/strings.xml 新增的 15 個顧客可見字串(員工卡支付、下載/上傳同步狀態、購物車數量上限、商品出貨成功、櫃門取貨、確認取貨倒數、機器巡檢等),補譯進全部語系。
2. 英/簡中/韓/越/泰/印尼/法/德/西 各補 15 條;日文補 12 條(原已有 back/syncing/sync_completed)。
3. 補齊後 10 個語系皆與主檔 0 缺漏,XML 全部 well-formed,這批新 UI 字串不再 fallback 中文。

註:機器翻譯品質,customer-facing 重點詞(員工卡、載具/統編等)建議母語者校對。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:30:48 +08:00
sky121113
a80bb4dd85 Merge remote-tracking branch 'origin/ui-design' into feat/general-cart-and-payment
# Conflicts:
#	app/src/main/java/com/unibuy/v2/adapter/CartOperationAdapter.kt
2026-06-15 09:21:49 +08:00
sky121113
92553d96d2 [FEAT] 多語系擴充法德西並對接後台 B014/B012 多語 API
1. 新增法文(values-fr)、德文(values-de)、西班牙文(values-es)三語系資源,各 286 條以 en 為範本,App 支援語系達 11 種;LanguageHelper 的 SUPPORTED_LANGUAGES、getDisplayName、canonicalize 一併補上 fr/de/es。
2. 語言選單改由後台決定:StarCloudAPI.saveSettings 解析 B014 新增的 LangSet(Languages 清單 + Default),FontendActivity 選單改讀 LanguageHelper.getMachineLanguages(),只顯示該機台後台啟用的語系;未取得 B014 時退回全部支援語系。首次(使用者未手動選過)自動套用後台預設語系。
3. 商品名稱改讀 B012 新增的 t060v01_i18n 多語 map:StarCloudAPI.saveProducts 解析並帶入每個商品;ProductStructure.getLocalizedName fallback 鏈改為 i18n map 對應語系 → map 的 zh_TW → 舊 _en/_jp → 中文主值,韓/泰/越等語商品名可正確顯示。
4. 語系代碼雙向轉換:後台底線格式 zh_TW 與 App 標準 zh-TW 互轉(LanguageHelper.toBackendLocale 與 canonicalize)。
5. 零 DB 風險持久化:機台語系(machineLanguages/defaultLanguage)與商品多語 blob 均存於既有 MemoDao(比照 shopping_mode),不動 products 表 schema、不升 DB 版本,避免破壞性 onUpgrade 清除現場機台綁定資料。

註:商品規格 spec 後台已送 t060v03_i18n 但 App 尚無顯示 UI 故未接;舊 HttpAPI 解析路徑未加 i18n,會 graceful fallback 至 _en/_jp。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:15:53 +08:00
TaiwanStar Developer
c7134fe712 feat(ui): 更新發票選擇對話框 UI 佈局為垂直排列
- 將發票選擇對話框 (InvoiceTypeDialog) 根佈局改為 ConstraintLayout,並加入半透明深色背景。
- 將發票選項(手機條碼、捐贈發票、公司統編)改為垂直排列,並移除副標題文字。
- 新增 invoice_button_height 定義發票選項卡片的高度。
- 調整返回按鈕位置,並優化倒數計時文字顯示邏輯。
2026-06-15 09:03:37 +08:00
TaiwanStar Developer
a10a51ec10 UI: 優化商品卡片結構與樣式
- 新增快速新增按鈕
- 卡片版面區分為上下區塊,統一由最外層控制邊距
- 調整商品圖片為 1:1,並帶有 padding 白框效果
- 貨道與庫存資訊移至左上角垂直排列,分別套用圓角背景色
- 修正金額為 0 時不顯示的問題
- 售罄遮罩覆蓋全卡片,文字改為白色「售完」
2026-06-12 17:27:12 +08:00
TaiwanStar Developer
0a19e965a8 feat(cart): 優化購物車品項數量調整邏輯與介面顯示
- 新增單一品項與總數量的調整限制邏輯,達上限時按鈕切換為 disabled 狀態

- 移除數量調整按鈕的陰影跳動與裁切問題

- 新增增加、減少、刪除按鈕的水波紋點擊反饋

- 在品項金額後方新增「庫存xx件」資訊,並統一樣式

- 購物車達數量上限時,顯示「已達數量上限」警告文字
2026-06-12 13:35:39 +08:00
TaiwanStar Developer
e331e399bc feat(ui): 統一來店禮與通行碼的對話框樣式與輸入模式切換
- 參照取貨碼對話框 (dialog_pickup_code_fontend) 重新設計 dialog_free_gift_code.xml 與 dialog_access_code.xml
- 於 CheckoutFreeGiftCodeDialog 與 CheckoutAccessCodeDialog 加入鍵盤輸入與掃描條碼之模式切換邏輯 (btnModeKeypad / btnModeScan)
2026-06-12 12:16:08 +08:00
sky121113
5b8a0d7cf6 [FEAT] 新增遠端 update_settings MQTT 指令並收斂系統設定為唯讀;升版 v84
1. MqttService 新增 update_settings 指令分支:收到後重抓系統設定(B014),
   不串接商品/廣告同步,僅更新設定本身。
2. 忙碌防呆:交易/購物車/對話框進行中(getBusyReason 非空)時拒收並回 failed
   ACK,後台 RemoteCommand 約 1 分鐘 timeout 後可重發。
3. 重連時序修正:先發成功 ACK,再延遲 1.5 秒重連 MQTT,避免重連把送指令的
   連線斷掉導致 ACK 發不出去(涵蓋 token 輪換情境)。
4. StarCloudAPI.downloadSetting 新增可選 reconnectMqtt 參數(saveSettings 同步),
   綁定 UI 流程維持 true、update_settings 路徑傳 false 由呼叫端控制重連時機;
   無參數多載維持原行為,向下相容。
5. SystemSetActivity 收斂:機台端系統設定全面唯讀,唯一可編輯項為硬體貨道
   (彈簧/履帶,僅系統方);移除現金總開關連動,回寫雲端只送 is_spring_slot_*。
6. app/build.gradle verPatch 升至 84。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:01:50 +08:00
TaiwanStar Developer
2e70c16aa3 UI: 更新取貨碼視窗樣式與佈局
- 將「請輸入取貨碼」標題與副標調整為單一標題
- 調整視窗上方 padding 與標題間距
- 實作「鍵盤輸入」與「掃描條碼」的分段控制按鈕,包含背景、文字顏色變化與陰影效果
- 按鈕群組移除外框並增加陰影
- 返回按鈕置中並修改文字為「取消xx秒」
- 「輸入完成」按鈕修改為「確認」
- 掃描條碼圖片等比例填滿容器
2026-06-12 11:49:14 +08:00
sky121113
318bfde1fb [FEAT] 多語系擴充至 8 語並改用語言選單;升版 v83
1. 新增 5 個語系資源:簡體中文(values-zh-rCN)、韓文(values-ko)、越南文(values-vi)、泰文(values-th)、印尼文(values-in,採 Android 舊代碼 in),各 285 條以 en 已翻譯集合為範本,缺漏字串自動 fallback 回預設繁中。
2. LanguageHelper 重寫支援 8 語完整 locale:SUPPORTED_LANGUAGES 清單、canonicalize() 相容舊碼(tw/jp)並帶 region、buildLocale() 處理 zh-TW/zh-CN 與印尼資源代碼、getDisplayName() 以各語自身文字顯示。
3. 商品名稱多語以 toProductLang() 收斂:僅 en/ja 取對應翻譯,其餘語言(簡中/韓/越/泰/印尼)一律 fallback 中文主值(後台目前僅中/英/日)。
4. FontendActivity 語言入口由 3 顆內聯鈕改為單一 🌐 鈕開啟 AlertDialog 單選選單(showLanguagePicker),隱藏 En/Ja 舊鈕;切換後重綁定入口。
5. 新增字串 select_language 並補齊全 8 語系翻譯。
6. 升版號 verPatch 82 → 83。

註:泰文需於真機驗證系統字型支援;5 種新翻譯為機器品質,customer-facing 字串建議母語者校對。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:30:22 +08:00
sky121113
fdf02189fb [FEAT] 解除 App 多語系隱藏並收斂商品名稱多語邏輯;升版 v82
1. FontendActivity 重新顯示中/En/日語言切換鈕(移除 visibility=gone),點擊監聽改傳統一代碼 zh/en/ja。
2. LanguageHelper 新增 normalizeCode()/normalizedLang(),將歷史 tw、Locale zh、日文 jp 全部收斂為 zh/en/ja 一套,changeLanguage 一律儲存正規化代碼。
3. ProductStructure 新增 getLocalizedName(),依目前語言取商品名稱,英/日未翻譯時自動 fallback 回中文主值,作為商品名稱顯示的單一來源。
4. 各 adapter 改用 getLocalizedName():RecyclerVmcBuyListAdpter、CartOperationAdapter、RecyclerDialogBuyListAdpter(原被註解)、RecyclerProductListAdpter 與 RecyclerProductSearchAdpter(原永遠顯示中文),刪除各自重複的語言 switch。
5. applySavedLanguage() 集中至 base class AppCompatActivityAbstract.onCreate(),所有 Activity 於 inflate 前統一套用語言設定。
6. 升版號 verPatch 81 → 82。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:59:47 +08:00
sky121113
73dee29485 [FIX] 修復購物車倒數返回 VMC 時的 onSaveInstanceState 崩潰
1. FontendActivity.switchFragment:commit 前檢查 Activity 是否已達 STARTED,非前景則跳過並記 log;並將 .commit() 改為 .commitAllowingStateLoss(),作為背景切換的最終防線(切換僅為 Fragment hide/show,資料在 ViewModel,state loss 無實害)。
2. FontendActivity.openFragment:同步加上 lifecycle 防呆,避免背景狀態下 commitNow() 丟出 IllegalStateException。
3. ShoppingCartFragment.onFinish:原以 isAdded() 判斷無法擋住 onSaveInstanceState 後的 commit,改以 lifecycle 是否達 STARTED 為準,避免 180 秒倒數於背景觸發 backToVmc 導致崩潰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:35:20 +08:00
sky121113
a33c0c97bd Merge remote-tracking branch 'origin/ui-design' into feat/general-cart-and-payment 2026-06-12 08:47:50 +08:00
sky121113
39245a0608 [FIX] TapPay 圖示檔名改小寫修正 aapt2 命名違規;升版 v81
- ui-design 合併帶進的 TapPay30~34.png 用大寫檔名,違反 Android
  resource「只能 a-z/0-9/底線」規則,導致 mergeResources 失敗。
  改名為 tappay30~34.png(git mv 保留歷史)。圖示目前未被引用,
  改名不影響功能,保留供日後接線。
- verPatch 80→81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:47:43 +08:00
TaiwanStar Developer
f6028ffb80 UI: 優化購物車頁面排版與樣式
1. 商品列表滿版顯示,品項數量移至最上方並縮小字級。
2. 小記與總額樣式調整:小記字級縮小且改為黑色,總額改為主題色,並對齊底部。
3. 底部操作區重新排版:左右比例改為 4:6,對齊底部水平線。
4. 按鈕樣式更新:
   - 「繼續選購」:改為 outline button。
   - 「清空購物車」:改為 warning 顏色的 outline button,移除計時括號並縮小字級。
   - 「前往結帳」:寬度填滿容器的按鈕。
5. 背景與陰影調整:底部操作區增加陰影並移除外框;商品區背景色調整。
6. 購物車單品操作按鈕(增減、刪除)增加 elevation 陰影效果。
2026-06-11 17:20:30 +08:00
sky121113
79e1f69325 Merge remote-tracking branch 'origin/ui-design' into feat/general-cart-and-payment 2026-06-11 16:58:21 +08:00
sky121113
8d2adee5ab [FEAT] 支付方式「母&&子」集中判斷 + 入口防呆;對話框期間隱藏銷售平台浮層
- DevSetStructure 新增 canCreditCard/canCardPay/canMobilePay/canEsunPay/
  canTapPay/canLinePay/canCash,集中「母開關 && 子項」真相
- BuyDialog 購物車/單品/例外三分支改用上述便利方法,並在 changeCheckout
  入口以 isPaymentFlowAllowed 再次防呆,擋下被繞過的已停用支付
- 浮層連動:HIDDEN/SHOW_MULTILANGUAGE_BUTTON 改為 HIDE/SHOW_SALE_OVERLAY,
  對話框與購物車 Fragment 期間統一隱藏取貨碼/購物車浮鈕與上下系統列
- 無操作逾時 3 分鐘調整為 5 分鐘;verPatch 79→80

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:58:14 +08:00
TaiwanStar Developer
b0f7381832 新增 TapPay 相關圖示資源 2026-06-11 16:21:49 +08:00
TaiwanStar Developer
5af9a94a0c 優化購物車品項排版與按鈕樣式
1. 新增按鈕背景與 Icon 向量資源
- 新增 bg_quantity_outline_button.xml:為加減按鈕設計的背景,底色為頁面背景,帶有 1.5dp 黑色圓角外框。
- 新增 bg_waste_outline_button.xml:為移除按鈕設計的背景,底色為頁面背景,帶有 1.5dp 警告色圓角外框。
- 新增 add_24.xml、remove_24.xml、close_24.xml:將原本填充式圖示改寫為純線段,設定黑色線條且加粗 (strokeWidth="3")。
- 新增 close_warning_24.xml:移除按鈕專用的叉叉圖示,線條同樣加粗,顏色改為警告色。

2. 優化購物車清單排版 (recycler_cart_operation.xml)
- 圖片縮小:將商品圖片寬高從 120dp 縮減為 80dp。
- 文字調整:將商品名稱字級放大至 24sp,並將字重調整為 normal。
- 將單一品項金額設為隱藏,並將小計金額從最右側移至商品名稱下方。
- 加減按鈕:尺寸調整為 44dp 並加上 10dp padding,套用新的黑色外框背景與加粗 Icon。
- 移除按鈕:尺寸同樣調整為 44dp,改用 Warning 樣式外框與 Icon,並加大左側邊距 (marginStart=32dp) 以拉開間隔。

3. 其他連帶變更
- 包含 fragment_shopping_cart.xml 與多語系 strings.xml 的相關微小變動。
2026-06-11 16:21:49 +08:00
TaiwanStar Developer
ee732e03a3 UI: 更新購物車與取貨碼懸浮按鈕樣式及動態角標
- 將購物車與取貨碼改為統一的帶圓角 CardView 容器設計,使用 surface 背景與黑色圖示文字。
- 修正陰影裁切問題,移除外層容器的 clipChildren 限制。
- 購物車新增右上角紅色 Badge 顯示當前選取的商品總數,並與 CartViewModel 動態綁定。
- 調整懸浮按鈕位置,使其與底部機台資訊列保持 24dp 的合適間距。
- 新增 Google Material Icons: package_24 與 shopping_cart_24。
- 同步更新相關的 Agent 發版規則文件,移除廢棄的多維度 flavor 定義。
2026-06-11 15:18:38 +08:00
sky121113
fc21186a53 [FEAT] 來店禮串接 B690 + 掃碼即時回饋與空窗修正、通行碼購物車模式封堵
1. 來店禮改串新端點 B690(取代舊 HttpAPI.freeGiftCode):
   - StarCloudAPI 新增 verifyWelcomeGift(),POST machine/welcome-gift/verify/B690(Bearer 機台 token,body {code})。
   - CheckoutFreeGiftCodeDialog 手動輸入與掃碼皆改走 B690;折扣映射 amount→mode1(折抵金額)、percentage→mode2(折扣趴數換算總價乘數,八折20→0.8)。
   - 加 isWelcomeGift API 防護;非 amount/percentage 型別擋下提示。
   - 注意:B690 無舊「免費商品」型,純折扣。
2. 掃碼 UX 空窗修正(取貨碼/通行碼/來店禮三個對話框):
   - 掃描器啟動延遲 5 秒→0,對話框一彈出即監聽,解決「剛彈出馬上掃會整個丟失、時好時壞、無回饋」。
   - 讀取窗口 7→9 次(約 45 秒),覆蓋 40 秒對話框存活時間。
   - 新增 SET_SCAN_INPUT:掃到的碼即時顯示在輸入框;送出前顯示「驗證中…」。
3. 通行碼購物車模式封堵:
   - BuyDialog 購物車模式的付款清單中,通行碼(buttonAccessCode)從未 gating;改在 onCreate 加權威判斷,需 isPickupModule && isPassCode 才顯示,涵蓋購物車/單品所有模式。
4. 版號 verPatch 76→79。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:09:42 +08:00
sky121113
2683b850a2 Merge remote-tracking branch 'origin/ui-design' into ui-design 2026-06-11 14:12:22 +08:00
sky121113
e8b3185b8a [FIX] 取貨碼/通行碼:封堵付款備援漏出、API 防護、掃碼即時回饋
通行碼漏洞封堵
- BuyDialog 單品模式 NFC 不支援時會強制顯示通行碼(buttonAccessCode)當備援,
  未判斷 isPassCode;改為從屬取貨模組(isPickupModule && isPassCode)才顯示

API 防護(defense-in-depth,UI 漏出入口也擋下)
- submitPassCode/submitPickupCode 開頭加守門:功能關閉即不呼叫 B670/B660,狀態列提示「功能未開放」

掃碼 UX 回饋
- 新增 SET_SCAN_INPUT 事件:掃碼後即時把掃到的碼顯示在輸入框
- 送出驗證前顯示「驗證中…」狀態,取代原本易忽略的遮罩 Toast,避免使用者誤以為沒掃到
- 版號 verPatch 75→76

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:11:06 +08:00
sky121113
018da036b1 [FEAT] B014 系統設定全面接入:取貨模組/來店禮 gating、購物車單品直購、購物方式顯示與設定權限分流
雲端 B014 設定解析與持久化(StarCloudAPI / MyApp / DevSetStructure)
- FunctionSet 改接 PickupModule/WelcomeGift/MemberSystem/AmbientTemp(原僅 PickupCode/PassCode)
- DevSet 補接刷卡機/掃碼支付細項;新增 HardwareSet 貨道型式(彈簧/履帶)解析
- B000 登入解析 identity/loginToken,供系統設定唯讀身分判斷與 B016 回寫
- 新增 bootstrap 預設(devset_defaults)、initialSystemSet/saveSystemSet DB 持久化

前台流程 gating
- 取貨碼、通行碼從屬「取貨模組」(PickupModule):模組關閉時連動隱藏(畫面與流程都不出現)
- 來店禮獨立判斷(WelcomeGift):原按鈕恆亮,改為依雲端開關顯示
- BasicSaleFlow:購物車(ShoppingCar)關閉時點商品改單品直購,清空後僅放本品並直接開 BuyDialog

系統設定頁(SystemSetActivity / activity_system_set / styles)
- 新增「購物方式」唯讀顯示(基礎版/員工卡/取貨單);僅 basic 展開其下可設定項
- 權限分流:商業政策項(購物車/發票/各支付方式/取貨模組/來店禮/會員)恆唯讀,
  機台實體項(刷卡機/掃碼/現金總開關、面額、環溫、硬體貨道)系統方可改並寫回雲端

其他
- DispatchDialog 成功結案清空 v2 購物車;ShoppingCartFragment 調整數量續命倒數
- 版號 verPatch 72→75

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:33:57 +08:00
TaiwanStar Developer
3b1764b75c feat: 新增商品出貨成功與機器巡檢中 UI 彈跳視窗 2026-06-11 10:24:04 +08:00
sky121113
b56c539549 [CHORE] 新增 .editorconfig 防止 XML 檔頭 UTF-8 BOM(aapt2 打包防呆)
供 UIUX 設計師直接在本 repo 改 UI 時,編輯器不再寫入 BOM 導致 aapt2 失敗。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:09:23 +08:00
sky121113
42947dce0c [FIX] DispatchDialog 關閉未結案補送 MQTT Finalize,修正多品項部分出貨不上報
1. 問題:出貨流程若走 dialogCancel() 收場(倒數結束 / dispatchIndex 越界 /
   中途中止),整筆交易完全不上報。批次 MQTT Finalize 僅在「成功完成」與
   「ERROR_PROCESS_GOODS 失敗」兩條路送出,dialogCancel 這條共通關閉路徑漏掉。
   此缺口於 main 既存,因通用版原無多品項出貨、Cmuh 失敗都走 ERROR_PROCESS_GOODS
   而未被觸發;多品項 basic 出貨為首次暴露。

2. 修正:在 dialogCancel() 開頭、buyList.clear() 之前加安全網——
   若出貨已開始(dispatchList 非空)且尚未結案(!isFinalizing),
   補呼叫既有 finalizeFailedDispatch() 送出一筆含全部明細的部分/未完成結案。

3. 守門:以 isFinalizing 擋住正常成功(:946)/錯誤(:1062)路徑,零重複上報;
   通行碼 flowType=5 仍由 publishFinalizeUnlessPassCode 自動略過,
   對晟崴/中國醫現行行為零影響。

雙硬體編譯綠。實機驗證待硬體(多品項第2件失敗→後台收到一筆部分出貨)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:47:10 +08:00
sky121113
1d4c4ab071 [FEAT] Phase3 購物車:移植 ShoppingCart 商品列外觀(表面移植,沿用現有 v2 購物車)
- recycler_cart_operation 改用 ShoppingCart 設計(120dp 圖、QuickCart 字樣、primary 色、56dp 加減鈕)
- 保留 buttonWaste 刪除鈕與 totalPriceTextView 小計(現有 CartOperationAdapter/ShoppingCartFragment 沿用)
- 新增 TextAppearance.App.QuickCart.Medium / .Small 樣式
- 不動 FontendActivity 架構與抽屜(DialogXManager/FlowerFragment 不在表面移植範圍)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:36:00 +08:00
sky121113
998b3ae89f [FEAT] Phase2 支付:dialog_buy 支付選單改用 Travis 版(沿用現有 BuyDialog 邏輯)
- dialog_buy 換成 Travis 付款選擇版面(gridPaymentIcons + ConstraintLayout Flow)
- binding 契約相符(layoutNfcPay 在現有 BuyDialog 已停用、逐顆控制),無需改 Java

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:19:49 +08:00
sky121113
33d79076a6 [FEAT] Phase2 發票:完整移植 Travis 發票設定新流程
- 新增 InvoiceTypeDialog(統一類型入口:載具/捐贈/統編)
- 新增 InvoiceDonationDialog + RecyclerDonationListAdpter(捐贈機構 RecyclerView)
- 新增 dialog_invoice_type / dialog_donation 版面 + bg_selected_item
- BuyDialog 發票入口改走 InvoiceTypeDialog(取代直接進 InvoiceBarcodeDialog)
- InvoiceBarcodeDialog:捐贈鈕改導向新 InvoiceDonationDialog,移除舊 inline
  showDonationDialog(其引用的舊 dialog_donation id 已不存在)
- 沿用現有 main 發票資料流(getInvoiceData/setLoveCode、ReportFlowInfoData)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:18:21 +08:00
sky121113
73e2b55c53 [FEAT] Phase2 發票:移植 Travis 統編/載具對話框外觀(沿用現有 Java)
- dialog_customer_identifier / dialog_invoice_barcode 換 Travis 版面
- 新增 bg_keypad_button / bg_step_active / bg_step_inactive 與 color/outlineVariant
- binding 契約相符,無需改 Java

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:07:39 +08:00
sky121113
696e6cb952 [FEAT] Phase2 通行碼/取貨碼:移植 Travis 對話框外觀(沿用現有 B660/B670 邏輯)
- dialog_access_code / dialog_pickup_code_fontend 換 Travis 版面
- binding 契約相符,無需改 Java(保留現有通行碼/取貨碼邏輯)
- 新增 color/transparent

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:05:21 +08:00
sky121113
d4fe3c6cf4 [FEAT] Phase2 支付流程:移植 Travis 現金/玉山結帳對話框外觀
分區授權:checkout 對話框採 Travis 設計,邏輯沿用現有 main。
- dialog_checkout_cash_pay / dialog_checkout_esun_pay 換 Travis 版面
- 補回 buttonRetryEscrow(CashPayDialogGeneral 退鈔重試邏輯沿用),以新 Material 樣式呈現
- 新增 pay_* 付款圖檔、icon_network_poor/temperature、img_reload
- 新增 colors infoContainer / surface

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:03:43 +08:00
sky121113
c24f409674 [FIX] 移除 origin/ui 帶入的 UTF-8 BOM(aapt2 無法解析導致打包失敗)
設計師端編輯器在 3 個 XML 開頭寫入 UTF-8 BOM,aapt2 報
「Cannot read field elmName because root is null」。移除 BOM 後 S12XYU/S9XYU
Debug 皆可正常打包。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:40:36 +08:00
sky121113
3d798eb94d [FEAT] 整合 UIUX origin/ui 改版(後台/貨道/商品卡/員工卡/出貨/StarCloud 圖示)
合併 origin/ui 設計師改版:前台商品卡、後台首頁/主控、貨道卡片式 Grid(停售遮罩、
一鍵補貨配色)、商品搜尋、員工卡取貨、出貨清單與進度對話框、各 checkout 對話框版面、
StarCloud App 圖示與名稱、付款管道/接受付款圖檔、switch 色票 selector。

衝突處理:
- 3 個 Cmuh res/layout 覆寫檔(activity_fontend、dialog_pickup_code_buy、
  dialog_pickup_code_fontend)已於 ab3b53d 收斂進 main,保留刪除,由 main 對應佈局承載。
- origin/ui 移除了 colors.xml 的 @color/red,但重構後移入 main 的 fragment_shopping_cart
  仍沿用,補回 red 定義避免 aapt 連結失敗。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:37:14 +08:00
sky121113
ab3b53da99 [REFACTOR] 收斂 project 維度為執行期 shopping_mode + B014 雲端設定唯讀同步
1. B014 雲端設定下發 → App 解析寫入 DevSet 並持久化:
   - StarCloudAPI.saveSettings() 解析 DevSet(14 開關)/CashSet(7 面額)/
     FunctionSet(取貨碼 PickupCode、通行碼 PassCode)/ShoppingMode;
     缺漏粒度區分(整個物件缺→不動本地防誤關;物件內 key 缺→視為 false)。
   - MyApp 修 TapPay30~34 持久化 bug(saveSystemSet 補序列化、initialSystemSet
     改 optBoolean 防舊紀錄 crash),並新增 PickupCode/PassCode/ShoppingMode 持久化。
   - DevSetStructure 新增 isPickupCode/isPassCode 欄位。
   - SystemSetActivity 全部 21 個 checkbox 鎖唯讀、返回鈕只 finish() 不回寫。
   - 取貨碼浮鈕(FontendActivity)改吃 DevSet.isPickupCode()、通行碼入口(BuyDialog)
     改吃 DevSet.isPassCode(),取代舊 flavor 資源/寫死。

2. 收掉 project flavor 維度,三種購買流程改由雲端 shopping_mode 執行期切換:
   - SaleFlowHandler 改為 main 抽象基底 + create() factory,依 shopping_mode 回傳
     BasicSaleFlow(basic/原 General)、EmployeeCardSaleFlow(employee_card/原 Chengwai)、
     PickupSheetSaleFlow(pickup_sheet/原 Cmuh) 三策略。
   - 三 flavor 的 dialog/adapter/Kotlin 購物車/Cmuh 醫令類別全併入 main;重複類別去重。
   - FontendPickupCodeDialog 內部依 shopping_mode 分支(basic/employee 走 B660、
     pickup_sheet 走舊 memberVerification 並跳過取貨口檢查)。
   - fragment_vmc 合併掃描單據按鈕,VmcFragment 依 shopping_mode toggle 可見性。
   - General strings(zh/en/ja)、drawable、購物車 layout 併入 main;
     devset_defaults 併為 basic-mode bootstrap 預設。

3. 取代 BuildConfig.FLAVOR_project 編譯期分支:
   - AppEntry.isCmuh() 改判 shopping_mode == pickup_sheet。
   - DispatchDialog 6 處 Cmuh 分支改用 AppEntry.isCmuh()。
   - ComPort.getFilepath() 改認硬體維度 BuildConfig.FLAVOR(S12XYU→ttyS7/S6、
     S9XYU→ttyS1/S2),與購買流程正交;對現役機台行為不變。

4. build.gradle 移除 project 維度,flavorDimensions 只留 hardware;輸出檔名去掉
   project 段(app-S_12_XY_U-<ver>-debug.apk)。刪除 app/src/{General,Chengwai,Cmuh}/。

5. compiler-apk / release-apk(workflow + .claude/commands)改以硬體規格 S9/S12 為
   打包對象,OTA keyin 改依硬體分版並提示 shopping_mode 部署鐵律。

6. verPatch 69→70(供 OTA 遠端更新)。

雙硬體 clean 編譯回歸 S12XYU/S9XYU 全綠。實機三流程驗證待硬體。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:28:59 +08:00
sky121113
2c3255cb64 [RELEASE] 升級修補版號 verPatch 68→69(供 General 下位機修正 OTA 更新)
ComPort 修正後需遞增版號讓現場機台可觸發 OTA 覆蓋更新。
General S9XYU → versionCode 210169 / versionName 21_01_69_R。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:14:48 +08:00
sky121113
275d6d1fa9 [FIX] 通用版下位機 COM port 改為與中國醫相同
General 的下位機硬體與中國醫(Cmuh)相同(晟崴為最新硬體),故 COM port
應接 ttyS1/ttyS2 而非原本的 ttyS7/ttyS6。

ComPort.getFilepath() 改為:只有晟崴(Chengwai) 用最新的 ttyS7/ttyS6,
其餘(General + Cmuh)用 ttyS1/ttyS2。晟崴/中國醫接線不變,僅 General 調整。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:58:23 +08:00
sky121113
588da4a2eb [CHORE] APK workflow 強殺防呆 + 新增 Claude Code slash command
- .agents/workflows/{compiler,release}-apk.md:Gradle Daemon 終止改防呆——
  先優雅 ./gradlew --stop,僅在仍殘留時才升級終止(先 TERM 再 KILL),
  避免無差別 pkill -9 撞鎖導致 clean 編譯隨機失敗。
- .claude/commands/{compiler,release}-apk.md:新增對應 slash command(薄包裝,
  指向 .agents/workflows 單一真相來源,處理參數注入與 Java 17 環境)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:25:48 +08:00
sky121113
53ea3dafa9 [FEAT] 取貨碼金額歸零 + 通行碼不上報交易
- 取貨碼(FontendPickupCodeDialog):setTotalPrice(0),因屬已預付來取貨/
  付款未出貨補出貨,本機交易付款金額為 0。
- 通行碼(payment_type=5):工程師/內部硬體測試用,DispatchDialog 照常出貨
  測試硬體,但略過 MQTT 交易結案上報(避免污染銷售/營收、不扣雲端庫存;
  B670 驗證已於後台記 log)。集中於 publishFinalizeUnlessPassCode(),
  成功/失敗兩路徑共用。

三 flavor 編譯/打包通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:01:51 +08:00
sky121113
1720d2007e [FEAT] 取貨碼/通行碼改走 StarCloudAPI B660/B670(比照員工卡 B680)
舊的 httpAPI(memberVerification / checkAccessCode B024)改為新 StarCloudAPI:
- StarCloudAPI 新增 verifyPickupCode(B660,回 code_id+slot_no)、
  verifyPassCode(B670,回 code_id),及 PickupVerificationCallback。
- 通行碼 CheckoutAccessCodeDialog:手動+掃碼兩入口改走 verifyPassCode,
  成功設 flowType=5、保存 accessCodeId 供 MQTT finalize(payment_type=5)。
- 取貨碼 FontendPickupCodeDialog:兩入口改走 verifyPickupCode,依回傳 slot_no
  建立 BuyList、設 flowType=6、保存 accessCodeId(payment_type=6),
  進入 BuyPickupCodeDialog 確認 → DispatchDialog;文案修正為「取貨碼」。

finalize 的 code_id 取自 ReportAccessCodeStructure.accessCodeId、payment_type
取自 flowType,App 端 TransactionFinalizeBuilder 既有支援。三 flavor 編譯/打包通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:52:20 +08:00
sky121113
f10f6f5879 [FEAT] General 接通取貨碼入口(供實機測試)
- General SaleFlowHandler.onPickupRequested() 開啟 FontendPickupCodeDialog
  (原為空,導致取貨碼點不到)
- 前台取貨碼浮鈕改由 flavor 資源 show_pickup_code_button 控制顯示:
  General=true 顯示,main/晟崴/中國醫=false 維持原本隱藏行為

至此 General 取貨碼與通行碼皆可實機測試。三 flavor 編譯/打包通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:42:30 +08:00
sky121113
e8856d0a8b [FIX] 通行碼 Dialog 防呆強化(Part 2-3)
CheckoutAccessCodeDialog.changeOk()(自 Luzui 移植防呆):
- 加 3 秒防連點,避免重複送出驗證
- 輸入加 .trim() 與 isEmpty 過濾
- 修正誤植文案「統一編號長度不能少於 8 碼」→「通行碼長度不能少於 8 碼」

三 flavor 完整 APK 打包成功(General/Chengwai/Cmuh)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:07:56 +08:00
sky121113
2315a32f56 [FEAT] General 現金強化版合併(Part 2-2)
移植 Luzui 現金 Dialog 的硬體容錯邏輯(卡鈔偵測+退鈔重試、零錢不足拒收+
退款保障、暫存區兩階段入帳、找零失敗自癒、百元鈔庫存輪詢、3分鐘閒置逾時),
上報層改接 TaiwanStar 新 MQTT(移除 B600/B650,改 DispatchDialog 統一 Finalize)。

採「獨立類別 + flavor 路由」避免 duplicate class、不污染已上線 flavor:
- 新增 src/General CashPayDialogGeneral(Luzui 強化版改名,只 General 編譯)
  + General layout override dialog_checkout_cash_pay.xml 補 buttonRetryEscrow
  - showDispatchDialog 改 MQTT 路徑、移除 callB600AndProceed/httpAPI
  - onCreate(Context) 移除 throws(對齊 TaiwanStar DialogAbstract 簽名)
- DialogAbstract.cashDialogClass() 經 SaleFlowHandler 解析各 flavor 現金 Dialog
- 三個 SaleFlowHandler 加 cashDialogClass():General→CashPayDialogGeneral、
  晟崴/中國醫→標準 CashPayDialog
- 3 個呼叫點(BuyDialog/InvoiceBarcodeDialog/InvoiceCustomerIdentifierDialog)
  case 9 改 changeDialog(cashDialogClass())

三 flavor 編譯通過、General APK 打包成功。現金硬體容錯需實機驗證(階段 7)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:05:59 +08:00
sky121113
402490ee58 [FEAT] General 付款選單啟用 + BuyDialog NFC 顯示控制(Part 2-1)
- DevSet 初始預設值改由 res/values/devset_defaults.xml 提供,各 flavor 可覆寫;
  main 維持原值(Chengwai/Cmuh 行為不變),General override 為購物車 + 現金/
  刷卡/玉山/LINE Pay/發票全開,裝上即可用。
- BuyDialog 購物車分支:NFC 卡/儲值/手機改逐顆依 isDevNFCPay 控制顯示
  (TaiwanStar 無 layoutNfcPay 容器,修正原本指向不存在 view 而被註解的問題)。

三 flavor 編譯通過、General APK 打包成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:48:07 +08:00
sky121113
cfc802dbb5 [FEAT] General flavor 多品項購物車(Part 1)
移植 Luzui v2 Kotlin MVVM 購物車到 General flavor,接 TaiwanStar 既有
結帳鏈(BuyDialog → 付款 → DispatchDialog → MQTT 結案)、沿用 VMC 出貨。

- build:啟用 Kotlin/lifecycle/BaseRecyclerViewAdapterHelper(全域 classpath,
  cart .kt 僅在 src/General 編譯,不影響 Chengwai/Cmuh 行為)
- 移植至 src/General:CartViewModel/CartOperationAdapter/CartOperationResult/
  SafeCountdownUtil/UnibuyHelper/ShoppingCartFragment + layout/drawable/三語 strings
  - 移除 Luzui 的 updateLocalGoods(FLOWER/DAO)、DialogXManager、HttpAPI、格子機依賴
  - 結帳改 convertToBuyStructure() + SaleFlowHandler.onBuyRequested()
- 共用檔最小改動(對 Chengwai/Cmuh 零行為影響):
  - SlotStructure 加 cartSelectedCount
  - FontendActivity 加 openFragment()/backToVmc()、購物車浮鈕改走 onShoppingCartRequested()
  - RecyclerVmcBuyListAdpter 點商品改走 flavor hook handleProductSelected()
  - 三個 SaleFlowHandler 加 onShoppingCartRequested()/handleProductSelected()
    (General:加入購物車;Chengwai/Cmuh:維持原行為)

三 flavor 編譯通過、General APK 打包成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:45:11 +08:00
TaiwanStar Developer
404ba69fba chore: 修改 APP 圖示與顯示名稱為 StarCloud 2026-06-09 16:58:11 +08:00
TaiwanStar Developer
8e4a0993c7 style: 優化首頁購物按鈕樣式與文字,並新增 logo 資源
- 優化首頁 (activity_home.xml) 的購物按鈕 (startButton) 樣式:
  1. 寬度由 0dp 改為固定寬度 400dp。
  2. 按鈕文字由「購物去」調整為「開始購物」。
  3. 背景與文字顏色改為套用主題色系 (@color/primary 與 @color/onPrimary)。
- 新增星雲 LOGO 圓形圖示資源 (starcloud_logo_circle.png)。
2026-06-09 16:46:11 +08:00
TaiwanStar Developer
4d7717edc3 style: 優化後台貨道清單介面佈局與狀態顯示
- 狀態辨識度提升:
  1. 貨道編號新增「貨道」前綴字樣。
  2. 販售與停售狀態加入顏色識別,文字與 Switch 顏色會根據狀態動態切換(成功色/警告色)。
- 佈局與操作體驗優化:
  1. 重新排列操作區塊,將「清空貨道」按鈕移至卡片最下方。
  2. 放大庫存加減按鈕的觸控範圍(高度調整為 60dp)與字體大小,提升機台觸控體驗。
  3. 「清空貨道」按鈕改用 warning 警示色系。
- 視覺微調:移除卡片外框線,使列表視覺更為乾淨。
2026-06-09 16:14:36 +08:00
sky121113
5f480d6cb6 [FIX] 修正非中國醫專案的物料代碼一致性驗證邏輯並升級修補版號
1. 於 VmcSlotListActivity 限制 validateMaterialCodeConsistency 僅在中國醫 (CMUH) 專案下執行,其他專案直接跳過。
2. 調整 app/build.gradle 的修補版號 (verPatch) 變數值,由 67 遞增至 68。
2026-06-09 16:00:58 +08:00
TaiwanStar Developer
b1732987ce feat: 優化商品搜尋介面並修復後台貨道編輯 Bug
- 商品搜尋介面大改版:
  1. 搜尋列表從單欄改為 3 欄網格排列,並導入 MaterialCardView 重新設計商品卡片。
  2. 搜尋框體驗優化:隱藏原生圖示,新增清空按鈕;支援點擊列表或候選字時自動收起鍵盤。
  3. 點擊候選字邏輯修改為直接套用完整字詞。
  4. 擴大點擊範圍,改為點擊整張商品卡片即可選取。
- 貨道編輯清單修復:
  1. 將 textCount 隱藏狀態改為 INVISIBLE 解決 GONE 導致輸入框寬度壓縮為 0 的問題。
  2. 輸入數量時改用 numberPassword 並移除密碼遮罩,以喚起乾淨的純數字鍵盤。
- 資源與樣式:
  1. 新增 button_outline_black.xml 黑框按鈕樣式。
  2. 修正圖片檔名大小寫 (eTicket -> eticket)。
2026-06-09 15:59:36 +08:00
TaiwanStar Developer
d917f6445c style(ui): 將出貨頁面標題改為灰色背景與黑色文字 2026-06-08 15:33:34 +08:00
TaiwanStar Developer
d1bf7d18ce style: 調整出貨清單 UI 的文字顏色與出貨中狀態格式
- 將商品名稱、順序(槽位號)與已出貨數量資訊的文字顏色改為黑色(@color/black)

- 將出貨中狀態的格式「出貨中(1)」改為「出貨中(1件)」
2026-06-08 14:34:26 +08:00
TaiwanStar Developer
f2ed393686 style(ui): 優化出貨進度對話框結構、版面自適應與配色調整
- 頁面標題改為「出貨進度」,背景調整為主色 (primary)
- 新增並更新多國語言字串 (processing, dispatch_progress)
- 重構底部的當前出貨進度區塊結構,移除冗餘嵌套,將區塊背景設為 cardSurface
- 取消按鈕樣式改為 warning/onWarning 配色,扁平化結構並去除冗餘 FrameLayout
- 清單區域高度改為 weight=1 自適應剩餘空間,移除 Space 元件,背景改為 background 色
2026-06-08 13:59:32 +08:00
TaiwanStar Developer
907d5095d5 style: 重構員工卡支付對話框佈局並更新身分證/員工卡相關圖檔資源 2026-06-08 11:54:15 +08:00
TaiwanStar Developer
28a3d623f0 feat: 新增支付管道與接受付款類型相關圖片資源 2026-06-08 09:53:40 +08:00
TaiwanStar Developer
2e71cc29b8 feat: 修改 VMC 貨道清單之未配置商品圖示、卡片背景色與一鍵補貨按鈕配色 2026-06-05 17:24:37 +08:00
TaiwanStar Developer
8f6b5217d6 feat(ui): 貨道停售中狀態時在商品圖片上方顯示透明黑色遮罩 2026-06-05 16:19:51 +08:00
TaiwanStar Developer
5d9c5ba5cf style(ui): 調整貨道管理儲存與一鍵補貨按鈕配色,並將 Switch 選中狀態設為 primary 色 2026-06-05 16:18:26 +08:00
TaiwanStar Developer
a933bb22f6 feat(ui): 改造貨道管理列表為卡片式 Grid 佈局與 Adapter 邏輯對接 2026-06-05 16:16:27 +08:00
TaiwanStar Developer
aed8d9bc1f style(ui): 依據參考介面調整貨道管理版面
調整項目:
1. 頂部區域:保留「貨道管理」標題,省略機櫃選擇與命名的部分。
2. 列表區域:將 recyclerSlotList 改為 Grid 網格排版,設定 spanCount 為 3。
3. 底部區域:省略批量匯出、批量匯入按鈕。將「返回」與「清空」按鈕設為隱藏以維持程式碼 ViewBinding 相容性。
4. 儲存按鈕:原「上傳後台並返回」按鈕文字修改為「儲存」,並維持原有點擊與同步邏輯。
2026-06-05 15:40:36 +08:00
TaiwanStar Developer
54e0c9ffa5 style(ui): 調整廣告區域容器比例至 21:9 與等比例縮放
1. 將廣告區域容器比例從 16:9 調整為 21:9

2. 設定影片播放器屬性為 app:resize_mode=zoom

3. 設定廣告圖片屬性為 android:scaleType=centerCrop
2026-06-04 16:28:34 +08:00
TaiwanStar Developer
02ae6623d9 style(ui): 調整前端首頁資訊排版與背景色
1. 將版本號與 TC/VC 移至最底部釘選

2. 頂部資訊順序改為「時間 | 溫度 | 網路」,文字改為 @color/gray

3. 上下區塊背景改為 @color/light_gray,文字改為 @color/gray

4. 頁面背景及商品卡片區域背景改為 @color/background
2026-06-04 15:41:56 +08:00
TaiwanStar Developer
12e61d2a10 feat(ui): 替換 VMC 購買清單為新版佈局並調適 Adapter
1. 替換 recycler_vmc_buy_list.xml 為新版 CardView 結構,微調定位使 textPrice 相容舊版價格邏輯
2. 將貨道 textSlot 移至左上角,大小設為 18sp,顏色改用 primary/onPrimary 語意色
3. 將庫存 textStock 獨立定位於右下角,大小設為 18sp,顏色改用 gray/black 資源
4. 修改 RecyclerVmcBuyListAdpter 綁定庫存數量,並將貨道文字調整為「貨道value」無補零格式
2026-06-04 15:24:22 +08:00
TaiwanStar Developer
c7d75a8522 fix(layout): 移除佈局 XML 檔案的 UTF-8 BOM 檔頭
修復 DataBinding 元件在 Gradle 建置過程中因 XML 檔案包含 BOM (Byte Order Mark) 檔頭而解析失敗的問題。

原因:XML 檔頭的前導字元 EF BB BF 導致 LayoutFileParser 在解析 root 節點時發生 NullPointerException。

調整項目:- 批次移除 layout 目錄下所有 XML 佈局檔案的 UTF-8 BOM 檔頭。
2026-06-04 14:22:32 +08:00
TaiwanStar Developer
c7b8372dfc style(ui): 簡化色彩系統設定與對齊佈局配置 2026-06-04 13:57:06 +08:00
277 changed files with 23454 additions and 6799 deletions

View File

@ -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` 自動連動生成,**嚴禁手動修改輸出檔名**。
---

View File

@ -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可在無串口設備的手機上正常運作不崩潰。

View File

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

View File

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

View File

@ -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 發版追溯標記指南"

View 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 檔名)。

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

View File

@ -24,7 +24,8 @@
- 禁止直接手改成品 `versionCode`/`versionName` 值。
- 版號管理透過 `verMajor`、`verMinor`、`verPatch` 變數。
- OTA 發版時,`versionCode` 必須嚴格遞增。
- 中國醫現場版 (`Cmuh`) 修正預設以 `:app:assembleCmuhS9XYUDebug` 驗證。
- 21.5 吋大螢幕現場版(如中國醫)修正預設以 `:app:assembleS9XYUDebug` 驗證。
- 10 吋一般現場版(如晟崴、通用)修正預設以 `:app:assembleS12XYUDebug` 驗證。
## When Rules Conflict

View File

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

View File

@ -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 {
}
// =====================================================================
// Flavorproject× hardware
// Flavorhardware / 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 Rdebug 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 / MVVMbasic
implementation libs.kotlin.stdlib
implementation libs.lifecycle.viewmodel.ktx
implementation libs.lifecycle.livedata.ktx
implementation libs.lifecycle.runtime.ktx
implementation libs.brvah
}

Binary file not shown.

View File

@ -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() {
// 晟崴版不處理取貨碼
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
// 通用版不處理取貨碼
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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); // 同時支援影片與圖片輪播

View File

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

View File

@ -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 的寫檔門檻等級預設 INFOdebug.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 = "";
// 商品名稱多語 mapB012 t060v01_i18nkey=商品IDvalue=底線代碼名稱 JSONObject
private JSONObject productNameI18nBlob = new JSONObject();
private JSONObject productSpecI18nBlob = new JSONObject();
// 商品彈簧補貨上限 mapB012 spring_limitkey=商品IDvalue=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 回傳 identitysystem=系統方可編輯系統設定並回寫雲端/ 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);
}
}
// 商品名稱多語 blobB012 t060v01_i18n離線重啟時供 fallback
if (memoDao.existOnlyOne("ProductNameI18n")) {
MyApp.getInstance().setProductNameI18nBlob(memoDao.getOnlyOne("ProductNameI18n").getData());
}
// 商品規格多語 blobB012 t060v03_i18n離線重啟時供 fallback
if (memoDao.existOnlyOne("ProductSpecI18n")) {
MyApp.getInstance().setProductSpecI18nBlob(memoDao.getOnlyOne("ProductSpecI18n").getData());
}
// 商品彈簧補貨上限 blobB012 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;
}

View File

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

View File

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

View File

@ -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/1vmcData 不足 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");

View File

@ -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);
// v1v2雙向 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 設為 1synced 仍為 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());
}
}
}

View File

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

View File

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

View File

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

View File

@ -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 自動重啟後又搶回串口
*
* serialReleasedtrue
* - 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();
}
}

View File

@ -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
// 未設定時的預設方案通用版本為 LEGACYttyS1/ttyS2
// ️代樂美等星科技主板專用建置需改為 STARTECHttyS7/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";
}
}

View File

@ -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 7searchSuPath 找不到 su suPath null
// 若直接 Runtime.exec(null) NPEString.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 7ttyS1=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;

View File

@ -18,6 +18,10 @@ import java.util.Date;
import java.util.Locale;
public class Logs {
// 單筆寫檔上限超過即截斷避免整包 JSON/payload dump debug.txt 撐爆淹沒有用事件
// 只影響寫入 debug.txt那份logcatLog.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) {

View File

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

View File

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

View File

@ -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));
// 身分與登入 Tokenidentity 決定系統設定是否可編輯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成功回傳折扣資訊
* discountTypeamount折抵金額/ 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 登入核發的使用者 Tokenauth: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 解析放進 tryonSuccess/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", ""));
// 新增商品名稱多語 mapB012 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) {}
}
}
// 新增商品規格多語 mapB012 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_limittrack 沿用 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());

View File

@ -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 測試 EMQXWebSocket 8083 TLS
builder = builder.serverPort(8083)
.webSocketConfig().serverPath("/mqtt").applyWebSocketConfig();
} else {
// prod / demoWSS 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 送出成敗待 callbackfalse=未連線保留於發件箱待下次補送
*/
public boolean rawPublishTransaction(String payloadJson, Runnable onSuccess, Runnable onFailure) {
if (client == null || !client.getState().isConnected()) {
logs.info("Cannot publish finalize: MQTT not connected");
return;
logs.info("rawPublishTransaction: MQTT not connected, kept in outbox for resend");
return false;
}
String payloadJson = gson.toJson(payload);
String topic = "machine/" + serialNo + "/transaction";
client.publishWith()
.topic(topic)
.payload(payloadJson.getBytes(StandardCharsets.UTF_8))
@ -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 {

View File

@ -94,18 +94,35 @@ public class MqttService extends Service {
logs.info("Received MQTT command: " + cmd + " (ID: " + cmdId + ")");
try {
if ("reboot".equals(cmd)) {
if ("reboot".equals(cmd) || "reboot_force".equals(cmd)) {
logs.info("Executing remote reboot command...");
long idle = MyApp.getInstance().getIdleSeconds();
String busyReason = MyApp.getInstance().getBusyReason();
// 🚩 若機台忙碌對話框開著購物車有東西NFC 交易中則拒絕
if (!busyReason.isEmpty()) {
// 🚩 強制重啟旗標指令為 reboot_force reboot payload {"force":true}
// 用途現場卡死例如對話框被 HID 誤觸卡住 [Busy Dialog Open]
// 操作員仍能無視忙碌遠端強制重啟作為最後逃生口
boolean force = "reboot_force".equals(cmd);
try {
com.google.gson.JsonElement p = command.getPayload();
if (!force && p != null && p.isJsonObject()) {
com.google.gson.JsonObject o = p.getAsJsonObject();
force = o.has("force") && o.get("force").getAsBoolean();
}
} catch (Exception ignore) {
// payload 非預期格式時視為非強制
}
// 🚩 若機台忙碌付款/出貨對話框NFC 交易中則拒絕force 時略過此保護
if (!busyReason.isEmpty() && !force) {
logs.info("Remote reboot rejected: " + busyReason);
mqttManager.publishCommandAck(cmdId, "failed", "Machine busy: " + busyReason);
return;
}
if (!busyReason.isEmpty()) {
logs.info("Remote reboot FORCED despite busy: " + busyReason);
}
// 🚩 若不忙碌但閒置時間不足僅記錄警告但允許重啟遠端控制優先
if (idle < 60) {
@ -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_cardApp 舊名 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)) {
// 後台送 checkoutApp 舊名 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. 延後重連 MQTTtoken 可能已輪換留時間讓 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");

View File

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

View File

@ -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 槽分組
* 超出 160 範圍視為履帶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;
}
}

View File

@ -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) 商品多語 mapzh_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 = slotUpperLimitB012 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();

View File

@ -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{" +

View File

@ -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{" +

View File

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

View File

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

View File

@ -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 3handler
*/
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);
}
}
}

View File

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

View File

@ -0,0 +1,11 @@
package com.unibuy.smartdevice.tools;
/**
* 現金收銀機除錯訊息監聽器
*
* <p>收銀 handlerUSB / 串口可透過此介面把連線初始化投退幣等診斷訊息
* 即時回拋給現金機設定測試頁顯示在畫面的 Debug 訊息區方便現場排查</p>
*/
public interface DebugLogListener {
void onDebugLog(String message);
}

View File

@ -0,0 +1,39 @@
package com.unibuy.smartdevice.tools;
import java.io.IOException;
/**
* 現金收銀機MDB232 轉換盒傳輸層共用介面
*
* <p>目標機台的收銀轉換盒可透過三種方式接線USBCom Port 3ttyS9
* 前者由 {@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);
}

View File

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

View File

@ -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>進度 ACKresult="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

View File

@ -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 轉接器!";

View File

@ -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 重繪完成後才彈出鍵盤
// 若直接呼叫 showSoftInputWindow flag 變更會清除 focus導致鍵盤無法彈出
editText.post(() -> {
InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
});
}
public void setImmHideByFocusChange(EditText editText) {

View File

@ -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 進入出貨 CMUH42
* 覆寫之後之後 {@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_detailcaptureOrderInfo 與 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);

View File

@ -0,0 +1,217 @@
package com.unibuy.smartdevice.tools;
import android.content.Context;
import android.content.SharedPreferences;
import com.google.gson.Gson;
import com.unibuy.smartdevice.exception.Logs;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 交易結案上報的本地發件箱Outbox
*
* 目的解決出貨完成但 MQTT 斷線 / App 被殺 finalize 直接遺失的漏送
*
* 設計重點
* - 單一背景執行緒outboxExecutor處理所有落地 / 送出 / 移除不卡 UI 主執行緒
* 天然序列化不需 synchronized無鎖競爭
* - 每筆交易一個 SharedPreferences key"tx:" + entryIdO(1) 落地 / 移除無整包重寫
* - 落地用 commit()在背景緒安全 送出前確保已在磁碟crash / 斷電不遺失
* - in-flight 防重送正在送 PUBACK 的單會被標記drain 不重複送同一筆避免流量放大
* - 送出成功broker PUBACK才移除失敗則清 in-flight累計失敗次數留待下次 drain
* - 重試上限 / 死信連續送出失敗達上限的毒丸單移入 dead: key保留資料不丟
* 停止反覆重送並出聲交維運處理
* - 補送觸發連線恢復onConnected順帶清 in-flight 防殘留 心跳定時 60s
*
* 可靠性QoS1 + broker PUBACK 後負責後續投遞後台對同一 flow_id 完全冪等
* serial 前綴防撞號 + 終態短路 + lockForUpdate故重送 / 亂序皆安全不會重複扣庫存
*
* entryId = flow_id + ":" + status pending / failed / completed 各自獨立不互蓋
*/
public class TransactionOutbox {
private static final String PREFS = "TransactionOutboxPrefs";
private static final String KEY_PREFIX = "tx:"; // 待送出
private static final String ATT_PREFIX = "att:"; // 累計送出失敗次數
private static final String DEAD_PREFIX = "dead:"; // 死信達重試上限保留待維運
/** 連續送出失敗broker 層)達此上限即移入死信,停止反覆重送。 */
private static final int MAX_ATTEMPTS = 100;
/** 堆積警戒線:超過即出聲警告(不丟資料),代表網路 / broker 長時間異常。 */
private static final int WARN_THRESHOLD = 200;
private static final Logs logs = new Logs(TransactionOutbox.class);
private static final Gson gson = new Gson();
/** 單一背景執行緒:序列化所有 outbox 操作,不卡 UI、不需鎖。 */
private static final ExecutorService outboxExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "tx-outbox");
t.setDaemon(true);
return t;
});
/** 正在送出、等待 PUBACK 的 entryId。只在 outboxExecutor 單一緒存取 → 免鎖。 */
private static final Set<String> inflight = new HashSet<>();
/**
* 結案上報背景緒落地後嘗試送出成功才移除
* 呼叫端出貨 / 付款結案的 UI 主執行緒只做一次輕量序列化後立即返回不阻塞不做磁碟 I/O
*/
public static void submit(Context context, TransactionFinalizePayload payload) {
if (context == null || payload == null) {
return;
}
final Context app = context.getApplicationContext();
final String json;
final String entryId;
try {
json = gson.toJson(payload);
String flowId = payload.getFlow_id();
String status = (payload.getOrder() != null && payload.getOrder().getStatus() != null)
? payload.getOrder().getStatus() : "completed";
entryId = (flowId != null ? flowId : "unknown") + ":" + status;
} catch (Exception e) {
logs.info("TransactionOutbox.submit serialize failed: " + e.getMessage());
return;
}
outboxExecutor.execute(() -> {
persist(app, entryId, json);
drain(app);
});
}
/** 心跳定時呼叫:背景緒補送所有未送出的交易(保留 in-flight 狀態)。 */
public static void resumeIfPending(Context context) {
if (context == null) {
return;
}
final Context app = context.getApplicationContext();
outboxExecutor.execute(() -> drain(app));
}
/** 連線恢復呼叫:先清 in-flight舊連線在途的送出已失效避免殘留卡住再補送。 */
public static void onConnected(Context context) {
if (context == null) {
return;
}
final Context app = context.getApplicationContext();
outboxExecutor.execute(() -> {
inflight.clear();
drain(app);
});
}
// ==== 以下全部只在 outboxExecutor 單一背景緒執行故無併發不需鎖 ====
/** 落地單筆commit 同步寫磁碟,確保 publish 前已在磁碟)。 */
private static void persist(Context app, String entryId, String json) {
try {
prefs(app).edit().putString(KEY_PREFIX + entryId, json).commit();
logs.info("TransactionOutbox persisted " + entryId);
int count = countPending(prefs(app).getAll());
if (count > WARN_THRESHOLD) {
logs.info("TransactionOutbox WARNING: " + count
+ " entries pending — MQTT/network likely down for a long time");
}
} catch (Exception e) {
logs.info("TransactionOutbox.persist failed: " + e.getMessage());
}
}
/** 嘗試送出所有未送出且未在途的交易;未連線即停,留待下次 drain。 */
private static void drain(Context app) {
try {
Map<String, ?> all = prefs(app).getAll();
int pending = countPending(all);
if (pending == 0) {
return;
}
logs.info("TransactionOutbox.drain: " + pending + " pending, " + inflight.size() + " in-flight");
for (Map.Entry<String, ?> e : all.entrySet()) {
String key = e.getKey();
if (key == null || !key.startsWith(KEY_PREFIX)) {
continue;
}
final String entryId = key.substring(KEY_PREFIX.length());
if (inflight.contains(entryId)) {
continue; // A已在送 PUBACK不重送
}
Object v = e.getValue();
if (!(v instanceof String)) {
continue;
}
boolean sent = MqttManager.getInstance().rawPublishTransaction(
(String) v,
() -> outboxExecutor.execute(() -> onSendSuccess(app, entryId)),
() -> outboxExecutor.execute(() -> onSendFailure(app, entryId)));
if (sent) {
inflight.add(entryId);
} else {
// 未連線其餘也會失敗留待下次 drain連線恢復或下次心跳
logs.info("TransactionOutbox.drain: not connected, keep pending for next drain");
break;
}
}
} catch (Exception e) {
logs.info("TransactionOutbox.drain failed: " + e.getMessage());
}
}
/** 送出成功PUBACK清 in-flight、移除該筆與其失敗計數。 */
private static void onSendSuccess(Context app, String entryId) {
try {
inflight.remove(entryId);
prefs(app).edit().remove(KEY_PREFIX + entryId).remove(ATT_PREFIX + entryId).apply();
logs.info("TransactionOutbox removed " + entryId + " (published)");
} catch (Exception e) {
logs.info("TransactionOutbox.onSendSuccess failed: " + e.getMessage());
}
}
/** 送出失敗:清 in-flight、累計失敗達上限移入死信保留資料、不丟、出聲。 */
private static void onSendFailure(Context app, String entryId) {
try {
inflight.remove(entryId);
SharedPreferences p = prefs(app);
int att = p.getInt(ATT_PREFIX + entryId, 0) + 1;
if (att >= MAX_ATTEMPTS) {
String json = p.getString(KEY_PREFIX + entryId, null);
SharedPreferences.Editor ed = p.edit()
.remove(KEY_PREFIX + entryId)
.remove(ATT_PREFIX + entryId);
if (json != null) {
ed.putString(DEAD_PREFIX + entryId, json); // 保留資料供維運處理不丟棄
}
ed.apply();
logs.info("TransactionOutbox DEAD-LETTER " + entryId + " after " + att
+ " failed attempts — moved to dead:, NEEDS OPS ATTENTION");
} else {
p.edit().putInt(ATT_PREFIX + entryId, att).apply();
logs.info("TransactionOutbox send failed " + entryId + " attempt " + att + "/" + MAX_ATTEMPTS);
}
} catch (Exception e) {
logs.info("TransactionOutbox.onSendFailure failed: " + e.getMessage());
}
}
/** 計算待送出tx:)筆數;傳入已取得的快照避免重複 getAll。 */
private static int countPending(Map<String, ?> all) {
int n = 0;
for (String key : all.keySet()) {
if (key != null && key.startsWith(KEY_PREFIX)) {
n++;
}
}
return n;
}
private static SharedPreferences prefs(Context app) {
return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
}

View File

@ -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
* 一收到明確狀態就立即回傳早退不空等~12 秒仍無回應才補送 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 移除舊的它已掛在被取代的舊容器上
* 否則購買後再開購物車會拿到孤兒 fragmentopenFragment 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 finalizeflow_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()=trueDispatchDialog/insertDrgnoToMemo 會讀 product.marketingPlan SN/DRGNO
// 故給每個商品一份合法 JSONSN=領藥單號不含病人個資避免 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 解密資料這裡補一份合法 JSONSN=領藥單號不含病人個資
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();
}
}
}

View File

@ -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
* 點選取貨按鈕開啟輸入取貨碼的 Dialogpickup_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) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
// 預設無動作
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
// 通行碼入口付款格內 buttonAccessCodeflowType 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());
// 貨道層級是否支援 NFCVMC/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)/門沒關(0x060x09) 就提示並關閉付款頁回銷售頁
// 員工卡(點擊已自檢)取貨碼(自有檢查)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);

View File

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

View File

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

View File

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

View File

@ -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直接提示並返回購買頁不進出貨
// [交易生命週期] 掃碼支付失敗上報 failedflag 開才送救回原本默默消失的失敗單
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;

View File

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

View File

@ -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=70LINE 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 失敗上報 failedflag 開才送救回原本默默消失的失敗單
com.unibuy.smartdevice.tools.TransactionFinalizeBuilder.publishFailed("scan_failed");
isTransaction = false;
start(getSrcClassName(), "@" + getContext().getString(R.string.transaction_failure));
changeBuy();
}
break;
case CHANGE_DISPATCH:

View File

@ -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));
// [交易生命週期] 刷卡機回空電文視為失敗上報 failedflag 開才送
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 {

View File

@ -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));
// [交易生命週期] 刷卡機回空電文視為失敗上報 failedflag 開才送
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 {

View File

@ -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));
// [交易生命週期] 刷卡機回空電文視為失敗上報 failedflag 開才送
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 {

View File

@ -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~34TapPay 子項已於本 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直接提示並返回購買頁不進出貨
// [交易生命週期] 掃碼支付失敗上報 failedflag 開才送救回原本默默消失的失敗單
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 {

View File

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

View File

@ -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 / 金額 / 找零 / 點數須在上方 isCmuh42 覆寫之後
// 且趁全域 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 會丟 CalledFromWrongThreadExceptionOnly 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