feat(finance): 资金出款支持按具体日期时间(时分秒)筛选
This commit is contained in:
@@ -2,6 +2,7 @@ package adminfinance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -292,6 +293,45 @@ func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
|
|||||||
return query, true
|
return query, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseFlexibleDateTime(raw string, loc *time.Location, isEnd bool) (time.Time, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, errors.New("empty time string")
|
||||||
|
}
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, fmtStr := range formats {
|
||||||
|
if t, err := time.ParseInLocation(fmtStr, raw, loc); err == nil {
|
||||||
|
switch fmtStr {
|
||||||
|
case "2006-01-02":
|
||||||
|
if isEnd {
|
||||||
|
return t.AddDate(0, 0, 1).Add(-time.Nanosecond), nil
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
case "2006-01-02 15:04", "2006-01-02T15:04":
|
||||||
|
if isEnd {
|
||||||
|
return t.Add(time.Minute).Add(-time.Nanosecond), nil
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
case "2006-01-02 15:04:05", "2006-01-02T15:04:05":
|
||||||
|
if isEnd {
|
||||||
|
return t.Add(time.Second - time.Nanosecond), nil
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
default:
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("invalid time format: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
|
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
|
||||||
loc := timeutil.ShanghaiLocation()
|
loc := timeutil.ShanghaiLocation()
|
||||||
today := timeutil.ShanghaiNow()
|
today := timeutil.ShanghaiNow()
|
||||||
@@ -300,24 +340,49 @@ func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Ti
|
|||||||
|
|
||||||
start := defaultStart
|
start := defaultStart
|
||||||
end := defaultEnd
|
end := defaultEnd
|
||||||
if raw := c.Query("start_date"); raw != "" {
|
|
||||||
parsed, err := time.ParseInLocation("2006-01-02", raw, loc)
|
rawStart := strings.TrimSpace(c.Query("start_time"))
|
||||||
|
usingStartTime := rawStart != ""
|
||||||
|
if rawStart == "" {
|
||||||
|
rawStart = strings.TrimSpace(c.Query("start_date"))
|
||||||
|
}
|
||||||
|
if rawStart != "" {
|
||||||
|
parsed, err := parseFlexibleDateTime(rawStart, loc, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "开始日期不正确")
|
if usingStartTime {
|
||||||
|
response.BadRequest(c, "开始时间不正确")
|
||||||
|
} else {
|
||||||
|
response.BadRequest(c, "开始日期不正确")
|
||||||
|
}
|
||||||
return start, end, false
|
return start, end, false
|
||||||
}
|
}
|
||||||
start = parsed
|
start = parsed
|
||||||
}
|
}
|
||||||
if raw := c.Query("end_date"); raw != "" {
|
|
||||||
parsed, err := time.ParseInLocation("2006-01-02", raw, loc)
|
rawEnd := strings.TrimSpace(c.Query("end_time"))
|
||||||
|
usingEndTime := rawEnd != ""
|
||||||
|
if rawEnd == "" {
|
||||||
|
rawEnd = strings.TrimSpace(c.Query("end_date"))
|
||||||
|
}
|
||||||
|
if rawEnd != "" {
|
||||||
|
parsed, err := parseFlexibleDateTime(rawEnd, loc, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "结束日期不正确")
|
if usingEndTime {
|
||||||
|
response.BadRequest(c, "结束时间不正确")
|
||||||
|
} else {
|
||||||
|
response.BadRequest(c, "结束日期不正确")
|
||||||
|
}
|
||||||
return start, end, false
|
return start, end, false
|
||||||
}
|
}
|
||||||
end = parsed.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
end = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.After(end) {
|
if start.After(end) {
|
||||||
response.BadRequest(c, "开始日期不能晚于结束日期")
|
if usingStartTime || usingEndTime {
|
||||||
|
response.BadRequest(c, "开始时间不能晚于结束时间")
|
||||||
|
} else {
|
||||||
|
response.BadRequest(c, "开始日期不能晚于结束日期")
|
||||||
|
}
|
||||||
return start, end, false
|
return start, end, false
|
||||||
}
|
}
|
||||||
return start, end, true
|
return start, end, true
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package adminfinance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFlexibleDateTime(t *testing.T) {
|
||||||
|
loc := timeutil.ShanghaiLocation()
|
||||||
|
|
||||||
|
// 1. Test YYYY-MM-DD
|
||||||
|
start, err := parseFlexibleDateTime("2026-09-01", loc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for date start: %v", err)
|
||||||
|
}
|
||||||
|
if start.Year() != 2026 || start.Month() != 9 || start.Day() != 1 || start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 {
|
||||||
|
t.Fatalf("unexpected start date: %v", start)
|
||||||
|
}
|
||||||
|
|
||||||
|
end, err := parseFlexibleDateTime("2026-09-01", loc, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for date end: %v", err)
|
||||||
|
}
|
||||||
|
if end.Year() != 2026 || end.Month() != 9 || end.Day() != 1 || end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 {
|
||||||
|
t.Fatalf("unexpected end date: %v", end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test YYYY-MM-DD HH:mm
|
||||||
|
startMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for minute start: %v", err)
|
||||||
|
}
|
||||||
|
if startMinute.Hour() != 14 || startMinute.Minute() != 30 || startMinute.Second() != 0 {
|
||||||
|
t.Fatalf("unexpected start minute: %v", startMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
endMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for minute end: %v", err)
|
||||||
|
}
|
||||||
|
if endMinute.Hour() != 14 || endMinute.Minute() != 30 || endMinute.Second() != 59 {
|
||||||
|
t.Fatalf("unexpected end minute: %v", endMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test YYYY-MM-DD HH:mm:ss
|
||||||
|
startSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for second start: %v", err)
|
||||||
|
}
|
||||||
|
if startSec.Hour() != 14 || startSec.Minute() != 30 || startSec.Second() != 15 {
|
||||||
|
t.Fatalf("unexpected start sec: %v", startSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
endSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for second end: %v", err)
|
||||||
|
}
|
||||||
|
if endSec.Hour() != 14 || endSec.Minute() != 30 || endSec.Second() != 15 {
|
||||||
|
t.Fatalf("unexpected end sec: %v", endSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Test invalid
|
||||||
|
if _, err := parseFlexibleDateTime("invalid-date", loc, false); err == nil {
|
||||||
|
t.Fatalf("expected error for invalid date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateRangeGin(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantStatus int
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default range",
|
||||||
|
query: "",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date range",
|
||||||
|
query: "?start_date=2026-09-01&end_date=2026-09-07",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "datetime range with minute",
|
||||||
|
query: "?start_date=2026-09-01%2009:30&end_date=2026-09-07%2018:45",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "datetime range with second",
|
||||||
|
query: "?start_date=2026-09-01%2009:30:00&end_date=2026-09-07%2018:45:59",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start_time and end_time params",
|
||||||
|
query: "?start_time=2026-09-01%2009:30:00&end_time=2026-09-07%2018:45:59",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start after end",
|
||||||
|
query: "?start_date=2026-09-07&end_date=2026-09-01",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid date",
|
||||||
|
query: "?start_date=invalid",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/test"+tc.query, nil)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
_, _, ok := parseDateRange(c, 29)
|
||||||
|
if ok != tc.wantOk {
|
||||||
|
t.Fatalf("parseDateRange() ok = %v, wantOk = %v", ok, tc.wantOk)
|
||||||
|
}
|
||||||
|
if !ok && w.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("HTTP status = %d, wantStatus = %d", w.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,6 +312,8 @@ export interface FinanceDetail {
|
|||||||
export interface FinanceDateQuery {
|
export interface FinanceDateQuery {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceDetailQuery extends FinanceDateQuery {
|
export interface FinanceDetailQuery extends FinanceDateQuery {
|
||||||
|
|||||||
@@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
gap: 14px 16px;
|
gap: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { useAdminSessionStore } from '@/stores/adminSession'
|
|||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
|
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
|
||||||
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
|
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
|
||||||
|
|
||||||
@@ -73,10 +73,84 @@ const createForm = reactive({
|
|||||||
voucher_url: '',
|
voucher_url: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const defaultStartTime = new Date(2000, 0, 1, 0, 0, 0)
|
||||||
|
const defaultEndTime = new Date(2000, 0, 1, 23, 59, 59)
|
||||||
|
|
||||||
|
const startTimeShortcuts = [
|
||||||
|
{
|
||||||
|
text: '今天 00:00',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '昨天 00:00',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '近7天',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 6)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '近30天',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 29)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '本月初',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const endTimeShortcuts = [
|
||||||
|
{
|
||||||
|
text: '此刻',
|
||||||
|
value: () => new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '今天 23:59',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setHours(23, 59, 59, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '昨天 23:59',
|
||||||
|
value: () => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
d.setHours(23, 59, 59, 0)
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
date_type: 'created',
|
date_type: 'created',
|
||||||
start_date: defaultStartDate(),
|
start_date: defaultStartDateTime(),
|
||||||
end_date: formatInputDate(new Date()),
|
end_date: defaultEndDateTime(),
|
||||||
source_type: '',
|
source_type: '',
|
||||||
status: '',
|
status: '',
|
||||||
business_no: '',
|
business_no: '',
|
||||||
@@ -115,8 +189,8 @@ function search() {
|
|||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filters.date_type = 'created'
|
filters.date_type = 'created'
|
||||||
filters.start_date = defaultStartDate()
|
filters.start_date = defaultStartDateTime()
|
||||||
filters.end_date = formatInputDate(new Date())
|
filters.end_date = defaultEndDateTime()
|
||||||
filters.source_type = ''
|
filters.source_type = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.business_no = ''
|
filters.business_no = ''
|
||||||
@@ -263,10 +337,17 @@ function emptySummary(): FinanceDisbursementListSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultStartDate() {
|
function defaultStartDateTime() {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
date.setDate(date.getDate() - 29)
|
date.setDate(date.getDate() - 29)
|
||||||
return formatInputDate(date)
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return formatDateTime(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEndDateTime() {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(23, 59, 59, 0)
|
||||||
|
return formatDateTime(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(cents: number | undefined | null) {
|
function money(cents: number | undefined | null) {
|
||||||
@@ -446,29 +527,37 @@ function accountTypeLabel(type: string) {
|
|||||||
<!-- 筛选面板 -->
|
<!-- 筛选面板 -->
|
||||||
<section class="section-card filter-card">
|
<section class="section-card filter-card">
|
||||||
<el-form class="filter-grid" label-position="top" @keyup.enter="search">
|
<el-form class="filter-grid" label-position="top" @keyup.enter="search">
|
||||||
<el-form-item label="日期口径">
|
<el-form-item label="时间口径">
|
||||||
<el-select v-model="filters.date_type" class="full-control">
|
<el-select v-model="filters.date_type" class="full-control">
|
||||||
<el-option label="业务形成日期" value="created" />
|
<el-option label="业务形成时间" value="created" />
|
||||||
<el-option label="实际打款日期" value="paid" />
|
<el-option label="实际打款时间" value="paid" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="开始日期">
|
<el-form-item label="开始时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="filters.start_date"
|
v-model="filters.start_date"
|
||||||
class="full-control"
|
class="full-control"
|
||||||
type="date"
|
type="datetime"
|
||||||
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="选择开始时间"
|
||||||
|
:default-time="defaultStartTime"
|
||||||
|
:shortcuts="startTimeShortcuts"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="结束日期">
|
<el-form-item label="结束时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="filters.end_date"
|
v-model="filters.end_date"
|
||||||
class="full-control"
|
class="full-control"
|
||||||
type="date"
|
type="datetime"
|
||||||
value-format="YYYY-MM-DD"
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="选择结束时间"
|
||||||
|
:default-time="defaultEndTime"
|
||||||
|
:shortcuts="endTimeShortcuts"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
Reference in New Issue
Block a user