นากการ์ตูนสีมิ้นต์ยิ้มชี้ปฏิทินรายเดือนบนผนังและวางเหรียญทองไว้ที่ช่องวันทำการสุดท้ายก่อนวันหยุดซึ่งแรเงาสีเทา บนโต๊ะมีซองใส่เหรียญทอง

คำนวณวันเงินเดือนออกด้วย Java TemporalAdjusters พร้อมเทสต์วันสิ้นเดือน

น้องกายEngineering

ระบบเงินเดือนอาจกำหนดให้จ่ายวันทำการสุดท้ายของเดือน แต่ระบบเจ้าหนี้กลับใช้วันศุกร์สุดท้ายของเดือน ถ้าเขียนสองกฎนี้ปนกัน เงินอาจออกคนละวันกับที่ฝ่ายบัญชีคาดไว้ครับ ยิ่งเจอกุมภาพันธ์หรือสิ้นเดือนตรงวันหยุด โค้ดที่ตั้งวันที่เป็น 30 หรือ 31 ไว้ตายตัวก็เริ่มมีปัญหา เราจะใช้ TemporalAdjusters ทำให้กฎเหล่านี้อ่านออกจากโค้ด และมีเทสต์ยืนยันวันที่ก่อนนำไปใช้จริง

WHY: กฎวันจ่ายเงินควรอ่านแล้วตรงกับภาษาของฝ่ายบัญชี

  • ลดการเขียนเงื่อนไขแยกจำนวนวันของแต่ละเดือน ใช้ lastDayOfMonth() หาวันสุดท้ายตามเดือนที่รับเข้ามา รวมถึงกุมภาพันธ์ปีอธิกสุรทิน ซึ่งเป็นปีที่มีวันที่ 29 กุมภาพันธ์
  • แยกความหมายให้ชัดว่า “ศุกร์สุดท้าย” หรือ “วันทำการสุดท้าย” เพราะสองกฎนี้ให้ผลต่างกันได้ แม้ใช้เดือนเดียวกัน
  • รวมกฎเฉพาะบริษัทไว้จุดเดียว เช่น ถ้าสิ้นเดือนเป็นเสาร์หรืออาทิตย์ ให้ถอยกลับจนเจอวันจันทร์ถึงศุกร์ ทีมอื่นจึงเรียกกฎเดียวกันได้
  • ทดสอบวันสำคัญด้วยวันที่คงที่ ไม่ต้องรอให้ระบบเดินทางไปถึงสิ้นเดือนจริงก่อนค่อยรู้ว่าคิดผิด

ตาม เอกสาร TemporalAdjusters คลาสนี้เตรียมตัวปรับวันที่มาตรฐานไว้ให้ และแนะนำรูปแบบ date.with(adjuster) เพราะอ่านชัดกว่าเรียก adjustInto() โดยตรง

ทำงานยังไง

TemporalAdjuster คือกฎที่ใช้ปรับวันที่ ส่วน TemporalAdjusters คือคลาสที่รวมกฎสำเร็จรูปไว้ให้ เปรียบเหมือนบอกพนักงานที่ถือปฏิทินว่า “เปิดไปวันสุดท้ายของเดือนนี้ แล้วถ้าร้านปิดให้ถอยกลับ” เราระบุคำสั่ง ส่วนขั้นตอนเดินบนปฏิทินอยู่ในกฎนั้นครับ

บทความนี้รับและคืนค่าเป็น LocalDate เพื่อคำนวณวันที่ของงวด ตัวอย่างใช้ปี ค.ศ. เช่น 2024 ไม่ใช่เลขปี พ.ศ. lastDayOfMonth() เลือกวันสิ้นเดือน ส่วน lastInMonth(FRIDAY) เลือกวันศุกร์ครั้งสุดท้ายภายในเดือน และ firstInMonth(MONDAY) เลือกวันจันทร์ครั้งแรกของเดือน ทั้งสามกฎอ้างเดือนของวันที่ตั้งต้น ไม่ได้เริ่มนับจากวันนี้

อีกกลุ่มใช้วันที่ตั้งต้นเป็นจุดเดินหน้าและถอยหลัง: next(FRIDAY) ต้องไปศุกร์ที่อยู่หลังวันตั้งต้นเสมอ แม้ตั้งต้นเป็นศุกร์ก็ข้ามไปอีกสัปดาห์ ส่วน previousOrSame(FRIDAY) ถอยไปศุกร์ก่อนหน้า หรืออยู่วันเดิมถ้าเป็นศุกร์แล้ว ความต่างนี้ระบุไว้ใน รายละเอียดกฎปรับวันที่ และมีเทสต์ให้เห็นด้านล่าง

สำหรับกฎของเรา ofDateAdjuster() ใช้ห่อฟังก์ชันที่รับและคืน LocalDate ให้เป็น adjuster ได้ เราจะเริ่มจากวันสิ้นเดือน แล้ววนถอยทีละวันเฉพาะเมื่อเป็นเสาร์หรืออาทิตย์ ใช้ DayOfWeek.SATURDAY และ SUNDAY ให้เห็นชื่อวันตรง ๆ ตามคำแนะนำใน เอกสาร DayOfWeek ที่ให้ใช้ enum ซึ่งเป็นชุดค่าที่ตั้งชื่อไว้ แทนตัวเลขวัน

โค้ดตัวอย่างพร้อมเทสต์

สมมติฝ่ายบัญชีต้องเตรียมเงินเดือนคุณมาลี 32,500 บาท เราจะคำนวณวันจ่ายโดยใช้กฎข้ามเสาร์อาทิตย์เท่านั้น ชื่อและจำนวนเงินเป็นข้อมูลสมมติ บันทึกบล็อกนี้เป็น Main.java แล้วรัน javac -encoding UTF-8 Main.java ตามด้วย java -ea Main โดย -ea เปิดการตรวจ assert ซึ่งจะหยุดโปรแกรมเมื่อเงื่อนไขทดสอบไม่เป็นจริง

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;

public class Main {
    // กฎตัวอย่าง: วันทำการหมายถึงจันทร์ถึงศุกร์เท่านั้น
    private static final TemporalAdjuster LAST_BUSINESS_DAY =
            TemporalAdjusters.ofDateAdjuster(date -> {
                LocalDate result = date.with(TemporalAdjusters.lastDayOfMonth());
                while (result.getDayOfWeek() == DayOfWeek.SATURDAY
                        || result.getDayOfWeek() == DayOfWeek.SUNDAY) {
                    result = result.minusDays(1);
                }
                return result;
            });

    public static void main(String[] args) {
        LocalDate period = LocalDate.of(2024, 2, 10);
        LocalDate monthEnd = period.with(TemporalAdjusters.lastDayOfMonth());
        LocalDate lastFriday = period.with(
                TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
        LocalDate firstMonday = period.with(
                TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
        LocalDate payday = period.with(LAST_BUSINESS_DAY);

        // กุมภาพันธ์ปีอธิกสุรทิน: สิ้นเดือนเป็นพฤหัสบดี
        assert monthEnd.equals(LocalDate.of(2024, 2, 29));
        assert monthEnd.getDayOfWeek() == DayOfWeek.THURSDAY;
        assert lastFriday.equals(LocalDate.of(2024, 2, 23));
        assert firstMonday.equals(LocalDate.of(2024, 2, 5));
        assert payday.equals(LocalDate.of(2024, 2, 29));
        assert period.equals(LocalDate.of(2024, 2, 10));

        // next ต้องเดินหน้า; previousOrSame ยอมให้เป็นวันเดิม
        assert monthEnd.with(TemporalAdjusters.next(DayOfWeek.FRIDAY))
                .equals(LocalDate.of(2024, 3, 1));
        assert monthEnd.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY))
                .equals(LocalDate.of(2024, 2, 23));
        assert lastFriday.with(TemporalAdjusters.next(DayOfWeek.FRIDAY))
                .equals(LocalDate.of(2024, 3, 1));
        assert lastFriday.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY))
                .equals(lastFriday);

        // ทดสอบสิ้นเดือนวันเสาร์ วันอาทิตย์ และกุมภาพันธ์ปีปกติ
        assert LocalDate.of(2024, 8, 12).with(LAST_BUSINESS_DAY)
                .equals(LocalDate.of(2024, 8, 30));
        assert LocalDate.of(2024, 3, 12).with(LAST_BUSINESS_DAY)
                .equals(LocalDate.of(2024, 3, 29));
        assert LocalDate.of(2023, 2, 12).with(LAST_BUSINESS_DAY)
                .equals(LocalDate.of(2023, 2, 28));

        String employee = "มาลี";
        int salaryBaht = 32_500;
        System.out.println(employee + " | " + salaryBaht + " บาท | " + payday);
        System.out.println("OK: all date assertions passed");
    }
}

ผมรันด้วย OpenJDK Temurin 21.0.12.1 โดยคอมไพล์ด้วย javac และรัน java -ea Main แล้ว เทสต์ผ่านทุก assert ครับ

ผลที่พิมพ์คือ มาลี | 32500 บาท | 2024-02-29 และบรรทัดสุดท้าย OK: all date assertions passed จุดที่ควรสังเกตคือวันศุกร์สุดท้ายได้วันที่ 23 แต่วันทำการสุดท้ายได้วันที่ 29 จึงใช้แทนกันไม่ได้ ส่วนกฎ next(FRIDAY) จากวันสิ้นเดือนข้ามไปเดือนมีนาคมได้ตามความหมายของคำว่า “ถัดไป”

เราเก็บกฎที่สร้างเองเป็น static final เพื่อให้เรียกใช้จากจุดเดียว ภายในกฎจะหาวันสิ้นเดือนก่อนเสมอ ดังนั้นส่งวันที่ 10 หรือ 12 ก็ยังคำนวณงวดของเดือนนั้นได้ ตัวอย่างนี้ยังตรวจว่าหลังคำนวณแล้วตัวแปร period มีค่าเดิม หากต้องการใช้ผลลัพธ์ต่อ ให้รับค่าจาก with() ไว้เหมือนตัวแปร payday

จุดที่คนพลาดบ่อย

  • ใช้ศุกร์สุดท้ายแทนวันทำการสุดท้าย: ถ้าสิ้นเดือนตรงพฤหัสบดี กฎศุกร์สุดท้ายจะถอยไปไกลกว่าที่ต้องการ แก้โดยตกลงนิยามกับฝ่ายบัญชี แล้วเลือก lastInMonth(FRIDAY) หรือกฎข้ามเสาร์อาทิตย์ให้ตรงงาน
  • คิดว่า next() รวมวันเดิม: เมื่อวันตั้งต้นเป็นศุกร์ next(FRIDAY) จะไปศุกร์ถัดไป แก้โดยใช้ nextOrSame() เมื่อต้องการให้คงวันเดิมได้ หรือ previousOrSame() เมื่อต้องการถอยกลับและยอมรับวันเดิม
  • ถือว่าจันทร์ถึงศุกร์เปิดทำการเสมอ: กฎตัวอย่างไม่รู้วันหยุดนักขัตฤกษ์ วันหยุดชดเชย หรือวันปิดบริษัท แก้โดยจัดการรายการวันหยุดที่งานนั้นใช้เอง แล้วเพิ่มเงื่อนไขให้ถอยเมื่อวันอยู่ในรายการด้วย ต้องเทสต์กรณีวันหยุดติดกันเพิ่ม
  • ใช้เลขวันจาก ordinal(): อย่านำลำดับ enum มาเดาวันในสัปดาห์ แก้โดยเทียบชื่อ DayOfWeek ตรง ๆ ถ้าจำเป็นต้องใช้เลขจริง เอกสารกำหนดให้ใช้ getValue() ซึ่งเป็นจันทร์ 1 ถึงอาทิตย์ 7
  • เห็นคำว่า OK แล้วเชื่อโดยไม่ได้เปิด assert: ถ้ารันโดยไม่มี -ea เงื่อนไขทดสอบในตัวอย่างจะไม่ถูกตรวจ แก้โดยใส่ -ea ทุกครั้งที่รันเทสต์ชุดนี้ บรรทัด OK จึงมีความหมายเมื่อรันด้วยคำสั่งที่ระบุไว้

ใช้ตรงไหนในงานจริง

  • สร้างวันจ่ายเงินเดือนแต่ละงวด โดยแยกกฎวันจ่ายออกจากการคำนวณยอดเงิน และให้เจ้าของกระบวนการยืนยันนิยามวันทำการ
  • เตรียมวันครบกำหนดตามสัญญาที่เขียนว่า “ศุกร์สุดท้ายของเดือน” ด้วย lastInMonth(FRIDAY) พร้อมเก็บวันที่คำนวณได้ไว้ตรวจสอบ
  • วางวันประชุมหรือวันส่งรายงานประจำเดือน เช่น จันทร์แรกด้วย firstInMonth(MONDAY)
  • คำนวณวันติดตามเอกสารรอบถัดไปด้วย next() หรือวันเป้าหมายที่ไม่เกินวันที่กำหนดด้วย previousOrSame() แล้วค่อยส่งวันที่นั้นให้ระบบนัดหมายอีกขั้น

โค้ดนี้คำนวณวันที่เท่านั้น ส่วนเวลาเริ่มงานและการสั่งโอนเงินจริงต้องจัดการในระบบที่นำวันที่ไปใช้ต่อครับ

เทียบกับทางเลือกอื่น

วิธีดีตรงไหนระวังอะไร
Adjuster สำเร็จรูปชื่อเมธอดตรงกับกฎ เช่น วันศุกร์สุดท้ายต้องเลือกกฎให้ตรงความหมาย ไม่ใช่แค่วันที่ดูใกล้กัน
Adjuster ที่สร้างเองรวมกฎวันทำการให้หลายจุดใช้ร่วมกันต้องนิยามวันหยุดและทดสอบเอง
เขียนเงื่อนไขรายเดือนเองเห็นทุกขั้นตอนและปรับรายละเอียดได้โค้ดซ้ำและหลุดกรณีปีอธิกสุรทินได้ง่าย
ตารางวันจ่ายที่ฝ่ายบัญชีอนุมัติรองรับข้อยกเว้นเฉพาะงวดได้ตรงไปตรงมาต้องมีคนดูแลข้อมูลและตรวจความครบถ้วน

Recap

  • ระบุก่อนว่างานใช้ศุกร์สุดท้ายหรือวันทำการสุดท้าย
  • ใช้ lastDayOfMonth() แทนการตั้งวัน 30 หรือ 31 เอง
  • เลือก firstInMonth() และ lastInMonth() ตามกฎภายในเดือน
  • แยกความหมาย next() ออกจากเมธอดที่ลงท้าย OrSame()
  • สร้างกฎข้ามเสาร์อาทิตย์ด้วย ofDateAdjuster() และรับผลจาก with()
  • เพิ่มรายการวันหยุดของงานเองเมื่อต้องใช้วันทำการจริง
  • เทสต์ปีอธิกสุรทิน สิ้นเดือนเสาร์อาทิตย์ และวันตั้งต้นที่ตรงวันเป้าหมาย
  • เปิด -ea เพื่อให้ Java ตรวจ assert ทุกครั้งที่รันตัวอย่าง