Java 日期運算實戰:精確計算上一季度起止時間,解決月份邊界與時分秒陷阱
財務報表、業務統計、數據倉庫 ETL 常需按自然季度劃分時間範圍。本文提供基於 Calendar 的健壯實現,處理季度邊界、月末日期、時分秒歸零等細節,並給出 Java 8+ java.time 現代替代方案。
季度劃分規則
| 季度 | 月份範圍 | 起始日 | 結束日 |
| Q1 | 1-3 月 | 1 月 1 日 00:00:00 | 3 月 31 日 23:59:59 |
| Q2 | 4-6 月 | 4 月 1 日 00:00:00 | 6 月 30 日 23:59:59 |
| Q3 | 7-9 月 | 7 月 1 日 00:00:00 | 9 月 30 日 23:59:59 |
| Q4 | 10-12 月 | 10 月 1 日 00:00:00 | 12 月 31 日 23:59:59 |
Calendar 版實現 (兼容 JDK 6+)
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class QuarterUtils {
private static final SimpleDateFormat FULL_FMT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static final SimpleDateFormat DAY_FMT = new SimpleDateFormat("yyyy-MM-dd");
/**
* 獲取上一季度起始或結束時間
* @param isStart true=季度首日 00:00:00, false=季度末日 23:59:59
* @return 格式化時間字符串
*/
public static String getLastQuarterTime(boolean isStart) {
Calendar cal = Calendar.getInstance();
int currentMonth = cal.get(Calendar.MONTH); // 0-based
// 1. 確定當前季度首月 (0,3,6,9)
int currentQuarterStartMonth;
if (currentMonth <= 2) currentQuarterStartMonth = 0; // Q1
else if (currentMonth <= 5) currentQuarterStartMonth = 3; // Q2
else if (currentMonth <= 8) currentQuarterStartMonth = 6; // Q3
else currentQuarterStartMonth = 9; // Q4
// 2. 回退到上一季度首月
int lastQuarterStartMonth = currentQuarterStartMonth - 3;
if (lastQuarterStartMonth < 0) {
lastQuarterStartMonth += 12;
cal.add(Calendar.YEAR, -1); // 跨年
}
// 3. 設置到上一季度首月 1 號
cal.set(Calendar.MONTH, lastQuarterStartMonth);
cal.set(Calendar.DATE, 1);
if (isStart) {
// 季度開始:首日 00:00:00
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
} else {
// 季度結束:末月最後一日 23:59:59
// 先加 3 個月到下一季度首月,再減 1 天
cal.add(Calendar.MONTH, 3);
cal.add(Calendar.DATE, -1);
cal.set(Calendar.HOUR_OF_DAY, 23);
cal.set(Calendar.MINUTE, 59);
cal.set(Calendar.SECOND, 59);
cal.set(Calendar.MILLISECOND, 999);
}
return FULL_FMT.format(cal.getTime());
}
// 測試
public static void main(String[] args) {
System.out.println("上季度開始: " + getLastQuarterTime(true));
System.out.println("上季度結束: " + getLastQuarterTime(false));
}
}
關鍵設計決策解析
- 不硬編碼月末日期:用
cal.add(MONTH, 3); cal.add(DATE, -1) 自動推導,自動處理 28/29/30/31 日及閏年
- 毫秒級精度:結束時間設為 23:59:59.999,配合資料庫
<= 查詢避免邊界遺漏
- 跨年處理:1 月查詢上季度需回退年份,代碼通用無特判
- 線程安全:方法內創建
Calendar 實例,無共享狀態
Java 8+ 現代寫法 (推薦新項目)
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
public class QuarterUtilsModern {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String getLastQuarterStart() {
LocalDateTime now = LocalDateTime.now();
// 獲取當前季度首日
LocalDateTime quarterStart = now.with(TemporalAdjusters.firstDayOfMonth())
.withMonth(firstMonthOfQuarter(now.getMonthValue()))
.withHour(0).withMinute(0).withSecond(0).withNano(0);
// 減去一個季度
return quarterStart.minusMonths(3).format(FMT);
}
public static String getLastQuarterEnd() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime quarterStart = now.with(TemporalAdjusters.firstDayOfMonth())
.withMonth(firstMonthOfQuarter(now.getMonthValue()))
.withHour(0).withMinute(0).withSecond(0).withNano(0);
// 上季度末 = 當前季度首日 - 1 納秒 (或 -1 秒視精度)
return quarterStart.minusMonths(3).plusMonths(3).minusNanos(1).format(FMT);
// 更清晰寫法:
// return quarterStart.minusMonths(3).with(TemporalAdjusters.lastDayOfMonth())
// .withHour(23).withMinute(59).withSecond(59).withNano(999_000_000).format(FMT);
}
private static int firstMonthOfQuarter(int month) {
return ((month - 1) / 3) * 3 + 1; // 1,4,7,10
}
// 更優雅:使用 YearQuarter (Java 15+ 預覽) 或三方庫 ThreeTen-Extra
// YearQuarter q = YearQuarter.from(LocalDate.now()).minus(1);
// LocalDate start = q.atDay(1);
// LocalDate end = q.atEndOfQuarter();
資料庫查詢最佳實踐
-- MySQL: 使用半開區間 [start, end) 避免秒級精度問題
SELECT * FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-04-01 00:00:00';
-- 索引友好:時間列加索引,避免函數運算
-- WHERE DATE(create_time) BETWEEN ... ❌ 不走索引
-- WHERE create_time >= ? AND create_time < ? ✅ 走索引
常見錯誤對照表
| 錯誤代碼 | 問題 | 後果 | 修正 |
| cal.set(MONTH, 2); cal.set(DATE, 31) | 硬編碼 3 月 31 日 | Q2 結束變成 6 月 31 日 (不存在) | 用 add(MONTH,3) + add(DATE,-1) |
| 只設置 HOUR/MINUTE/SECOND | 忽略 MILLISECOND | 結束時間 23:59:59.000 漏掉 59.999 的數據 | 設置 MILLISECOND=999 |
| SimpleDateFormat 靜態共享 | 非線程安全 | 高併發下格式錯亂/異常 | ThreadLocal 或局部變量/DateTimeFormatter |
| 跨年邏輯缺失 | 1 月查詢上季度不減年份 | 年份錯誤 | 檢測月份回退時調整年份 |
擴展:獲取任意季度範圍
public static String[] getQuarterRange(int year, int quarter, boolean includeTime) {
// quarter: 1-4
Calendar cal = Calendar.getInstance();
cal.set(Calendar.YEAR, year);
cal.set(Calendar.MONTH, (quarter - 1) * 3); // 0,3,6,9
cal.set(Calendar.DATE, 1);
setTime(cal, true); // 00:00:00
String start = FULL_FMT.format(cal.getTime());
cal.add(Calendar.MONTH, 3);
cal.add(Calendar.DATE, -1);
setTime(cal, false); // 23:59:59
String end = FULL_FMT.format(cal.getTime());
return new String[]{start, end};
}