5/04/2015

[SQL] Tạo bảng lịch ảo bằng truy vấn (phần 2)

Lưu ý:
  • Người đọc cần có kiến thức cơ bản về CSDL quan hệ và ngôn ngữ truy vấn SQL.
  • Các truy vấn ví dụ sử dụng trong bài được viết bằng cú pháp T-SQL của SQL Server.
  • Người đọc cần đọc phần 1 để hiểu rõ nội dung bài viết này.

Vấn đề

Trong phần 1 tôi đã giới thiệu một số giải pháp để tạo một bảng lịch ngày tháng có chứa thêm thông tin ngày nào là ngày nghỉ. Trong đó, giải pháp cao cấp mà tôi đưa ra về phương diện lý thuyết cốt lõi có thể nói là đã khá hoàn hảo và tôi cảm thấy ko cần thiết phải đào sâu cải tiến thêm nữa. Tuy nhiên việc ứng dụng cái lý thuyết cốt lõi ấy vào giải quyết vấn đề thực tế lại là một vấn đề khác và đòi hỏi rất nhiều sự sửa chữa, cải tiến ở mức độ chi tiết, cũng như mở rộng thêm chức năng để đáp ứng nhu cầu người dùng.
Cụ thể tôi có thể thấy được một số vấn đề đối với giải pháp cao cấp ở bài viết trước như sau:
  • Chênh lệch số ngày trong tháng: Nếu người dùng thiết lập quy luật nghỉ lặp hàng tháng lấy ngày 2015/1/31 làm ngày chuẩn, thì tháng 4 chỉ có 30 ngày sẽ ko có ngày nào ứng với quy luật này. Vậy người dùng nếu muốn thiết lập ngày cuối mỗi tháng là ngày nghỉ thì sẽ phải làm thế nào?
  • Ngày nghỉ trong quá khứ: Nếu sang năm nay quy luật nghỉ thứ Bảy Chủ Nhật được chuyển thành thứ Tư Chủ Nhật, thì tất cả các ngày thứ Bảy cho đến năm ngoái sẽ đột nhiên được hiển thị là ngày làm việc. Vậy người dùng nếu muốn đưa ra hai bản báo cáo thành tích từng ngày (có thông tin ngày nghỉ) của tháng 1 năm ngoài và tháng 1 năm nay để so sánh thì sẽ phải làm thế nào?
  • Thiết lập ngoại lệ: Nếu người dùng muốn thiết lập ngày cuối mỗi tháng là ngày nghỉ, nhưng vì 1/1 đã là ngày nghỉ lễ rồi nên ngày 12/31 là ngày đi làm, thì sẽ phải làm thế nào?

Chênh lệch số ngày trong tháng

Đây là vấn đề dễ thấy nhất (bị lộ sớm nhất), và cách sửa cũng đơn giản nhất. Vấn đề nảy sinh do khi xác định phương pháp đánh giá quy luật nghỉ, tôi đã dựa trên định nghĩa "ngày nghỉ lặp theo tháng là các ngày có cùng số ngày với ngày chuẩn". Định nghĩa này đứng từ góc nhìn của người làm hệ thống đang tìm cách giải quyết vấn đề. Trong khi đó suy nghĩ của người dùng thường sẽ là "mỗi tháng lấy một ngày nhất định theo tiêu chí nào đó làm ngày nghỉ".
Vậy thì để có giải pháp toàn diện và "đẹp" nhất tôi cần chuyển suy nghĩ theo góc nhìn của người dùng: một quy luật lặp theo tháng nếu áp dụng vào tháng của ngày cần đánh giá thì sẽ rơi vào ngày nào? Từ hướng này, tôi có thể thấy: nếu số ngày của ngày chuẩn vượt quá số ngày của tháng đó thì ngày nghỉ sẽ rơi vào ngày cuối của tháng. Như vậy nếu ngày cần đánh giá có cùng số ngày với số lớn hơn trong hai số là 1.số ngày của ngày chuẩn và 2.số ngày của tháng chứa ngày cần đánh giá, thì ngày đó sẽ là ngày nghỉ.
WHEN 'm' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < ..., DAY([Ngày chuẩn]), ...), 1, 0)
Vấn đề còn lại là tìm ra số ngày của tháng chứa ngày cần đánh giá. Cách đơn giản nhất là lấy số ngày của ngày cuối cùng của tháng đó. Điều này mỗi ngôn ngữ SQL có sự khác nhau, đối với T-SQL thì đó sẽ là DAY(EOMONTH([Ngày])). Sau khi đã sửa được phương pháp đánh giá cho kiểu lặp theo tháng, tôi sẽ áp dụng tương tự cho kiểu lặp theo năm và đi đến được câu truy vấn như sau.
SELECT [Ngày], MAX(CASE [Kiểu lặp] 
  WHEN 'n' THEN IIF([Ngày] = [Ngày chuẩn], 1, 0)
  WHEN 'w' THEN IIF(DATEPART(weekday, [Ngày]) = DATEPART(weekday, [Ngày chuẩn]), 1, 0)
  WHEN 'm' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])),
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))), 1, 0)
  WHEN 'y' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])), 
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))) AND MONTH([Ngày]) = MONTH([Ngày chuẩn]), 1, 0)
  ELSE 0 END) AS [Nghỉ]
FROM [Bảng ngày], [Bảng quy luật] GROUP BY [Ngày]

Ngày nghỉ trong quá khứ

Đây là một vấn đề hơi khó nhận ra nếu ko có nhu cầu người dùng thực tế. Giải pháp nảy ra sớm nhất trong đầu tôi là lưu một bảng lịch quá khứ. Hàng ngày tôi sẽ lưu ngày hôm đó vào cột ngày, và thông tin có phải ngày nghỉ hay ko vào một cột nữa của bảng đó. Tác vụ này có thể được tự động hóa bằng SQL Agent hoặc Task Scheduler. Khi lập bảng lịch, tôi sẽ chỉ thực hiện tính toán tự động dựa trên quy luật nghỉ đối với các ngày từ thời điểm hiện tại trở đi, sau đó kết hợp với bảng lịch quá khứ bằng cú pháp UNION.
SELECT [Ngày], MAX(...) AS [Nghỉ] FROM [Bảng ngày], [Bảng quy luật] WHERE [Ngày] >= GETDATE() GROUP BY [Ngày]
UNION
SELECT [Ngày], [Nghỉ] FROM [Bảng lịch quá khứ]
Hoặc tôi cũng có thể chỉ lưu lại các ngày nghỉ trong quá khứ thay vì toàn bộ tất cả các ngày, sau đó trong câu truy vấn tôi sẽ tham vấn đến bảng này nếu ngày cần đánh giá nằm trước thời điểm hiện tại.
SELECT [Bảng ngày].[Ngày], IIF([Ngày] >= GETDATE(), MAX(...), [Bảng lịch quá khứ].[Nghỉ]) AS [Nghỉ]
FROM [Bảng ngày] LEFT OUTER JOIN [Bảng lịch quá khứ] ON [Bảng ngày].[Ngày] = [Bảng lịch quá khứ].[Ngày], [Bảng quy luật]
GROUP BY [Ngày]
Hai giải pháp trên đến từ suy nghĩ rằng vấn đề này là do bản thân việc tính toán tự động khiến cho dữ liệu quá khứ đáng lẽ phải được cố định thì lại bị thay đổi. Tuy nhiên như giải pháp sơ cấp và trung cấp ở phần 1, hai giải pháp trên đều phụ thuộc nhiều vào dữ liệu thực dẫn đến độ tin cậy kém. Đặc biệt giải pháp thứ nhất có thể dẫn đến xuất hiện hai bản ghi của cùng một ngày nếu một bản ghi chứa một ngày sau hiện tại vì một lý do nào đó lọt vào bảng lịch quá khứ.
Để khắc phục điều đó, tôi chuyển suy nghĩ sang việc lưu lại dữ liệu về quy luật nghỉ trong quá khứ thay vì ngày nghỉ. Tôi có thể tạo một bảng quy luật nghỉ quá khứ rồi tham vấn bảng này đối với các ngày trước thời điểm hiện tại. Tuy nhiên việc lưu quy luật thay vì ngày dẫn đến một vấn đề mới: làm sao tôi có thể biết được một quy luật trong quá khứ bắt đầu được áp dụng từ khi nào và hết hiệu lực từ khi nào?
Vậy là tôi cần thêm một cột ngày bắt đầu và một cột ngày kết thúc vào bảng quy luật quá khứ để biết quy luật nào có thể dùng được tại một thời điểm nhất định trong quá khứ. Đến đây tôi lại nhận ra rằng quy luật hiện hành đơn giản chỉ là các quy luật có ngày bắt đầu ở trong quá khứ và ngày kết thúc ở một thời điểm trong tương lai. Như vậy tôi có thể lưu cả các quy luật quá khứ trong bảng quy luật hiện hành bằng việc thêm ngày bắt đầu và kết thúc như sau:
Ngày chuẩnKiểu lặpGiải thíchNgày bắt đầuNgày kết thúc
2014/1/4wThứ Bảy2014/1/12014/12/31
2015/1/7wThứ Tư (từ 2015)2015/1/12100/1/1
Và khi đánh giá ngày và quy luật nghỉ, tôi sẽ chỉ xét đến các quy luật có ngày bắt đầu đứng trước và ngày kết thúc đứng sau ngày cần đánh giá như sau:
MAX(IIF([Ngày] NOT BETWEEN [Ngày bắt đầu] AND [Ngày kết thúc], 0, CASE [Kiểu lặp] ... END)) AS [Nghỉ]
Chú ý ở đây tôi ko đưa điều kiện vào cú pháp WHERE vì như vậy sẽ loại mất các ngày ko nằm giữa ngày bắt đầu và kết thúc của bất cứ một quy luật nào. Như vậy tôi sẽ được câu truy vấn như sau:
SELECT [Ngày], MAX(IIF([Ngày] NOT BETWEEN [Ngày bắt đầu] AND [Ngày kết thúc], 0, CASE [Kiểu lặp] 
  WHEN 'n' THEN IIF([Ngày] = [Ngày chuẩn], 1, 0)
  WHEN 'w' THEN IIF(DATEPART(weekday, [Ngày]) = DATEPART(weekday, [Ngày chuẩn]), 1, 0)
  WHEN 'm' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])),
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))), 1, 0)
  WHEN 'y' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])),
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))) AND MONTH([Ngày]) = MONTH([Ngày chuẩn]), 1, 0)
  ELSE 0 END)) AS [Nghỉ]
FROM [Bảng ngày], [Bảng quy luật] GROUP BY [Ngày]

Thiết lập ngoại lệ

Vấn đề này cũng là một vấn đề khá thú vị. Nhận thấy yêu cầu của người dùng ko phải là "thiết lập ngoại lệ cho một quy luật nghỉ" mà là "thiết lập một ngày làm việc theo quy luật lặp nhất định", tôi có thể thêm quy luật ngày đi làm vào bảng quy luật, và khi có một ngày ứng với cả quy luật nghỉ lẫn quy luật đi làm thì sẽ ưu tiên quy luật đi làm. Bảng quy luật nghỉ của tôi như vậy sẽ phải thêm cột thể hiện quy luật đó là quy luật nghỉ hay đi làm như sau:
Ngày chuẩnKiểu lặpNghỉGiải thíchNgày bắt đầuNgày kết thúc
2015/1/31m1Nghỉ cuối tháng1900/1/12100/1/1
2015/12/31y0Mai nghỉ rồi còn gì1900/1/12100/1/1
Về phía câu truy vấn, nếu như trước đây kết quả đánh giá mà tôi cần tổng hợp từ một cặp ngày và quy luật chỉ gồm hai ứng viên là 1.ngày ko ứng với quy luật và 2.ngày ứng với quy luật, thì bây giờ tôi có ba ứng viên là 1.ngày ko ứng với quy luật, 2.ngày ứng với quy luật và quy luật là nghỉ và 3.ngày ứng với quy luật và quy luật là đi làm, trong đó ứng viên 3. luôn được ưu tiên. Do vốn sẵn tôi đã dùng hàm MAX với khả năng 1. và 2. lần lượt mang giá trị 0 và 1, nên tôi có thể cho ứng viên 3 mang giá trị 2 như sau:
WHEN 'n' THEN IIF([Ngày] = [Ngày chuẩn], 2 - [Nghỉ], 0)
Và khi tổng hợp lại tôi cần quyết định ngày đó có phải ngày nghỉ hay ko theo bảng sau:
MAX(...)Nghỉ
00
11
20
Nên mặc dù chẳng liên quan gì nhưng tôi có thể chuyển đổi giá trị trả về của hàm MAX thành giá trị cuối cùng như sau:
SELECT [Ngày], MAX(...) % 2 AS [Nghỉ] FROM ...
Và tôi sẽ được câu truy vấn hoàn chỉnh như sau:
SELECT [Ngày], MAX(IIF([Ngày] NOT BETWEEN [Ngày bắt đầu] AND [Ngày kết thúc], 0, CASE [Kiểu lặp] 
  WHEN 'n' THEN IIF([Ngày] = [Ngày chuẩn], 2 - [Nghỉ], 0)
  WHEN 'w' THEN IIF(DATEPART(weekday, [Ngày]) = DATEPART(weekday, [Ngày chuẩn]), 2 - [Nghỉ], 0)
  WHEN 'm' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])), 
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))), 2 - [Nghỉ], 0)
  WHEN 'y' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])), 
    DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))) AND
  MONTH([Ngày]) = MONTH([Ngày chuẩn]), 2 - [Nghỉ], 0)
ELSE 0 END)) % 2 AS [Nghỉ]
FROM [Bảng ngày], [Bảng quy luật] GROUP BY [Ngày]
Đến đây tôi đã có thể ngồi lại và tận hưởng hương vị chiến thắng, cho đến khi người dùng đưa ra thêm yêu cầu như sau: thiết lập các ngày cuối tháng làm ngày nghỉ trừ ngày 12/31, nhưng mà nếu 12/31 rơi vào ngày nghỉ hàng tuần thì cho nghỉ nốt. Giải pháp tôi đưa ra ở trên như vậy là đổ bể hoàn toàn, do tôi đã dựa trên quá nhiều giả định có thể dễ dàng bị phá bỏ bới yêu cầu người dùng.
Để ý thấy rằng trong yêu cầu nói trên có sự xuất hiện của 3 quy luật lặp khác nhau là 1.tất cả các ngày cuối tháng, 2.ngày 12/31 hàng năm và 3.ngày nghỉ theo tuần, tôi nhận thấy cần phải tìm cách so sánh mức độ ưu tiên của nhiều hơn hai quy luật, và để làm được điều đó thì tôi cần thêm một cột thể hiện độ ưu tiên vào bảng quy luật như sau
Ngày chuẩnKiểu lặpNghỉGiải thíchĐộ ưu tiênNgày bắt đầuNgày kết thúc
2015/1/31m1Nghỉ cuối tháng101900/1/12100/1/1
2015/12/31y0Mai nghỉ rồi còn gì201900/1/12100/1/1
2015/4/25w1Thứ Bảy501900/1/12100/1/1
2015/4/26w1Chủ Nhật501900/1/12100/1/1
Trong đó quy luật có giá trị độ ưu tiên càng lớn thì càng được ưu tiên cao hơn khi tổng hợp kết quả đánh giá các quy luật cho mỗi ngày. Vấn đề còn lại là làm thế nào để tổng hợp kết quả?
SELECT [Ngày], ISNULL((
  SELECT TOP 1 [Nghỉ] FROM (
    SELECT IIF([Ngày] NOT BETWEEN [Ngày bắt đầu] AND [Ngày kết thúc], 0, CASE [Kiểu lặp] 
      WHEN 'n' THEN IIF([Ngày] = [Ngày chuẩn], 1, 0) 
      WHEN 'w' THEN IIF(DATEPART(weekday, [Ngày]) = DATEPART(weekday, [Ngày chuẩn]), 1, 0) 
      WHEN 'm' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])), 
        DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))), 1, 0) 
      WHEN 'y' THEN IIF(DAY([Ngày]) = IIF(DAY([Ngày chuẩn]) < DAY(EOMONTH([Ngày])),
        DAY([Ngày chuẩn]), DAY(EOMONTH([Ngày]))) AND MONTH([Ngày]) = MONTH([Ngày chuẩn]), 1, 0)
      ELSE 0 END) AS [Kết quả đánh giá], [Nghỉ], [Độ ưu tiên]
    FROM [Bảng quy luật]
  ) AS [Bảng đánh giá quy luật]
  WHERE [Kết quả đánh giá] = 1 ORDER BY [Độ ưu tiên] DESC
), 0) AS [Nghỉ]
FROM [Bảng ngày]
Để đi đến được câu truy vấn khủng long trên, tôi cần phải hình dung lại lần nữa cách tôi sẽ giải quyết vấn đề nếu sử dụng ngôn ngữ lập trình thông thường. Như đã trình bày ở phần 1, tôi sẽ sử dụng hai vòng for lồng vào nhau, vòng ngoài chạy tất cả các ngày, vòng trong chạy tất cả các quy luật. Điểm khác biệt ở đây là thay vì chỉ cần tìm được một quy luật ứng với ngày cần đánh giá là có thể kết luận đó là ngày nghỉ, thì bây giờ tôi cần phải tìm tất cả các quy luật ứng với ngày cần đánh giá, tìm ra quy luật có độ ưu tiên cao nhất, và lấy giá trị nghỉ của quy luật đó làm kết quả đánh giá cho ngày đó.
Để dễ hình dùng hơn, thay vì suy nghĩ từ ngoài vào thì tôi suy nghĩ từ trong ra. Trước hết tôi cần đánh giá tất cả các quy luật hiện có đối với ngày cần đánh giá, với phần đánh giá lấy lại từ câu truy vấn ở phần trên. Do tôi cần biết độ ưu tiên và giá trị nghỉ của từng quy luật để dùng cho các xử lý về sau nên tôi cũng phải thêm các dữ liệu đó vào câu truy vấn.
SELECT ... AS [Kết quả đánh giá], [Độ ưu tiên], [Nghỉ] FROM [Bảng quy luật]
Tiếp theo tôi cần loại bỏ tất cả các quy luật ko áp dụng được với ngày cần đánh giá. Do tôi ko thể sử dụng một giá trị vừa được tính toán trong phần SELECT để làm điều kiện trong phần WHERE, nên tôi sẽ phải chuyển câu truy vấn trên thành một câu truy vấn con (subquery) như một bảng tạm để dùng trong câu truy vấn loại bỏ như sau:
SELECT ...  FROM (
    SELECT ... AS [Kết quả đánh giá], [Độ ưu tiên], [Nghỉ] FROM [Bảng quy luật]
) AS [Bảng đánh giá quy luật]
WHERE [Kết quả đánh giá] = 1
Sau khi đã loại bỏ các quy luật ko cần thiết, tôi cần tìm ra quy luật có độ ưu tiên cao nhất và lấy ra giá trị nghỉ của nó. Điều này có thể được thực hiện đơn giản bằng cách sắp xếp các bản ghi theo thứ tự giảm dần của độ ưu tiên và lấy bản ghi ở trên cùng bằng cú pháp SELECT TOP 1 (tham khảo). Để ý ở đây tôi cần tìm ra một quy luật, tức một bản ghi, chứ ko phải tìm ra một giá trị là độ ưu tiên cao nhất, nên các hàm aggregate đều ko sử dụng được.
SELECT TOP 1 [Nghỉ] FROM (
  SELECT ... AS [Kết quả đánh giá], [Độ ưu tiên], [Nghỉ] FROM [Bảng quy luật]
) AS [Bảng đánh giá quy luật]
WHERE [Kết quả đánh giá] = 1 ORDER BY [Độ ưu tiên] DESC
Việc còn lại chỉ là thực hiện câu truy vấn trên đối với tất cả các ngày trong bảng ngày. Do câu truy vấn trên chỉ trả về đúng một giá trị nên tôi có thể chuyển nó thành một câu truy vấn con như một giá trị trả về trong phần SELECT như sau:
SELECT [Ngày], (
  SELECT TOP 1 [Nghỉ] FROM (
      SELECT ... AS [Kết quả đánh giá], [Độ ưu tiên], [Nghỉ] FROM [Bảng quy luật]
  ) AS [Bảng đánh giá quy luật]
  WHERE [Kết quả đánh giá] = 1 ORDER BY [Độ ưu tiên] DESC
) AS [Nghỉ] FROM [Bảng ngày]
Trên lý thuyết thì câu truy vấn đề đây đã hoàn thành, nhưng khi chạy thử thì tôi phát hiện ra còn một cái bẫy cuối cùng. Đó là đối với những ngày ko ứng với một quy luật nào thì SELECT TOP 1 sẽ ko trả về bản ghi nào và do vậy giá trị nghỉ ở câu truy vấn ngoài cùng sẽ trở thành NULL. Do nếu ko có quy luật nào áp dung được thì ngày cần đánh giá tự động trở thành ngày làm việc, nên tôi có thể khắc phục vấn đề này bằng hàm ISNULL (tham khảo).
SELECT [Ngày], ISNULL((
  SELECT TOP 1 [Nghỉ] FROM (
      SELECT ... AS [Kết quả đánh giá], [Độ ưu tiên], [Nghỉ] FROM [Bảng quy luật]
  ) AS [Bảng đánh giá quy luật]
  WHERE [Kết quả đánh giá] = 1 ORDER BY [Độ ưu tiên] DESC
), 0) AS [Nghỉ] FROM [Bảng ngày]
Cuối cùng tôi sẽ được câu truy vấn hoàn chỉnh như đã đưa ra ở trên.

Kết

Đến đây thì có thể nói là bảng ngày tháng nhỏ nhắn ban đầu của tôi đã tiến hóa lên thành một ứng dụng lịch làm việc tương đối mạnh (so với lượng công sức bỏ ra). Từ nền tảng này tôi có thể thêm thắt nhiều chức năng khác như tạo lịch cho riêng từng bộ phận, thậm chí từng người trong công ty, hay quản lý thêm sự kiện và công việc bên cạnh ngày nghỉ tùy vào nhu cầu của người dùng.
Những chức năng trên, hay bản thân chức năng tạo lịch từ quy luật, đều có thể được thực hiện dễ dàng (có thể là dễ hơn) bởi một ứng dụng lập trình bằng ngôn ngữ khác ngoài SQL. Cũng có thể có người sẽ cho rằng những chức năng này thuộc về logic nghiệp vụ và ko nên để trong tầng dữ liệu. Tuy nhiên ở đây tôi muốn thể hiện rằng một phần ko nhỏ những việc ngôn ngữ lập trình khác làm được thì SQL cũng làm được với một nét đẹp của riêng SQL. Đó là chưa kể đến lý do mang tính thực dụng là một khung nhìn SQL sẽ có thể được ứng dụng rộng rãi và đơn giản hơn rất nhiều so với một thư viện hay một dịch vụ tương đương.

Tất cả vấn đề trong bài viết này đều do tôi tự nghĩ ra, nhưng là dựa trên những nhu cầu rất thật trong thực tiễn công việc. Các giải pháp là do tôi tự nghĩ ra dựa trên kinh nghiệm cá nhân kết hợp với các thủ thuật cóp nhặt từ trên internet. Hy vọng bài viết sẽ có ích cho mọi người và chúc mọi người thành công.

Nguồn: Hawkie

No comments:

Post a Comment