Prechádzať zdrojové kódy

docs: 取票打印+扫码出场实现计划

lq 1 mesiac pred
rodič
commit
1574d6c70c

+ 317 - 0
docs/superpowers/plans/2026-07-30-ticket-printer-plan.md

@@ -0,0 +1,317 @@
+# 取票打印 + 扫码出场 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
+
+**Goal:** 临时车取票入场(按钮→打印二维码小票),扫码出场(扫码枪→查票→结算)
+
+**Architecture:** 新增 `internal/modules/printer/` 模块。ESC/POS 指令构建 + 串口通信。票机按钮POST创建无车牌入场记录并打印。前端监听扫码枪input自动填入ticket_no。
+
+---
+
+## 文件结构
+
+| 文件 | 操作 |
+|------|------|
+| `internal/dao/printer.go` | 新建 |
+| `internal/modules/printer/api.go` | 新建 |
+| `internal/modules/printer/service/service.go` | 新建 |
+| `internal/modules/printer/service/escpos.go` | 新建 |
+| `internal/modules/printer/service/serial.go` | 新建 |
+| `internal/initialize/gorm.go` | 修改 |
+| `internal/initialize/router.go` | 修改 |
+| `frontend/src/view/parking/entryExit.vue` | 修改 |
+
+---
+
+### Task 1: ESC/POS 打印模块
+
+- [ ] **Step 1: DAO**
+
+Write `internal/dao/printer.go`:
+
+```go
+package dao
+
+import "wails-app/internal/global"
+
+type Printer struct {
+	global.GVA_MODEL
+	Name     string `gorm:"size:50" json:"name"`
+	PortName string `gorm:"size:20" json:"port_name"` // COM3
+	BaudRate int    `json:"baud_rate"`                // 9600
+	Status   string `gorm:"size:20;default:offline" json:"status"`
+	Location string `gorm:"size:20" json:"location"` // entry / exit
+}
+
+func (Printer) TableName() string { return "printer" }
+```
+
+Add to `internal/initialize/gorm.go` AutoMigrate (after `dao.ShiftRecord{}`):
+
+```go
+dao.ShiftRecord{},
+dao.Printer{},
+```
+
+- [ ] **Step 2: ESC/POS 协议**
+
+Write `internal/modules/printer/service/escpos.go`:
+
+```go
+package service
+
+// ESC/POS commands for 58mm thermal printer
+const (
+	ESC = 0x1B
+	GS  = 0x1D
+)
+
+var (
+	Init       = []byte{ESC, '@'}                       // 初始化
+	AlignLeft  = []byte{ESC, 'a', 0}                   // 左对齐
+	AlignCenter= []byte{ESC, 'a', 1}                   // 居中
+	FeedLine   = []byte{ESC, 'd', 1}                   // 换行
+	CutPaper   = []byte{GS, 'V', 66, 0}                // 切纸
+	DoubleH    = []byte{ESC, '!', 0x11}                // 双倍高度
+	Normal     = []byte{ESC, '!', 0x00}                // 正常字体
+)
+
+func ESCPOS_Text(text string) []byte    { return append([]byte(text), '\n') }
+func ESCPOS_QRCode(data string) []byte {
+	// GS ( k <fn> <cn> <a> <m> <nL> <nH> <d1...dk>
+	n := len(data) + 3
+	cmd := []byte{GS, '(','k', byte(n), 0, 49, 80, 48}  // QR model 2, ECC L
+	cmd = append(cmd, byte(len(data)+3), 0, 49, 80, 48) // store
+	cmd = append(cmd, []byte(data)...)
+	cmd = append(cmd, GS,'(','k',3,0,49,81,48) // print QR
+	return cmd
+}
+```
+
+- [ ] **Step 3: 串口通信**
+
+Write `internal/modules/printer/service/serial.go`:
+
+```go
+package service
+
+import (
+	"fmt"
+	"github.com/tarm/serial"
+)
+
+type SerialPrinter struct {
+	Port *serial.Port
+}
+
+func NewSerialPrinter(portName string, baudRate int) (*SerialPrinter, error) {
+	c := &serial.Config{Name: portName, Baud: baudRate}
+	port, err := serial.OpenPort(c)
+	if err != nil { return nil, fmt.Errorf("打开串口失败: %w", err) }
+	return &SerialPrinter{Port: port}, nil
+}
+
+func (p *SerialPrinter) Write(data []byte) error {
+	_, err := p.Port.Write(data)
+	return err
+}
+
+func (p *SerialPrinter) Close() error { return p.Port.Close() }
+```
+
+Note: `go get github.com/tarm/serial` required.
+
+- [ ] **Step 4: Print Service**
+
+Write `internal/modules/printer/service/service.go`:
+
+```go
+package service
+
+import (
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type PrintService struct{}
+
+func NewPrintService() *PrintService { return &PrintService{} }
+
+// PrintTicket 打印入场小票
+func (s *PrintService) PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error {
+	var printer dao.Printer
+	if err := global.GVA_DB.First(&printer, printerID).Error; err != nil {
+		return fmt.Errorf("打印机未配置")
+	}
+
+	sp, err := NewSerialPrinter(printer.PortName, printer.BaudRate)
+	if err != nil {
+		return fmt.Errorf("连接打印机失败: %w", err)
+	}
+	defer sp.Close()
+
+	now := time.Now().Format("02-01-2006 15:04:05")
+	slipNo := fmt.Sprintf("%d", ticketID)
+
+	write := func(data []byte) { sp.Write(data) }
+	write(Init)
+	write(AlignCenter)
+	write(DoubleH)
+	write(ESCPOS_Text("PARKING TICKET"))
+	write(Normal)
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text(lotName))
+	write(ESCPOS_Text("A-General Car"))
+	write(ESCPOS_Text(""))
+	write(ESCPOS_Text("PARK AT YOUR OWN RISK"))
+	write(FeedLine)
+	write(AlignLeft)
+	write(ESCPOS_Text("In-Time::" + now))
+	if channelCode != "" { write(ESCPOS_Text("InGate::" + channelCode)) }
+	if plateNo != "" { write(ESCPOS_Text("Veh No::" + plateNo)) }
+	write(FeedLine)
+	write(ESCPOS_Text("Slip No::" + slipNo))
+	write(FeedLine)
+	write(AlignCenter)
+	write(ESCPOS_QRCode(ticketNo))
+	write(FeedLine)
+	write(ESCPOS_Text("SCAN AND PAY WITH"))
+	write(ESCPOS_Text("NEW THE CANADIA BANK APP"))
+	write(FeedLine)
+	write(CutPaper)
+	global.GVA_DB.Model(&printer).Update("status", "online")
+	return nil
+}
+```
+
+- [ ] **Step 5: 构建+提交**
+
+```bash
+cd lc_garage && go get github.com/tarm/serial && go build -o build/bin/smart-parking.exe .
+git add internal/dao/printer.go internal/modules/printer/ internal/initialize/gorm.go go.mod go.sum
+git commit -m "feat: ESC/POS打印模块——串口通信+58mm小票模板+QRCode"
+```
+
+---
+
+### Task 2: 按钮事件 API + 入场集成
+
+- [ ] **Step 1: api.go**
+
+Write `internal/modules/printer/api.go`:
+
+```go
+package printer
+
+import (
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/global"
+	"wails-app/internal/model/common/response"
+	ticketSvc "wails-app/internal/modules/digital-ticket/service"
+	"wails-app/internal/modules/printer/service"
+	"wails-app/internal/service"
+	utils "wails-app/internal/pkg"
+)
+
+var printSvc = service.NewPrintService()
+var vehicleSvc = service.ServiceGroupApp.VehicleServiceGroup.VehicleService
+
+type buttonReq struct {
+	PrinterID   uint   `json:"printer_id"`
+	ChannelCode string `json:"channel_code"`
+	PlateNo     string `json:"plate_number"` // 可选,识别到则填
+}
+
+func TicketMachineButton(c *gin.Context) {
+	var req buttonReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c); return
+	}
+
+	// 创建入场记录(无车牌临时车)
+	entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
+		PlateNumber: req.PlateNo, RFIDTag: "", ParkingLotID: 1, ParkingSpaceID: 1,
+	})
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+
+	// 获取最新 digital_ticket
+	var tickets []dao.DigitalTicket
+	global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
+	if len(tickets) == 0 { response.FailWithMessage("票创建失败", c); return }
+
+	// 打印小票
+	var lot dao.ParkingLot
+	global.GVA_DB.First(&lot, entryResp.Data.ParkingLotID)
+	if err := printSvc.PrintTicket(req.PrinterID, lot.LotName, req.ChannelCode, req.PlateNo, tickets[0].ID, tickets[0].TicketNo); err != nil {
+		response.FailWithMessage("打印失败: "+err.Error(), c); return
+	}
+
+	response.OkWithData(gin.H{"ticket_id": tickets[0].ID, "ticket_no": tickets[0].TicketNo}, c)
+}
+
+func SetupPrinterRouter(router *gin.RouterGroup) {
+	router.POST("/ticket-machine/button", TicketMachineButton)
+}
+```
+
+Need imports: `"wails-app/internal/dao"`, `"wails-app/internal/model/vehicle/request"`.
+
+- [ ] **Step 2: 注册路由**
+
+Edit `internal/initialize/router.go`, import `"wails-app/internal/modules/printer"`, add:
+
+```go
+printer.SetupPrinterRouter(PrivateGroup)
+```
+
+- [ ] **Step 3: 构建+提交**
+
+```bash
+go build -o build/bin/smart-parking.exe .
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO casbin_rule (ptype,v0,v1,v2) VALUES ('p','888','/ticket-machine/button','POST'),('p','618','/ticket-machine/button','POST');"
+git add internal/modules/printer/api.go internal/initialize/router.go
+git commit -m "feat: 票机按钮事件API——创建无车牌入场+打印小票"
+```
+
+---
+
+### Task 3: 前端扫码枪监听
+
+- [ ] **Step 1: entryExit.vue 出场 Tab 添加扫码枪监听**
+
+扫码枪 = USB键盘,快速输入一串字符后回车。在出场 Tab 添加全局 input 监听,检测快速输入(<50ms/字符)即为扫码枪,自动填入并查询。
+
+Add to `<script setup>` of `frontend/src/view/parking/entryExit.vue`:
+
+```js
+// 扫码枪监听(USB键盘模拟输入)
+let scanBuffer = '', scanTimer = null
+function onScanKey(e) {
+  if (e.key === 'Enter' && scanBuffer.length > 10) {
+    queryForm.rfid_tag = ''; queryForm.plate_number = ''
+    // 判断是 ticket_no (hex) 还是车牌
+    if (/^[0-9a-f]{32}$/.test(scanBuffer)) {
+      // ticket_no → 查 digital_ticket
+      queryForm.rfid_tag = scanBuffer
+    } else {
+      queryForm.plate_number = scanBuffer
+    }
+    scanBuffer = ''; queryVehicle()
+  } else if (e.key.length === 1) {
+    scanBuffer += e.key; clearTimeout(scanTimer)
+    scanTimer = setTimeout(() => { scanBuffer = '' }, 100)
+  }
+}
+onMounted(() => document.addEventListener('keydown', onScanKey))
+onUnmounted(() => { document.removeEventListener('keydown', onScanKey); clearInterval(eventTimer) })
+```
+
+- [ ] **Step 2: 构建+提交**
+
+```bash
+cd frontend && npm run build && cd ..
+git add frontend/src/view/parking/entryExit.vue
+git commit -m "feat: 出场Tab扫码枪监听——自动识别ticket_no/车牌并查询"
+```