[FEAT] 實作遠端找零與遠端出貨功能

1. 新增 CashPayoutManager.java:封裝 MDB 退幣/退鈔硬體控制邏輯與金額拆解算法。
2. 新增 ProductDispenseManager.java:封裝背景出貨流程,包含貨道檢查、出貨執行及狀態輪詢,並實作成功後自動更新本地 SQLite 庫存與 MyApp 狀態。
3. 修改 MqttService.java:接入 'change' 與 'dispense' MQTT 指令,解析 payload 並調用對應管理器,執行完畢後回傳 ACK 狀態至雲端。
This commit is contained in:
sky121113 2026-05-11 09:16:20 +08:00
parent df8dae9a56
commit 7a990ffc12
3 changed files with 493 additions and 0 deletions

View File

@ -136,6 +136,89 @@ public class MqttService extends Service {
Log.e(TAG, "NFC checkout failed: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "NFC checkout failed: " + e.getMessage());
}
} else if ("change".equals(cmd)) {
Log.i(TAG, "Executing remote change command...");
try {
int amount = 0;
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
if (payloadObj.has("amount")) {
amount = payloadObj.get("amount").getAsInt();
}
}
if (amount <= 0) {
throw new Exception("Invalid or missing amount: " + amount);
}
int finalAmount = amount;
com.unibuy.smartdevice.tools.CashPayoutManager payoutManager = new com.unibuy.smartdevice.tools.CashPayoutManager(getApplicationContext(), new com.unibuy.smartdevice.tools.CashPayoutManager.PayoutListener() {
@Override
public void onProgress(String message) {
Log.d(TAG, "Payout Progress: " + message);
}
@Override
public void onSuccess() {
Log.i(TAG, "Remote payout success for amount: " + finalAmount);
mqttManager.publishCommandAck(cmdId, "success", "Payout completed: " + finalAmount);
}
@Override
public void onFailure(String error) {
Log.e(TAG, "Remote payout failed: " + error);
mqttManager.publishCommandAck(cmdId, "failed", "Payout failed: " + error);
}
});
payoutManager.startPayout(amount);
// 注意ACK 會在 PayoutListener 的回調中發送不在此處發送
} catch (Exception e) {
Log.e(TAG, "Failed to parse or start change command: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
}
} else if ("dispense".equals(cmd)) {
Log.i(TAG, "Executing remote dispense command...");
try {
int slot = -1;
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
if (payloadObj.has("slot_no")) {
slot = payloadObj.get("slot_no").getAsInt();
} else if (payloadObj.has("slot")) {
slot = payloadObj.get("slot").getAsInt();
}
}
if (slot <= 0) {
throw new Exception("Invalid or missing slot_no: " + slot);
}
int finalSlot = slot;
com.unibuy.smartdevice.tools.ProductDispenseManager dispenseManager = new com.unibuy.smartdevice.tools.ProductDispenseManager(getApplicationContext(), new com.unibuy.smartdevice.tools.ProductDispenseManager.DispenseListener() {
@Override
public void onProgress(String message) {
Log.d(TAG, "Dispense Progress: " + message);
}
@Override
public void onSuccess() {
Log.i(TAG, "Remote dispense success for slot: " + finalSlot);
mqttManager.publishCommandAck(cmdId, "success", "Product dispensed from slot: " + finalSlot);
}
@Override
public void onFailure(String error) {
Log.e(TAG, "Remote dispense failed: " + error);
mqttManager.publishCommandAck(cmdId, "failed", "Dispense failed: " + error);
}
});
dispenseManager.startDispense(slot);
} catch (Exception e) {
Log.e(TAG, "Failed to parse or start dispense command: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
}
} else {
Log.w(TAG, "Unknown MQTT command: " + cmd);
mqttManager.publishCommandAck(cmdId, "failed", "Unknown command");

View File

@ -0,0 +1,235 @@
package com.unibuy.smartdevice.tools;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.unibuy.smartdevice.exception.ErrorCode;
import com.unibuy.smartdevice.tools.SimpleCashAcceptorHandler;
import java.util.ArrayList;
import java.util.List;
/**
* 遠端找零管理器
* 負責處理 MDB 設備的連線初始化庫存查詢及退幣/退鈔指令發送
*/
public class CashPayoutManager {
private static final String TAG = "CashPayoutManager";
public interface PayoutListener {
void onProgress(String message);
void onSuccess();
void onFailure(String error);
}
private final Context context;
private final PayoutListener listener;
private SimpleCashAcceptorHandler handler;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
// 庫存變數
private int coin50_count = 0;
private int coin10_count = 0;
private int coin5_count = 0;
private int coin1_count = 0;
private int note100_count = 0;
public CashPayoutManager(Context context, PayoutListener listener) {
this.context = context;
this.listener = listener;
}
public void startPayout(int amount) {
Log.i(TAG, "Starting remote payout for amount: " + amount);
listener.onProgress("正在初始化退幣系統...");
handler = new SimpleCashAcceptorHandler(context, event -> {
Log.d(TAG, "MDB Event: " + event);
handleMdbEvent(event);
});
new Thread(() -> {
String connectResult = handler.connect();
if ("成功連接到 USB 轉接器!".equals(connectResult)) {
mainHandler.post(() -> {
handler.startReading();
performInitializationSequence(amount);
});
} else {
mainHandler.post(() -> listener.onFailure("MDB 連線失敗: " + connectResult));
}
}).start();
}
private void performInitializationSequence(int amount) {
// 依序發送初始化與查詢指令
// 指令序列參考 CashMachineSettings CashPayDialog
sendMdbCommand("0C00000000", 0, () -> { // 先禁止硬幣接收
sendMdbCommand("3400000000", 500, () -> { // 禁止紙鈔接收
sendMdbCommand("370100000002", 500, () -> { // 設定紙鈔類型
sendMdbCommand("3703", 500, () -> { // 查詢設置
sendMdbCommand("3705", 500, () -> { // 查詢紙鈔庫存
sendMdbCommand("0A", 1000, () -> { // 查詢硬幣庫存
calculateAndExecutePayout(amount);
});});});});});});
}
private void calculateAndExecutePayout(int amount) {
listener.onProgress("正在計算退幣組合...");
List<ChangeCommand> commands = new ArrayList<>();
int remaining = amount;
// 1. 100元紙鈔
int hundredsToGive = Math.min(remaining / 100, note100_count);
if (hundredsToGive > 0) {
String cmd = "370600000" + String.format("%01X", hundredsToGive);
commands.add(new ChangeCommand(cmd, 100, hundredsToGive));
remaining -= hundredsToGive * 100;
}
// 2. 50元硬幣
int fiftiesToGive = Math.min(remaining / 50, coin50_count);
if (fiftiesToGive > 0) {
String cmd = "0D" + String.format("%01X", fiftiesToGive) + "4";
commands.add(new ChangeCommand(cmd, 50, fiftiesToGive));
remaining -= fiftiesToGive * 50;
}
// 3. 10元硬幣
int tensToGive = Math.min(remaining / 10, coin10_count);
if (tensToGive > 0) {
String cmd = "0D" + String.format("%01X", tensToGive) + "2";
commands.add(new ChangeCommand(cmd, 10, tensToGive));
remaining -= tensToGive * 10;
}
// 4. 5元硬幣
int fivesToGive = Math.min(remaining / 5, coin5_count);
if (fivesToGive > 0) {
String cmd = "0D" + String.format("%01X", fivesToGive) + "1";
commands.add(new ChangeCommand(cmd, 5, fivesToGive));
remaining -= fivesToGive * 5;
}
// 5. 1元硬幣
int onesToGive = Math.min(remaining, coin1_count);
if (onesToGive > 0) {
String cmd = "0D" + String.format("%01X", onesToGive) + "0";
commands.add(new ChangeCommand(cmd, 1, onesToGive));
remaining -= onesToGive;
}
if (remaining > 0) {
Log.w(TAG, "Insufficient inventory to payout full amount. Missing: " + remaining);
// 依然執行現有的但回報錯誤訊息
}
if (commands.isEmpty()) {
listener.onFailure("無需找零或庫存不足以找零");
close();
return;
}
executePayoutCommands(commands, 0);
}
private void executePayoutCommands(List<ChangeCommand> commands, int index) {
if (index >= commands.size()) {
listener.onProgress("退幣完成");
// 延遲關閉確保指令處理完畢
mainHandler.postDelayed(() -> {
close();
listener.onSuccess();
}, 2000);
return;
}
ChangeCommand current = commands.get(index);
listener.onProgress("正在執行: " + current.denomination + "元 x " + current.count);
sendMdbCommand(current.command, 0, () -> {
// 退幣指令後需要較長延遲
executePayoutCommands(commands, index + 1);
});
}
private void sendMdbCommand(String hexCommand, int delay, Runnable onComplete) {
mainHandler.postDelayed(() -> {
new Thread(() -> {
try {
byte[] command = hexStringToByteArray(hexCommand);
handler.writeData(command);
if (onComplete != null) {
mainHandler.post(onComplete);
}
} catch (Exception e) {
Log.e(TAG, "Error sending MDB command: " + hexCommand, e);
}
}).start();
}, Math.max(delay, 1000)); // 每個指令至少間隔 1 退幣可能需要更長
}
private void handleMdbEvent(String event) {
if (event == null || event.trim().isEmpty()) return;
String[] parts = event.trim().split("\\s+");
// 解析硬幣庫存 (0A 回傳)
if (parts.length == 19) {
try {
coin1_count = Integer.parseInt(parts[2], 16);
coin5_count = Integer.parseInt(parts[3], 16);
coin10_count = Integer.parseInt(parts[4], 16);
coin50_count = Integer.parseInt(parts[6], 16);
Log.d(TAG, String.format("Inventory Update: 1=%d, 5=%d, 10=%d, 50=%d",
coin1_count, coin5_count, coin10_count, coin50_count));
} catch (Exception e) {
Log.e(TAG, "Parse coin inventory error", e);
}
}
// 解析百鈔庫存 (3705 回傳)
if (parts.length >= 30) {
try {
int noteCount = Integer.parseInt(parts[3], 16);
if (noteCount >= 0 && noteCount <= 100) {
note100_count = noteCount;
Log.d(TAG, "Inventory Update: 100=" + note100_count);
}
} catch (Exception e) {
Log.e(TAG, "Parse bill inventory error", e);
}
}
}
private void close() {
if (handler != null) {
handler.stopReading();
handler.close();
handler = null;
}
}
private byte[] hexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i + 1), 16));
}
return data;
}
private static class ChangeCommand {
final String command;
final int denomination;
final int count;
ChangeCommand(String command, int denomination, int count) {
this.command = command;
this.denomination = denomination;
this.count = count;
}
}
}

View File

@ -0,0 +1,175 @@
package com.unibuy.smartdevice.tools;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.controller.DevController;
import com.unibuy.smartdevice.controller.DevXinYuanController;
import com.unibuy.smartdevice.devices.PortTools;
/**
* 遠端出貨管理器
* 負責在背景執行商品出貨流程不依賴於 UI (DispatchDialog)
*/
public class ProductDispenseManager {
private static final String TAG = "ProductDispenseManager";
public interface DispenseListener {
void onProgress(String message);
void onSuccess();
void onFailure(String error);
}
private final Context context;
private final DispenseListener listener;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private int checkRetryCount = 0;
public ProductDispenseManager(Context context, DispenseListener listener) {
this.context = context;
this.listener = listener;
}
public void startDispense(int slot) {
Log.i(TAG, "Starting background dispense for slot: " + slot);
listener.onProgress("正在檢查貨道狀態...");
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
if (controller == null || !controller.isConnected()) {
listener.onFailure("下位機控制器未連線");
return;
}
// 步驟 1: 檢查貨道狀態 (CMD 01 -> VMC 02)
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
@Override
protected void execute(Context context, int commandCode, String message) {}
}) {
@Override
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
@Override
protected Class<?> setCls() { return getClass(); }
@Override
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
boolean isCMD = false;
boolean isStatusOk = false;
for (byte[] buffer : devController.getReadBufferByMax()) {
if (buffer[0] == 0x02) { // VMC_02_CHECK_SLOT_STATUS
isCMD = true;
Log.d(TAG, "Check Slot Result: " + PortTools.showHex(buffer));
// 01: OK, 02: PDT_EMPTY (有些機型空也會允許出貨指令), 04: TMP_USE
if (buffer[1] == 0x01 || buffer[1] == 0x02 || buffer[1] == 0x04) {
isStatusOk = true;
}
break;
}
}
if (isCMD) {
if (isStatusOk) {
mainHandler.post(() -> executeActualDispense(slot));
} else {
mainHandler.post(() -> listener.onFailure("貨道狀態異常,無法出貨"));
}
} else {
if (checkRetryCount < 3) {
checkRetryCount++;
controller.addSendBuffer(controller.getDevXinYuan().checkSlot(slot));
controller.getReadBufferByNow(this, 1000);
} else {
mainHandler.post(() -> listener.onFailure("下位機檢查貨道無回應"));
}
}
}
}, 1000);
controller.addSendBuffer(controller.getDevXinYuan().checkSlot(slot));
}
private void executeActualDispense(int slot) {
listener.onProgress("貨道正常,正在發送出貨指令...");
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
// 步驟 2: 發送出貨指令並監聽狀態 (CMD 06 -> VMC 04)
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
@Override
protected void execute(Context context, int commandCode, String message) {}
}) {
@Override
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
@Override
protected Class<?> setCls() { return getClass(); }
@Override
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
boolean isCMD = false;
boolean isSuccess = false;
boolean isFailed = false;
String errorDetail = "";
for (byte[] buffer : devController.getReadBufferByMax()) {
if (buffer[0] == 0x04) { // VMC_04_BUY_STATUS
isCMD = true;
Log.d(TAG, "Dispense Status: " + PortTools.showHex(buffer));
// 02: OK, 24: PLIS_BOX (可能是某些特定機型的成功代碼)
if (buffer[1] == 0x02 || buffer[1] == 0x24) {
isSuccess = true;
break;
}
// 01: NOW (處理中), 10: PLATFORM_UP, 11: PLATFORM_DOWN
else if (buffer[1] == 0x01 || buffer[1] == 0x10 || buffer[1] == 0x11) {
continue;
}
else {
isFailed = true;
errorDetail = String.format("%02X", buffer[1]);
}
}
}
if (isSuccess) {
mainHandler.post(() -> {
updateLocalStock(slot);
listener.onSuccess();
});
} else if (isFailed) {
String finalErrorDetail = errorDetail;
mainHandler.post(() -> listener.onFailure("出貨失敗,錯誤代碼: " + finalErrorDetail));
} else {
// 繼續輪詢直到成功或失敗
controller.getReadBufferByNow(this, 1000);
}
}
}, 1000);
controller.addSendBuffer(controller.getDevXinYuan().getProduct(slot));
}
private void updateLocalStock(int slot) {
try {
// 預設為 VMC 貨道 (Field 1)
int field = com.unibuy.smartdevice.devices.SlotField.VMC.getField();
com.unibuy.smartdevice.structure.SlotStructure slotData = MyApp.getInstance().getSlotData(field, slot);
int currentCount = slotData.getCount();
if (currentCount > 0) {
int newCount = currentCount - 1;
slotData.setCount(newCount);
Log.i(TAG, "Updated slot " + slot + " count: " + currentCount + " -> " + newCount);
// 儲存至資料庫
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(context);
slotsDao.deleteAll();
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
slotsDao.close();
}
} catch (Exception e) {
Log.e(TAG, "Failed to update local stock: " + e.getMessage());
}
}
}