2026-07-29-shift-report-plan.md 18 KB

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

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:

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{}):

dao.DigitalTicket{},
dao.ShiftRecord{},
  • Step 2: Repository + Service

Write internal/modules/shift/repository/repo.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:

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:

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:

shift.SetupShiftRouter(PrivateGroup)
  • Step 4: Casbin + 编译+提交
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:

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:

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:

report.SetupReportRouter(PrivateGroup)
  • Step 2: Casbin + 编译+提交
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:

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

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:

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

import service from '@/utils/request'
export const getRevenueReport = (params) => service({ url: '/report/revenue', method: 'get', params })
  • Step 3: 菜单 + 翻译

DB inserts:

# 交接班: 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:

shiftRecord: '交接班管理',
revenueReport: '收入报表',

en.js:

shiftRecord: 'Shift Record',
revenueReport: 'Revenue Report',
  • Step 4: 编译+提交
cd frontend && npm run build && cd ..
git add frontend/src/ internal/initialize/seed.go
git commit -m "feat: 交接班前端+收入报表+菜单+多语言"