فهرست منبع

docs: 交接班+收入报表实现计划

lq 1 ماه پیش
والد
کامیت
e7336ca000
1فایلهای تغییر یافته به همراه491 افزوده شده و 0 حذف شده
  1. 491 0
      docs/superpowers/plans/2026-07-29-shift-report-plan.md

+ 491 - 0
docs/superpowers/plans/2026-07-29-shift-report-plan.md

@@ -0,0 +1,491 @@
+# 交接班 + 收入报表 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
+
+**Goal:** 交接班(接班/交班/差异统计)+ 收入报表(按日+停车场汇总)
+
+**Architecture:** 新增两个独立模块 `modules/shift/` 和 `modules/report/`,共享 payment_record 数据。
+
+---
+
+## 文件结构
+
+| 文件 | 操作 |
+|------|------|
+| `internal/dao/shift_record.go` | 新建 |
+| `internal/modules/shift/api.go` | 新建 |
+| `internal/modules/shift/service/service.go` | 新建 |
+| `internal/modules/shift/repository/repo.go` | 新建 |
+| `internal/modules/report/api.go` | 新建 |
+| `internal/modules/report/service/service.go` | 新建 |
+| `internal/initialize/gorm.go` | 修改 |
+| `internal/initialize/router.go` | 修改 |
+| `internal/initialize/seed.go` | 修改 |
+| `frontend/src/view/parking/shiftRecord.vue` | 新建 |
+| `frontend/src/view/report/revenue.vue` | 新建 |
+| `frontend/src/api/shift.js` | 新建 |
+| `frontend/src/api/report.js` | 新建 |
+| `frontend/src/lang/zh-CN.js` | 修改 |
+| `frontend/src/lang/en.js` | 修改 |
+
+---
+
+### Task 1: 交接班——后端
+
+- [ ] **Step 1: DAO**
+
+Write `internal/dao/shift_record.go`:
+
+```go
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+type ShiftRecord struct {
+	global.GVA_MODEL
+	OperatorID     uint       `gorm:"index" json:"operator_id"`
+	StartTime      time.Time  `json:"start_time"`
+	EndTime        *time.Time `json:"end_time"`
+	StartingCash   float64    `json:"starting_cash"`
+	CollectedCash  float64    `json:"collected_cash"`
+	ExpectedTotal  float64    `json:"expected_total"`
+	ActualTotal    float64    `json:"actual_total"`
+	Difference     float64    `json:"difference"`
+	Status         string     `gorm:"size:20;default:active" json:"status"`
+	Remark         string     `gorm:"size:200" json:"remark"`
+}
+
+func (ShiftRecord) TableName() string { return "shift_record" }
+```
+
+Add to `internal/initialize/gorm.go` AutoMigrate (after `dao.DigitalTicket{}`):
+
+```go
+dao.DigitalTicket{},
+dao.ShiftRecord{},
+```
+
+- [ ] **Step 2: Repository + Service**
+
+Write `internal/modules/shift/repository/repo.go`:
+
+```go
+package repository
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type ShiftRepository struct{}
+
+func (r *ShiftRepository) Create(record *dao.ShiftRecord) error { return global.GVA_DB.Create(record).Error }
+func (r *ShiftRepository) Update(record *dao.ShiftRecord) error { return global.GVA_DB.Save(record).Error }
+
+func (r *ShiftRepository) GetActive(operatorID uint) (*dao.ShiftRecord, error) {
+	var s dao.ShiftRecord
+	err := global.GVA_DB.Where("operator_id = ? AND status = ?", operatorID, "active").First(&s).Error
+	if err != nil { return nil, err }
+	return &s, nil
+}
+
+type ShiftListQuery struct { Page int `form:"page"`; PageSize int `form:"page_size"` }
+
+func (r *ShiftRepository) List(q ShiftListQuery, operatorID uint) ([]dao.ShiftRecord, int64, error) {
+	db := global.GVA_DB.Model(&dao.ShiftRecord{}).Where("operator_id = ?", operatorID)
+	var total int64; db.Count(&total)
+	if q.Page <= 0 { q.Page = 1 }; if q.PageSize <= 0 { q.PageSize = 10 }
+	var list []dao.ShiftRecord
+	err := db.Order("id DESC").Offset((q.Page-1)*q.PageSize).Limit(q.PageSize).Find(&list).Error
+	return list, total, err
+}
+```
+
+Write `internal/modules/shift/service/service.go`:
+
+```go
+package service
+
+import (
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/shift/repository"
+)
+
+type ShiftService struct { repo *repository.ShiftRepository }
+func NewShiftService() *ShiftService { return &ShiftService{repo: &repository.ShiftRepository{}} }
+
+// Start 接班
+func (s *ShiftService) Start(operatorID uint, startingCash float64) (*dao.ShiftRecord, error) {
+	active, _ := s.repo.GetActive(operatorID)
+	if active != nil { return nil, errors.New("已有当班记录,请先交班") }
+	rec := &dao.ShiftRecord{OperatorID: operatorID, StartTime: time.Now(), StartingCash: startingCash, Status: "active"}
+	if err := s.repo.Create(rec); err != nil { return nil, err }
+	return rec, nil
+}
+
+// End 交班
+func (s *ShiftService) End(operatorID uint, actualCash float64, remark string) (*dao.ShiftRecord, error) {
+	active, err := s.repo.GetActive(operatorID)
+	if err != nil { return nil, errors.New("无当班记录") }
+
+	// 统计当班现金收款
+	var collected float64
+	global.GVA_DB.Raw("SELECT COALESCE(SUM(amount),0) FROM payment_record WHERE payment_method='cash' AND paid_at >= ? AND paid_at < ?", active.StartTime, time.Now()).Scan(&collected)
+
+	now := time.Now()
+	expected := active.StartingCash + collected
+	diff := actualCash - expected
+	active.EndTime = &now
+	active.CollectedCash = collected
+	active.ExpectedTotal = expected
+	active.ActualTotal = actualCash
+	active.Difference = diff
+	active.Status = "closed"
+	active.Remark = remark
+	if err := s.repo.Update(active); err != nil { return nil, err }
+	fmt.Printf("交班完成: 接班%.2f + 当班%.2f = 应交%.2f, 实交%.2f, 差异%.2f\n", active.StartingCash, collected, expected, actualCash, diff)
+	return active, nil
+}
+
+func (s *ShiftService) GetCurrent(operatorID uint) (*dao.ShiftRecord, error) { return s.repo.GetActive(operatorID) }
+func (s *ShiftService) List(q repository.ShiftListQuery, operatorID uint) ([]dao.ShiftRecord, int64, error) { return s.repo.List(q, operatorID) }
+```
+
+- [ ] **Step 3: API + 路由注册**
+
+Write `internal/modules/shift/api.go`:
+
+```go
+package shift
+
+import (
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/shift/repository"
+	"wails-app/internal/modules/shift/service"
+	utils "wails-app/internal/pkg"
+)
+
+var shiftSvc = service.NewShiftService()
+
+type startReq struct { StartingCash float64 `json:"starting_cash"` }
+type endReq struct { ActualCash float64 `json:"actual_cash"`; Remark string `json:"remark"` }
+
+func StartShift(c *gin.Context) {
+	var req startReq
+	if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c); return }
+	rec, err := shiftSvc.Start(utils.GetUserID(c), req.StartingCash)
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+	response.OkWithData(rec, c)
+}
+
+func EndShift(c *gin.Context) {
+	var req endReq
+	if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c); return }
+	rec, err := shiftSvc.End(utils.GetUserID(c), req.ActualCash, req.Remark)
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+	response.OkWithData(rec, c)
+}
+
+func GetCurrentShift(c *gin.Context) {
+	rec, err := shiftSvc.GetCurrent(utils.GetUserID(c))
+	if err != nil { response.FailWithDetailed(gin.H{"has_active": false}, "无当班", c); return }
+	response.OkWithData(rec, c)
+}
+
+func ListShifts(c *gin.Context) {
+	var q repository.ShiftListQuery
+	if err := c.ShouldBindQuery(&q); err != nil { response.FailWithMessage(err.Error(), c); return }
+	list, total, err := shiftSvc.List(q, utils.GetUserID(c))
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+func SetupShiftRouter(router *gin.RouterGroup) {
+	sr := router.Group("/shift")
+	sr.POST("/start", StartShift)
+	sr.POST("/end", EndShift)
+	sr.GET("/current", GetCurrentShift)
+	sr.GET("/list", ListShifts)
+}
+```
+
+Register in `internal/initialize/router.go` — import `"wails-app/internal/modules/shift"`, add:
+
+```go
+shift.SetupShiftRouter(PrivateGroup)
+```
+
+- [ ] **Step 4: Casbin + 编译+提交**
+
+```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','/shift/start','POST'),('p','618','/shift/start','POST'),('p','888','/shift/end','POST'),('p','618','/shift/end','POST'),('p','888','/shift/current','GET'),('p','618','/shift/current','GET'),('p','888','/shift/list','GET'),('p','618','/shift/list','GET');"
+git add internal/dao/shift_record.go internal/modules/shift/ internal/initialize/
+git commit -m "feat: 交接班模块——接班/交班/当班统计/差异计算"
+```
+
+---
+
+### Task 2: 收入报表——后端
+
+- [ ] **Step 1: Service + API**
+
+Write `internal/modules/report/service/service.go`:
+
+```go
+package service
+
+import "wails-app/internal/global"
+
+type RevenueItem struct {
+	Date       string  `json:"date"`
+	ParkingLot string  `json:"parking_lot"`
+	Cash       float64 `json:"cash"`
+	Free       float64 `json:"free"`
+	Total      float64 `json:"total"`
+	Count      int64   `json:"count"`
+}
+
+type ReportService struct{}
+
+func (s *ReportService) RevenueReport(startDate, endDate string, lotID uint) ([]RevenueItem, error) {
+	var items []RevenueItem
+	db := global.GVA_DB.Table("payment_record p").
+		Select(`DATE(p.paid_at) as date, COALESCE(pr.lot_name,'-') as parking_lot,
+			COALESCE(SUM(CASE WHEN p.payment_method='cash' THEN p.amount ELSE 0 END),0) as cash,
+			COALESCE(SUM(CASE WHEN p.payment_method='free' THEN p.amount ELSE 0 END),0) as free,
+			COALESCE(SUM(p.amount),0) as total, COUNT(*) as count`).
+		Joins("LEFT JOIN vehicle_record vr ON vr.id = p.record_id").
+		Joins("LEFT JOIN parking_lot pr ON pr.id = vr.parking_lot_id").
+		Where("p.paid_at >= ? AND p.paid_at <= ?", startDate, endDate+" 23:59:59")
+	if lotID > 0 { db = db.Where("vr.parking_lot_id = ?", lotID) }
+	err := db.Group("DATE(p.paid_at), vr.parking_lot_id").Order("date DESC").Scan(&items).Error
+	return items, err
+}
+```
+
+Write `internal/modules/report/api.go`:
+
+```go
+package report
+
+import (
+	"strconv"
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/report/service"
+)
+
+var reportSvc = service.ReportService{}
+
+func RevenueReport(c *gin.Context) {
+	lotID, _ := strconv.Atoi(c.Query("parking_lot_id"))
+	items, err := reportSvc.RevenueReport(c.Query("start_date"), c.Query("end_date"), uint(lotID))
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+	response.OkWithData(items, c)
+}
+
+func SetupReportRouter(router *gin.RouterGroup) {
+	router.GET("/report/revenue", RevenueReport)
+}
+```
+
+Register in `internal/initialize/router.go` — import `"wails-app/internal/modules/report"`, add:
+
+```go
+report.SetupReportRouter(PrivateGroup)
+```
+
+- [ ] **Step 2: Casbin + 编译+提交**
+
+```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','/report/revenue','GET'),('p','618','/report/revenue','GET');"
+git add internal/modules/report/ internal/initialize/router.go
+git commit -m "feat: 收入报表——按日+停车场汇总payment_record"
+```
+
+---
+
+### Task 3: 前端
+
+- [ ] **Step 1: 交接班页面**
+
+Write `frontend/src/view/parking/shiftRecord.vue`:
+
+```vue
+<template>
+  <div>
+    <el-card v-if="current" class="active-shift">
+      <template #header>当班中</template>
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="接班时间">{{ current.start_time }}</el-descriptions-item>
+        <el-descriptions-item label="接班现金">¥{{ current.starting_cash.toFixed(2) }}</el-descriptions-item>
+        <el-descriptions-item label="当班收款">¥{{ current.collected_cash.toFixed(2) }}</el-descriptions-item>
+        <el-descriptions-item label="已收笔数">--</el-descriptions-item>
+        <el-descriptions-item label="应交总额"><b>¥{{ (current.starting_cash + current.collected_cash).toFixed(2) }}</b></el-descriptions-item>
+      </el-descriptions>
+      <div style="margin-top:16px">
+        <el-input-number v-model="endForm.actual_cash" :min="0" :precision="2" placeholder="实交现金" style="width:200px" />
+        <el-button type="danger" style="margin-left:12px" @click="doEnd">交班</el-button>
+      </div>
+    </el-card>
+
+    <el-card v-else>
+      <el-form inline>
+        <el-form-item label="接班现金">
+          <el-input-number v-model="startForm.starting_cash" :min="0" :precision="2" style="width:200px" />
+        </el-form-item>
+        <el-form-item><el-button type="primary" @click="doStart">接班</el-button></el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-table :data="list" border style="margin-top:16px">
+      <el-table-column label="接班时间" prop="start_time" width="170" />
+      <el-table-column label="交班时间" prop="end_time" width="170" />
+      <el-table-column label="接班现金" width="100"><template #default="{row}">¥{{ row.starting_cash.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="当班收款" width="100"><template #default="{row}">¥{{ row.collected_cash.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="应交" width="100"><template #default="{row}">¥{{ row.expected_total.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="实交" width="100"><template #default="{row}">¥{{ row.actual_total.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="差异" width="100"><template #default="{row}"><span :style="{color:row.difference<0?'#f56c6c':'#67c23a'}">¥{{ row.difference.toFixed(2) }}</span></template></el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { startShift, endShift, getCurrentShift, getShiftList } from '@/api/shift'
+
+const current = ref(null), list = ref([])
+const startForm = reactive({ starting_cash: 0 })
+const endForm = reactive({ actual_cash: 0 })
+
+async function fetch() {
+  const r = await getCurrentShift(); if (r.code === 0) current.value = r.data; else current.value = null
+  const l = await getShiftList({ page: 1, page_size: 20 }); if (l.code === 0) { list.value = l.data.list || []; list.value.forEach(s => s.collected_cash = s.collected_cash || 0) }
+}
+async function doStart() { const r = await startShift(startForm); if (r.code === 0) { ElMessage.success('接班成功'); fetch() } else ElMessage.error(r.msg) }
+async function doEnd() { const r = await endShift(endForm); if (r.code === 0) { ElMessage.success(`交班完成,差异¥${r.data.difference.toFixed(2)}`); fetch() } else ElMessage.error(r.msg) }
+
+onMounted(fetch)
+</script>
+```
+
+Write `frontend/src/api/shift.js`:
+
+```js
+import service from '@/utils/request'
+export const startShift = (data) => service({ url: '/shift/start', method: 'post', data })
+export const endShift = (data) => service({ url: '/shift/end', method: 'post', data })
+export const getCurrentShift = () => service({ url: '/shift/current', method: 'get' })
+export const getShiftList = (params) => service({ url: '/shift/list', method: 'get', params })
+```
+
+- [ ] **Step 2: 收入报表页面**
+
+Write `frontend/src/view/report/revenue.vue`:
+
+```vue
+<template>
+  <div>
+    <el-form inline>
+      <el-form-item label="日期">
+        <el-date-picker v-model="dateRange" type="daterange" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" />
+      </el-form-item>
+      <el-form-item label="停车场">
+        <el-select v-model="lotId" clearable placeholder="全部" style="width:180px">
+          <el-option v-for="l in lotList" :key="l.ID" :label="l.lot_name" :value="l.ID" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="fetch">{{ $t.value.Search }}</el-button>
+      </el-form-item>
+    </el-form>
+    <el-table :data="list" border show-summary :summary-method="summaries">
+      <el-table-column label="日期" prop="date" width="120" />
+      <el-table-column label="停车场" prop="parking_lot" width="140" />
+      <el-table-column label="现金" width="100"><template #default="{row}">¥{{ row.cash.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="免密" width="100"><template #default="{row}">¥{{ row.free.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="总收入" width="120"><template #default="{row}">¥{{ row.total.toFixed(2) }}</template></el-table-column>
+      <el-table-column label="笔数" prop="count" width="80" />
+    </el-table>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { getRevenueReport } from '@/api/report'
+import { obtainPullOverList } from '@/api/pullOver'
+
+const dateRange = ref([]), lotId = ref(null), list = ref([]), lotList = ref([])
+
+function summaries({ columns, data }) {
+  const sums = ['合计', '', 0, 0, 0, 0]
+  data.forEach(r => { sums[2] += r.cash; sums[3] += r.free; sums[4] += r.total; sums[5] += r.count })
+  return columns.map((c, i) => i >= 2 ? (i === 5 ? sums[i] : '¥' + sums[i].toFixed(2)) : sums[i])
+}
+
+async function fetch() {
+  const [s, e] = dateRange.value && dateRange.value.length === 2 ? dateRange.value : ['', '']
+  const r = await getRevenueReport({ start_date: s, end_date: e, parking_lot_id: lotId.value || '' })
+  if (r.code === 0) list.value = r.data || []
+}
+
+onMounted(async () => {
+  const today = new Date().toISOString().slice(0, 10)
+  dateRange.value = [today, today]
+  const l = await obtainPullOverList({ page: 1, pageSize: 100 })
+  if (l.code === 0) lotList.value = l.data?.list || []
+  fetch()
+})
+</script>
+```
+
+Write `frontend/src/api/report.js`:
+
+```js
+import service from '@/utils/request'
+export const getRevenueReport = (params) => service({ url: '/report/revenue', method: 'get', params })
+```
+
+- [ ] **Step 3: 菜单 + 翻译**
+
+DB inserts:
+```bash
+# 交接班: ParentId=20, Sort=7
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO sys_base_menus (menu_level,parent_id,path,name,hidden,component,sort,title,icon,created_at,updated_at) VALUES (0,20,'shiftRecord','shiftRecord',0,'view/parking/shiftRecord.vue',7,'交接班管理','clock',datetime('now'),datetime('now')); INSERT INTO sys_authority_menus VALUES ('618',34),('888',34),('9527',34);"
+
+# 收入报表: ParentId=25, Sort=3
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO sys_base_menus (menu_level,parent_id,path,name,hidden,component,sort,title,icon,created_at,updated_at) VALUES (0,25,'revenue','revenue',0,'view/report/revenue.vue',3,'收入报表','money',datetime('now'),datetime('now')); INSERT INTO sys_authority_menus VALUES ('618',35),('888',35),('9527',35);"
+```
+
+Seed.go: add menu entries + authority_menu + Casbin rules.
+
+zh-CN.js:
+```js
+shiftRecord: '交接班管理',
+revenueReport: '收入报表',
+```
+
+en.js:
+```js
+shiftRecord: 'Shift Record',
+revenueReport: 'Revenue Report',
+```
+
+- [ ] **Step 4: 编译+提交**
+
+```bash
+cd frontend && npm run build && cd ..
+git add frontend/src/ internal/initialize/seed.go
+git commit -m "feat: 交接班前端+收入报表+菜单+多语言"
+```