Pull to refresh

Производственный календарь своими руками в Firebird

Reading time9 min
Views4.8K
Здравствуйте, меня зовут Денис, я явлюсь разработчиком информационных систем, пишу статьи и документацию по СУБД Firebird. В этой статье я хочу рассказать о реализации производственного календаря с использованием СУБД Firebird.

На написание этой статьи меня натолкнули похожие статьи на хабре: производственный календарь с использование PostgreSQL и MS SQL. Я решил использовать смешанный подход. С одной стороны, хранить только исключения для дат, и генерировать календарь «на лету», с другой такой календарь можно сохранять в постоянную таблицу и осуществлять быстрый поиск по дате или другим атрибутам.

Для разработки будем использовать Firebird 3.0, в нём был значительно расширены возможности PSQL по сравнению с предыдущими версиями. Все процедуры и функции для работы с календарём будут инкапсулированы в пакете DATE_UTILS.

Первым делом создадим таблицу для хранения стандартных праздничных дат.

CREATE TABLE HOLIDAYS (
    ID      INTEGER GENERATED BY DEFAULT AS IDENTITY,
    AMONTH  SMALLINT NOT NULL,
    ADAY    SMALLINT NOT NULL,
    REMARK  VARCHAR(255) NOT NULL,
    CONSTRAINT PK_HOLIDAYS PRIMARY KEY (ID),
    CONSTRAINT UNQ_HOLIDAYS UNIQUE (AMONTH, ADAY
);

INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (1, 1, 1, 'Новый год');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (2, 1, 7, 'Рождество');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (3, 2, 23, 'День защитника отечества');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (4, 3, 8, 'Международный женский день');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (5, 5, 1, 'Праздник весны и труда');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (6, 5, 9, 'День победы');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (7, 6, 12, 'День России');
INSERT INTO HOLIDAYS (ID, AMONTH, ADAY, REMARK)
              VALUES (8, 11, 4, 'День народного единства');

COMMIT;

Такая таблица поможет нам автоматизировать процесс заполнения календаря, чтобы не добавлять праздники каждый раз в качестве выходных.

Теперь создадим таблицу для хранения исключений. В ней будут храниться как будние дни, которые стали выходными, так и наоборот выходные, которые назначены рабочими днями.

Кроме того, для любой даты можно оставить произвольную заметку.

CREATE TABLE CALENDAR_NOTES (
    BYDATE    DATE NOT NULL,
    DAY_TYPE  SMALLINT NOT NULL,
    REMARK    VARCHAR(255),
    CONSTRAINT PK_CALENDAR_NOTES PRIMARY KEY (BYDATE)
);

Поле DAY_TYPE указывает тип даты: 0 – рабочий день. 1 – выходной, 2 – праздник.

Для работы с таблицей исключений создадим 2 хранимые процедуры и разместим их внутри пакета DATE_UTILS.

  
  -- Устанавливает пометку для даты и её тип
  PROCEDURE SET_DATE_NOTE (
      ADATE     DATE,
      ADAY_TYPE SMALLINT,
      AREMARK   VARCHAR(255))
  AS
  BEGIN
    UPDATE OR INSERT INTO CALENDAR_NOTES (BYDATE, DAY_TYPE, REMARK)
    VALUES (:ADATE, :ADAY_TYPE, :AREMARK);
  END

  -- снимает пометку у даты
  PROCEDURE UNSET_DATE_NOTE (
      ADATE DATE)
  AS
  BEGIN
    DELETE FROM CALENDAR_NOTES
    WHERE BYDATE = :ADATE;
  END

В Firebird в отличие от PostgreSQL отсутствует специальная функция для генерации серий значений. Такую генерацию можно сделать с помощью рекурсивного CTE, но в этом случае мы будем ограничены глубиной рекурсии. Мы поступим несколько проще, напишем специальную селективную хранимую процедуру для генерации последовательности дат и разместим её внутри пакета DATE_UTILS.

  
  -- генерация последовательности дат
  -- с интервалом 1 день
  PROCEDURE GENERATE_SERIES (
      MIN_DATE DATE,
      MAX_DATE DATE)
  RETURNS (
      BYDATE DATE)
  AS
  BEGIN
    IF (MIN_DATE > MAX_DATE) THEN
      EXCEPTION E_MIN_DATE_EXCEEDS;
    BYDATE = MIN_DATE;
    WHILE (BYDATE <= MAX_DATE) DO
    BEGIN
      SUSPEND;
      BYDATE = BYDATE + 1;
    END
  END

В процедуре предусмотрена защита от зацикливания, если минимальная дата будет больше максимальной, то будет брошено исключение E_MIN_DATE_EXCEEDS, которое определено следующим образом:

CREATE EXCEPTION E_MIN_DATE_EXCEEDS 'Минимальная дата превышает максимальную';

Теперь собственно перейдём к генерации календаря «на лету». Если дата содержится в таблице исключений, то будет выведен тип даты и примечание из таблицы исключений. Если даты нет в таблице исключений, но она присутствует в таблице с праздничными датами, то выводим примечание из таблицы праздников. Выходные определяем по номеру дня недели, остальные даты являются рабочими. Описанный алгоритм реализовывается следующим запросом

SELECT
    D.BYDATE,
    CASE
        WHEN NOTES.DAY_TYPE IS NOT NULL THEN NOTES.DAY_TYPE
        WHEN HOLIDAYS.ID IS NOT NULL THEN 2
        WHEN EXTRACT(WEEKDAY FROM D.BYDATE) IN (0, 6) THEN 1
        ELSE 0
    END AS DATE_TYPE,
    COALESCE(NOTES.REMARK, HOLIDAYS.REMARK) AS REMARK
FROM DATE_UTILS.GENERATE_SERIES(:MIN_DATE, :MAX_DATE) D
    LEFT JOIN HOLIDAYS 
      ON HOLIDAYS.AMONTH = EXTRACT(MONTH FROM D.BYDATE) AND
         HOLIDAYS.ADAY = EXTRACT(DAY FROM D.BYDATE)
    LEFT JOIN CALENDAR_NOTES NOTES 
      ON NOTES.BYDATE = D.BYDATE

Сохраним данный запрос в селективную хранимую процедуру и добавим вывод некоторых дополнительных столбцов


-- возвращает календарь
PROCEDURE GET_CALENDAR (
    MIN_DATE DATE,
    MAX_DATE DATE)
RETURNS (
    BYDATE     DATE,
    YEAR_OF    SMALLINT,
    MONTH_OF   SMALLINT,
    DAY_OF     SMALLINT,
    WEEKDAY_OF SMALLINT,
    DATE_TYPE  SMALLINT,
    REMARK     VARCHAR(255))
AS
BEGIN
    FOR
      SELECT
          D.BYDATE,
          EXTRACT(YEAR FROM d.BYDATE) AS YEAR_OF,
          EXTRACT(MONTH FROM d.BYDATE) AS MONTH_OF,
          EXTRACT(DAY FROM d.BYDATE) AS DAY_OF,
          EXTRACT(WEEKDAY FROM d.BYDATE) AS WEEKDAY_OF,
          CASE
            WHEN NOTES.DAY_TYPE IS NOT NULL THEN NOTES.DAY_TYPE
            WHEN HOLIDAYS.ID IS NOT NULL THEN 2
            WHEN EXTRACT(WEEKDAY FROM D.BYDATE) IN (0, 6) THEN 1
            ELSE 0
          END AS DATE_TYPE,
          COALESCE(NOTES.REMARK, HOLIDAYS.REMARK) AS REMARK
      FROM DATE_UTILS.GENERATE_SERIES(:MIN_DATE, :MAX_DATE) D
          LEFT JOIN HOLIDAYS
            ON HOLIDAYS.AMONTH = EXTRACT(MONTH FROM D.BYDATE) AND
               HOLIDAYS.ADAY = EXTRACT(DAY FROM D.BYDATE)
          LEFT JOIN CALENDAR_NOTES NOTES
            ON NOTES.BYDATE = D.BYDATE
      INTO BYDATE,
           YEAR_OF,
           MONTH_OF,
           DAY_OF,
           WEEKDAY_OF,
           DATE_TYPE,
           REMARK
    DO
      SUSPEND;
END

Добавим несколько функций для вывода дней недели, названий месяцев и типа даты на русском языке.


-- возвращает короткое имя дня недели
  FUNCTION GET_WEEKDAY_NAME(AWEEKDAY SMALLINT) RETURNS CHAR(2)
  AS
  BEGIN
    RETURN CASE AWEEKDAY
      WHEN 1 THEN 'пн'
      WHEN 2 THEN 'вт'
      WHEN 3 THEN 'ср'
      WHEN 4 THEN 'чт'
      WHEN 5 THEN 'пт'
      WHEN 6 THEN 'сб'
      WHEN 0 THEN 'вс'
    END;
  END

  -- возвращает имя месяца
  FUNCTION GET_MONTH_NAME(AMONTH SMALLINT) RETURNS VARCHAR(10)
  AS
  BEGIN
    RETURN CASE AMONTH
      WHEN 1 THEN 'январь'
      WHEN 2 THEN 'февраль'
      WHEN 3 THEN 'март'
      WHEN 4 THEN 'апрель'
      WHEN 5 THEN 'май'
      WHEN 6 THEN 'июнь'
      WHEN 7 THEN 'июль'
      WHEN 8 THEN 'август'
      WHEN 9 THEN 'сентябрь'
      WHEN 10 THEN 'октябрь'
      WHEN 11 THEN 'ноябрь'
      WHEN 12 THEN 'декабрь'
    END;
  END

  -- возвращает наименование типа даты
  FUNCTION GET_DAY_TYPE_NAME(ADAY_TYPE SMALLINT) RETURNS VARCHAR(11)
  AS
  BEGIN
    RETURN CASE ADAY_TYPE
      WHEN 0 THEN 'Рабочий'
      WHEN 1 THEN 'Выходной'
      WHEN 2 THEN 'Праздничный'
    END;
  END

Теперь мы можем вывести календарь используя следующий запрос:

SELECT
    D.BYDATE AS BYDATE,
    D.YEAR_OF,
    DATE_UTILS.GET_MONTH_NAME(D.MONTH_OF) AS MONTH_NAME,
    D.DAY_OF,
    DATE_UTILS.GET_WEEKDAY_NAME(D.WEEKDAY_OF) AS WEEKDAY_NAME,
    DATE_UTILS.GET_DAY_TYPE_NAME(D.DATE_TYPE) AS DATE_TYPE,
    D.REMARK AS REMARK
FROM DATE_UTILS.GET_CALENDAR(DATE '01.05.2019', DATE '31.05.2019') D

BYDATE      YEAR_OF MONTH_NAME  DAY_OF WEEKDAY_NAME DATE_TYPE   REMARK
=========== ======= ========== ======= ============ =========== ======================
2019-05-01     2019 май              1 ср           Праздничный Праздник весны и труда
2019-05-02     2019 май              2 чт           Выходной    Майские праздники
2019-05-03     2019 май              3 пт           Выходной    Майские праздники
2019-05-04     2019 май              4 сб           Выходной    Майские праздники
2019-05-05     2019 май              5 вс           Выходной    Майские праздники
2019-05-06     2019 май              6 пн           Рабочий     <null>
2019-05-07     2019 май              7 вт           Рабочий     <null>
2019-05-08     2019 май              8 ср           Рабочий     <null>
2019-05-09     2019 май              9 чт           Праздничный День победы
2019-05-10     2019 май             10 пт           Выходной    Майские праздники
2019-05-11     2019 май             11 сб           Выходной    <null>
2019-05-12     2019 май             12 вс           Выходной    <null>
2019-05-13     2019 май             13 пн           Рабочий     <null>
2019-05-14     2019 май             14 вт           Рабочий     <null>
2019-05-15     2019 май             15 ср           Рабочий     <null>
2019-05-16     2019 май             16 чт           Рабочий     <null>
2019-05-17     2019 май             17 пт           Рабочий     <null>
2019-05-18     2019 май             18 сб           Выходной    <null>
2019-05-19     2019 май             19 вс           Выходной    <null>
2019-05-20     2019 май             20 пн           Рабочий     <null>


BYDATE      YEAR_OF MONTH_NAME  DAY_OF WEEKDAY_NAME DATE_TYPE   REMARK
=========== ======= ========== ======= ============ =========== ==================
2019-05-21     2019 май             21 вт           Рабочий     <null>
2019-05-22     2019 май             22 ср           Рабочий     <null>
2019-05-23     2019 май             23 чт           Рабочий     <null>
2019-05-24     2019 май             24 пт           Рабочий     <null>
2019-05-25     2019 май             25 сб           Выходной    <null>
2019-05-26     2019 май             26 вс           Выходной    <null>
2019-05-27     2019 май             27 пн           Рабочий     <null>
2019-05-28     2019 май             28 вт           Рабочий     <null>
2019-05-29     2019 май             29 ср           Рабочий     <null>
2019-05-30     2019 май             30 чт           Рабочий     <null>
2019-05-31     2019 май             31 пт           Рабочий     <null>

Если необходимо пометить какую-то дату, как выходной или будний день используем следующий запрос:


EXECUTE PROCEDURE DATE_UTILS.SET_DATE_NOTE(date '05.05.2019', 1, 'Майские праздники');

Чтобы убрать дату из списка исключений необходимо выполнить запрос


EXECUTE PROCEDURE DATE_UTILS.UNSET_DATE_NOTE(date '05.05.2019');

Теперь создадим таблицу для хранения производственного календаря, и напишем процедуру для её заполнения.

CREATE TABLE CALENDAR (
    BYDATE      DATE NOT NULL,
    YEAR_OF     SMALLINT NOT NULL,
    MONTH_OF    SMALLINT NOT NULL,
    DAY_OF      SMALLINT NOT NULL,
    WEEKDAY_OF  SMALLINT NOT NULL,
    DATE_TYPE   SMALLINT NOT NULL,
    REMARK      VARCHAR(255),
    CONSTRAINT PK_CALENDAR PRIMARY KEY (BYDATE)
);

  -- заполнение/обновление таблицы календаря
  PROCEDURE FILL_CALENDAR (
      MIN_DATE DATE,
      MAX_DATE DATE)
  AS
  BEGIN
    MERGE INTO CALENDAR
    USING (
      SELECT
        BYDATE,
        YEAR_OF,
        MONTH_OF,
        DAY_OF,
        WEEKDAY_OF,
        DATE_TYPE,
        REMARK
      FROM DATE_UTILS.GET_CALENDAR(:MIN_DATE, :MAX_DATE)
    ) S
    ON CALENDAR.BYDATE = S.BYDATE
    WHEN NOT MATCHED THEN
    INSERT (
      BYDATE,
      YEAR_OF,
      MONTH_OF,
      DAY_OF,
      WEEKDAY_OF,
      DATE_TYPE,
      REMARK
    )
    VALUES (
      S.BYDATE,
      S.YEAR_OF,
      S.MONTH_OF,
      S.DAY_OF,
      S.WEEKDAY_OF,
      S.DATE_TYPE,
      S.REMARK
    )
    WHEN MATCHED AND
      (CALENDAR.DATE_TYPE <> S.DATE_TYPE OR 
       CALENDAR.REMARK <> S.REMARK) THEN
    UPDATE SET
      DATE_TYPE = S.DATE_TYPE,
      REMARK = S.REMARK;
  END

Процедура заполнения таблицы для хранения календаря спроектирована таким образом, что если в нём уже существует дата, то произойдёт обновление типа даты и заметки, только если изменения произошли в таблице исключений, или дата удалена из списка исключений.

Для того, чтобы изменения в таблице исключений сразу отражались в таблице календаря немного изменим процедуры SET_DATE_NOTE и UNSET_DATE_NOTE. Первое изменение довольно тривиально, мы просто добавляем в процедуру ещё один запрос на обновление примечания и типа даты в таблице CALENDAR.

  -- Устанавливает пометку для даты и её тип
  PROCEDURE SET_DATE_NOTE (
      ADATE     DATE,
      ADAY_TYPE SMALLINT,
      AREMARK   VARCHAR(255))
  AS
  BEGIN
    UPDATE OR INSERT INTO CALENDAR_NOTES (BYDATE, DAY_TYPE, REMARK)
    VALUES (:ADATE, :ADAY_TYPE, :AREMARK);

    -- если дата уже есть в календаре обновляем её
    UPDATE CALENDAR
    SET DATE_TYPE = :ADAY_TYPE,
        REMARK = :AREMARK
    WHERE BYDATE = :ADATE
      AND (DATE_TYPE <> :ADAY_TYPE OR REMARK <> :AREMARK);
  END

Удаление пометки для даты несколько сложнее, поскольку мы должны вернуть замечание, которое было у даты до того, как она была внесена в список исключений. Для этого, мы используем туже логику определения типа даты и замечания, что уже использовалась в процедуре GET_CALENDAR.


  -- снимает пометку у даты
  PROCEDURE UNSET_DATE_NOTE (
      ADATE DATE)
  AS
  BEGIN
    DELETE FROM CALENDAR_NOTES
    WHERE BYDATE = :ADATE;

    -- надо вернуть тип даты и примечание по умолчанию
    MERGE INTO CALENDAR
    USING (
      SELECT
          :ADATE AS BYDATE,
          CASE
            WHEN HOLIDAYS.ID IS NOT NULL THEN 2
            WHEN EXTRACT(WEEKDAY FROM :ADATE) IN (0, 6) THEN 1
            ELSE 0
          END AS DATE_TYPE,
          HOLIDAYS.REMARK AS REMARK
      FROM RDB$DATABASE
      LEFT JOIN HOLIDAYS ON
        HOLIDAYS.AMONTH = EXTRACT(MONTH FROM :ADATE) AND
        HOLIDAYS.ADAY = EXTRACT(DAY FROM :ADATE)
    ) S
    ON CALENDAR.BYDATE = S.BYDATE
    WHEN MATCHED THEN
    UPDATE SET
      DATE_TYPE = S.DATE_TYPE,
      REMARK = S.REMARK;
  END

Вывести календарь из таблицы можно с использованием следующего запрос:

SELECT
    D.BYDATE AS BYDATE,
    D.YEAR_OF,
    DATE_UTILS.GET_MONTH_NAME(D.MONTH_OF) AS MONTH_NAME,
    D.DAY_OF,
    DATE_UTILS.GET_WEEKDAY_NAME(D.WEEKDAY_OF) AS WEEKDAY_NAME,
    DATE_UTILS.GET_DAY_TYPE_NAME(D.DATE_TYPE) AS DATE_TYPE,
    D.REMARK AS REMARK
FROM CALENDAR D
WHERE D.BYDATE BETWEEN DATE '01.05.2019' AND DATE '31.05.2019'


BYDATE      YEAR_OF MONTH_NAME  DAY_OF WEEKDAY_NAME DATE_TYPE   REMARK
=========== ======= ========== ======= ============ =========== ======================
2019-05-01     2019 май              1 ср           Праздничный Праздник весны и труда
2019-05-02     2019 май              2 чт           Выходной    Майские праздники
2019-05-03     2019 май              3 пт           Выходной    Майские праздники
2019-05-04     2019 май              4 сб           Выходной    Майские праздники
2019-05-05     2019 май              5 вс           Выходной    Майские праздники
2019-05-06     2019 май              6 пн           Рабочий     <null>
2019-05-07     2019 май              7 вт           Рабочий     <null>
2019-05-08     2019 май              8 ср           Рабочий     <null>
2019-05-09     2019 май              9 чт           Праздничный День победы
2019-05-10     2019 май             10 пт           Выходной    Майские праздники
2019-05-11     2019 май             11 сб           Выходной    <null>
2019-05-12     2019 май             12 вс           Выходной    <null>
2019-05-13     2019 май             13 пн           Рабочий     <null>
2019-05-14     2019 май             14 вт           Рабочий     <null>
2019-05-15     2019 май             15 ср           Рабочий     <null>
2019-05-16     2019 май             16 чт           Рабочий     <null>
2019-05-17     2019 май             17 пт           Рабочий     <null>
2019-05-18     2019 май             18 сб           Выходной    <null>
2019-05-19     2019 май             19 вс           Выходной    <null>
2019-05-20     2019 май             20 пн           Рабочий     <null>


BYDATE      YEAR_OF MONTH_NAME  DAY_OF WEEKDAY_NAME DATE_TYPE   REMARK
=========== ======= ========== ======= ============ =========== ==================
2019-05-21     2019 май             21 вт           Рабочий     <null>
2019-05-22     2019 май             22 ср           Рабочий     <null>
2019-05-23     2019 май             23 чт           Рабочий     <null>
2019-05-24     2019 май             24 пт           Рабочий     <null>
2019-05-25     2019 май             25 сб           Выходной    <null>
2019-05-26     2019 май             26 вс           Выходной    <null>
2019-05-27     2019 май             27 пн           Рабочий     <null>
2019-05-28     2019 май             28 вт           Рабочий     <null>
2019-05-29     2019 май             29 ср           Рабочий     <null>
2019-05-30     2019 май             30 чт           Рабочий     <null>
2019-05-31     2019 май             31 пт           Рабочий     <null>

На этом, всё. Мы получили возможность генерировать производственный календарь «на лету», управлять исключениями для дат, а также сохранять календарь в таблице для быстрого поиска по дате. Скрипт для создания таблиц и пакетов календаря вы можете найти здесь.
Tags:
Hubs:
+11
Comments1

Articles

Change theme settings