wtorek, 29 grudnia 2015

• Bug funkcji WOY (MyDate As Date) As Integer ' Week Of Year

W poprzednim poście Data tygodniowa ISO 8601 poruszyłem temat „Funkcja konwertująca datę do formatu daty tygodniowej, zgodnie z normą ISO 8601”. Napisana tam funkcja funDataTygodniowaISO8601 (...) nie działała prawidłowo. Użyte funkcje Format (...) i DatePart (...) nie zawsze zwracały prawidłowy wynik.
Na stronie http://support.microsoft.com/pl-pl/kb/200299  znalazłem artykuł, który opisuje błąd biblioteki Oleaut32.dll:
Przy ustalaniu numeru tygodnia zgodnie z normą ISO 8601, za pomocą funkcji Format (...) lub DatePart (...) z biblioteki Oleaut32.dll w postaci:

'• AnyDate - argument typu Variant (Date), która ma zostać wyliczony
'• "WW" - (tydzień) przedział czasu, który ma zostać zwrócony
'• vbMonday - tydzień zaczyna się od Poniedziałku,
'• vbFirstFourDays - pierwszy tydzień roku musi zawierać Czwartek
 
Format (AnyDate "WW", vbMonday, vbFirstFourDays)
DatePart ("WW", AnyDate, vbMonday, vbFirstFourDays)
 

funkcja Format (...) oraz funkcja DatePart (...), dla ostatniego poniedziałku w niektórych latach, zwracają nieprawidłowy numer tygodnia (53 tydzień), zamiast 1-szy tydzień.

Przykładowo:
29-12-2003;   poniedziałek;  tydzień: 53;  zamiast;  1
31-12-2007;   poniedziałek;  tydzień: 53;  zamiast;  1
30-12-2019;   poniedziałek;  tydzień: 53;  zamiast;  1
29-12-2031;   poniedziałek;  tydzień: 53;  zamiast;  1
31-12-2035;   poniedziałek;  tydzień: 53;  zamiast;  1
30-12-2047;   poniedziałek;  tydzień: 53;  zamiast;  1

W Visual Basic for Applications, wszystkie funkcje daty, z wyjątkiem funkcji DateSerial (...), pochodzą z biblioteki Oleaut32.dll. Ponieważ zarówno funkcja Format (...) jak i funkcja DatePart (...) mogą zwrócić liczbę tygodni kalendarzowych dla danej daty, obie są dotknięte tym błędem. Aby uniknąć tego problemu:

Microsoft proponuje następujące obejście problemu:

Jeżeli funkcja Format (...) lub DatePart (...) zwróci numer tygodnia równy 53, należy uruchomić instrukcję sprawdzającą numer następnego tygodnia dla argumentu MyDate + 7. Jeżeli zwracany następny tydzień będzie 2-gim tygodniem roku, to zwracana wartość przez funkcję WOY musi być równa 1.

'• MyDate - argument typu Variant (Date), która ma zostać wyliczony
'• "ww" - (tydzień) przedział czasu, który ma zostać zwrócony
'• vbMonday - tydzień zaczyna się od Poniedziałku,
'• vbFirstFourDays - pierwszy tydzień roku musi zawierać Czwartek
 
 Function WOY(MyDate As Date) As Integer    ' Week Of Year
  WOY = Format(MyDate, "ww", vbMonday, vbFirstFourDays)
  If WOY > 52 Then
    If Format(MyDate + 7, "ww", vbMonday, vbFirstFourDays) = 2 Then WOY = 1
  End If
End Function
 

I wszystko by było dobrze, ale funkcja WOY (MyDate As Date) dla poniższych dat zwraca numer tygodnia 53, a powinna zwrócić numer 52.

WOY(CDate("02-01-2101")) = 53
WOY(CDate("02-01-2501")) = 53
WOY(CDate("02-01-2901")) = 53
WOY(CDate("02-01-3301")) = 53
WOY(CDate("02-01-3701")) = 53
WOY(CDate("02-01-4101")) = 53
WOY(CDate("02-01-4501")) = 53

Innymi słowy, Microsoft powinien zrobić poprawkę do poprawki. Mniej więcej, coś w tym stylu:

'• MyDate - argument typu Variant (Date), która ma zostać wyliczony
'• "ww" - (tydzień) przedział czasu, który ma zostać zwrócony
'• vbMonday - tydzień zaczyna się od Poniedziałku,
'• vbFirstFourDays - pierwszy tydzień roku musi zawierać Czwartek
 
Function WOYC(MyDate As Date) As Integer    ' Week Of Year Corrected

   WOYC = Format(MyDate, "ww", vbMonday, vbFirstFourDays)
   If WOYC > 52 Then
      If Format(MyDate + 7, "ww", vbMonday, vbFirstFourDays) = 2 Then
         WOYC = 1
      ElseIf Format(MyDate + 7, "ww", vbMonday, vbFirstFourDays) = 1 And _
            WeekDay(MyDate, vbMonday) = 7 And _
            Day(MyDate) = 2 And _
            Format(MyDate - 7, "ww", vbMonday, vbFirstFourDays) = 51 Then
         WOYC = 52
      End If
   End If
End Function
 

Nie ma tego złego, co by na dobre nie wyszło. Pod sam koniec artykułu Microsoft zamieścił funkcję WeekNumber (InDate As Date), która prawidłowo wylicza numery dni tygodni i nie korzysta z niedopracowanej (zwracającej błędne dane) funkcji WOY(MyDate As Date).
Poniżej przedstawiam listing funkcji WeekNumber (...) ze strony http://support.microsoft.com/pl-pl/kb/200299

 
Function WeekNumber(InDate As Date) As Integer
  Dim DayNo As Integer
  Dim StartDays As Integer
  Dim StopDays As Integer
  Dim StartDay As Integer
  Dim StopDay As Integer
  Dim VNumber As Integer
  Dim ThurFlag As Boolean

  DayNo = Days(InDate)
  StartDay = WeekDay(DateSerial(Year(InDate), 1, 1)) - 1
  StopDay = WeekDay(DateSerial(Year(InDate), 12, 31)) - 1
  ' Number of days belonging to first calendar week
  StartDays = 7 - (StartDay - 1)
  ' Number of days belonging to last calendar week
  StopDays = 7 - (StopDay - 1)
  ' Test to see if the year will have 53 weeks or not
  If StartDay = 4 Or StopDay = 4 Then ThurFlag = True Else ThurFlag = False
  VNumber = (DayNo - StartDays - 4) / 7
  ' If first week has 4 or more days, it will be calendar week 1
  ' If first week has less than 4 days, it will belong to last year's
  ' last calendar week
  If StartDays >= 4 Then
     WeekNumber = Fix(VNumber) + 2
  Else
     WeekNumber = Fix(VNumber) + 1
  End If
  ' Handle years whose last days will belong to coming year's first
  ' calendar week
  If WeekNumber > 52 And ThurFlag = False Then WeekNumber = 1
  ' Handle years whose first days will belong to the last year's
  ' last calendar week
  If WeekNumber = 0 Then
     WeekNumber = WeekNumber(DateSerial(Year(InDate) - 1, 12, 31))
  End If
End Function

Function Days(DayNo As Date) As Integer
  Days = DayNo - DateSerial(Year(DayNo), 1, 0)
End Function
 

Przy wykorzystaniu, do wyliczenia numeru tygodnia  funkcji WeekNumber (InDate As Date) ze strony Microsoftu, funkcja WeekDateISO (...) konwertująca datę do formatu daty tygodniowej, zgodnie z normą ISO 8601, będzie miała postać:

' Pobiera:
'  • datDate - data do przekonwertowania na format ISO
'  • strDelimDate - separator daty, domyślnie [-] myślnik
'  • strPrefixWeek - prefix przed dwucyfrowym numerem tygodnia, domyślnie [W]
' Zwraca:
'  Przy powodzeniu zwraca sformatowaną datę tygodniową (ISO-8601 )
'  Poszczególne elementy daty rozdzielone opcjonalnym separatorem daty
'  przekazanym w argumencie strDelimDate, dwucyfrowy numer tygodnia poprzedzony
'  jest opcjonalnym prefiksem przekazanym w argumentcie strPrefixWeek.
'  Przy niepowodzeniu zwraca ciąg zerowej długości.
 
Public Function WeekDateISO(datDate As Date, _
                  Optional strDelimDate As String = "-", _
                  Optional strPrefixWeek As String = "W") As String

Dim intYear As Integer
Dim intWeek As Integer
Dim intDay As Integer

   ' pobierz rok
   intYear = Year(datDate)
   
   ' pobierz numer tygodnia, korzystając z funkcji WeekNumber Microsoftu:
   intWeek = WeekNumber(datDate)

   If Month(datDate) = 12 And intWeek = 1 Then
      intYear = intYear + 1
   ElseIf (Month(datDate) = 1 And (intWeek = 52 Or intWeek = 53)) Then
      intYear = intYear - 1
   End If

   ' pobierz numer dnia w tygodniu (licząc od poniedziałku)
   intDay = WeekDay(datDate, vbMonday)
   
   WeekDateISO = CStr(intYear) & strDelimDate & _
               strPrefixWeek & Format$(intWeek, "00") & _
               strDelimDate & CStr(intDay)
  
End Function

' poniżej przykład jak wywołać funkcję: WeekDateISO(...)
Private Sub cmdTest_Click()
Dim strDataISO As String
Dim dtData As Date

   dtData = CDate("3 stycznia 2010")
   'prawidłowo powinniśmy przekazać datę po "amerykańsku" #1/3/2010#
   strDataISO = WeekDateISO(dtData, "-")
   MsgBox "3 stycznia 2010" & " => " & strDataISO


   dtData = CDate("31 grudnia 2007")
   strDataISO = WeekDateISO(dtData, "-")
   MsgBox "31 grudnia 2007" & " => " & strDataISO

End Sub