日本語 · English

Non-delivery days and arrival dates — a logistics business-day calendar from one definition

Translated from the canonical Japanese page recipes/logistics-delivery.md.

A page for IT departments in logistics and e-commerce: people who maintain the “business-day master” and the “non-delivery-day master” that a WMS, a TMS or an order-management system reads. Instead of filling those tables by hand every year, generate them from rules and a holiday table, and run the yearly update as regenerate-and-compare. You do not need to read the language specification.

Kairos does not replace a WMS or a TMS. It splits the roles: which days are business days, and on which day a shipment arrives come out of a definition in Kairos; orders, stock and vehicle dispatch stay in the systems you already have. Results are handed to the masters as tab-separated values (TSV) (Handing it to the master).

The authority on non-delivery days is the carrier’s official notice (Upstream authority stays upstream). The rules on this page are typical ones from public information; they differ from company to company.

At a glance

Requirement Rule (typical) Kairos
Non-delivery days Sundays, national holidays, year-end 12/30–1/3, Obon 8/13–8/15 the union of sunday, the holiday table and the company’s closures
Business days (shipping and delivering) every day that is not a non-delivery day everyDay minus the non-delivery days
Arrival date ship date + N business days (nearby 1, distant 2, remote islands 3 or more) shift(+N, unit: deliveryDay)
Order cutoff 15:00 on business days; later orders ship on the next business day at(T15:00)
Scheduled pickup every Tuesday and Friday; the next business day if it is a non-delivery day filter and roll(Following)
Stocktaking last business day of the month within(month) and last
Year-end band list 12/26–1/7 and send it around list --from --to

Preamble — the holiday table and the company’s closures

Three ingredients. Sundays are a rule; national holidays are a table; the company’s own closures (year-end and Obon) are a table too. Closures are decided year by year, so they go in a table, not in a rule.

# 営業日(日祝・年末年始・お盆を除く)
# eval: 2026-01-01..2026-01-11
premise JP {
  calendar-system: Gregorian
  tz: "Asia/Tokyo"
  wkst: Mon
}

@JP
holidays2026 = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20,
                2026-04-29, 2026-05-03, 2026-05-04, 2026-05-05, 2026-05-06,
                2026-07-20, 2026-08-11, 2026-09-21, 2026-09-22, 2026-09-23,
                2026-10-12, 2026-11-03, 2026-11-23] covering: 2026..2026
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery

deliveryDay
#=> 2026-01-05 2026-01-06 2026-01-07 2026-01-08 2026-01-09 2026-01-10

If Saturdays are non-delivery days too, replace the sunday line with satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun) and put satSun in place of sunday in noDelivery.

The examples below omit premise JP and holidays2026 (in this site’s verification, a standard premise carrying the 2026 holiday table is prepended automatically). When you run them yourself, put the preamble above the expression.

Non-delivery days and business days

Non-delivery days in August: the three Obon days, Mountain Day (8/11) and the Sundays:

# eval: 2026-08-01..2026-09-01
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
noDelivery
#=> 2026-08-02 2026-08-09 2026-08-11 2026-08-13 2026-08-14 2026-08-15 2026-08-16 2026-08-23 2026-08-30

Business days are deliveryDay (everyDay \ noDelivery). For a whole year, make the range 2026-01-01..2027-01-01 (2026 has 289 of them).

Arrival date = ship date + N business days

Arrival dates are counted on the business-day scale. shift(+N, unit: deliveryDay) is “N steps ahead in business days”. For shipments on 4/30 (Thu), 5/1 (Fri) and 5/2 (Sat), just before Golden Week, to a nearby prefecture (1 business day):

# eval: 2026-04-27..2026-05-16
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
shipped = [2026-04-30, 2026-05-01, 2026-05-02] covering: 2026..2026
shipped |> shift(+1, unit: deliveryDay)
#=> 2026-05-01 2026-05-02 2026-05-07

For distant regions (2 business days), only the number changes:

# eval: 2026-04-27..2026-05-16
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
shipped = [2026-04-30, 2026-05-01, 2026-05-02] covering: 2026..2026
shipped |> shift(+2, unit: deliveryDay)
#=> 2026-05-02 2026-05-07 2026-05-08

A shipment on 5/2 (Sat) arrives on 5/7 (Thu) even nearby, because 5/3–5/6 are non-delivery days. Distant regions get 5/8. Remote islands are +3.

When you start from the order date, an order on a non-delivery day ships on the next business day, and the count starts there. Put roll(Following, on: deliveryDay) first:

# eval: 2026-04-27..2026-05-16
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
orders = [2026-05-02, 2026-05-03, 2026-05-04] covering: 2026..2026
orders |> roll(Following, on: deliveryDay) |> shift(+1, unit: deliveryDay)
#=> 2026-05-07 2026-05-08

An order on 5/2 (Sat) ships on 5/2 and arrives on 5/7. Orders on 5/3 (Sun) and 5/4 (holiday) both ship on 5/7 and arrive on 5/8. The result is a set of dates, so two orders that land on the same arrival date become one point (the per-order mapping is looked up one order at a time on the order-system side).

shift(unit: deliveryDay) stops with an error when its input is not a business day, so keep roll first.

Order cutoff (15:00)

Orders up to 15:00 ship the same day; orders after 15:00 ship on the next business day. Write the cutoff as the sequence of points “15:00 on business days”:

# eval: 2026-04-30..2026-05-09
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
deliveryDay |> at(T15:00)
#=> 2026-04-30T15:00 2026-05-01T15:00 2026-05-02T15:00 2026-05-07T15:00 2026-05-08T15:00

Scheduled pickup (every Tuesday and Friday; the next business day if it is a non-delivery day)

Build it from weekdays, then move any non-delivery day forward to the next business day. Around the long weekend in September (9/21 Respect for the Aged Day, 9/22 Citizens’ Holiday, 9/23 Autumnal Equinox Day):

# eval: 2026-09-14..2026-10-01
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
pickup = everyDay |> filter(d => weekday(d) == Tue or weekday(d) == Fri)
pickup |> roll(Following, on: deliveryDay)
#=> 2026-09-15 2026-09-18 2026-09-24 2026-09-25 2026-09-29

9/22 (Tue) moves to 9/24 (Thu), because 9/23 is a holiday as well. 9/25 (Fri) stays. roll does not move a point that is already a business day.

Month-end stocktaking (last business day of the month)

# eval: 2026-01-01..2027-01-01
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
deliveryDay |> within(month) |> last
#=> 2026-01-31 2026-02-28 2026-03-31 2026-04-30 2026-05-30 2026-06-30
#=> 2026-07-31 2026-08-31 2026-09-30 2026-10-31 2026-11-30 2026-12-29

Saturdays are business days, so 1/31 (Sat) and 10/31 (Sat) stay. May gives 5/30 (Sat), just before 5/31 (Sun). December gives 12/29 (Tue), because 12/30–31 are closures.

The year-end band (12/26–1/7)

The band for the “year-end and New Year business days” notice sent out in December. List 12/26 through 1/7:

# eval: 2026-12-26..2027-01-08
@JP
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31] covering: 2026..2026
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays2026 | closed
deliveryDay = everyDay \ noDelivery
deliveryDay
#=> 2026-12-26 2026-12-28 2026-12-29 2027-01-01 2027-01-02 2027-01-04 2027-01-05 2027-01-06 2027-01-07
#~> 範囲外 2027-01-01..2027-01-08(holidays2026 covering 2026-01-01..2026-12-31)
#~> 範囲外 2027-01-01..2027-01-08(closed covering 2026-01-01..2026-12-31)

12/30 and 12/31 are closures. 2027-01-01 and 01-02 come out as business days, because both the holiday table and the closure table end at 2026. The two lines under the points say so: 範囲外 means “out of coverage”.

In the CLI they appear as # ⚠ 範囲外 … lines, and the remaining runway in the coverage summary goes negative (--lang en puts the framing in English; the annotation text itself stays Japanese).

The points come out, but they come out without knowing the table. Extend the tables before sending this band.

Extending the tables means adding the 2027 rows and setting covering: to 2026..2027:

# 営業日(2027 年まで延ばした表)
# eval: 2026-12-26..2027-01-08
premise JP {
  calendar-system: Gregorian
  tz: "Asia/Tokyo"
  wkst: Mon
}

@JP
holidays = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20,
            2026-04-29, 2026-05-03, 2026-05-04, 2026-05-05, 2026-05-06,
            2026-07-20, 2026-08-11, 2026-09-21, 2026-09-22, 2026-09-23,
            2026-10-12, 2026-11-03, 2026-11-23,
            2027-01-01, 2027-01-11, 2027-02-11, 2027-02-23, 2027-03-21,
            2027-03-22, 2027-04-29, 2027-05-03, 2027-05-04, 2027-05-05,
            2027-07-19, 2027-08-11, 2027-09-20, 2027-09-23, 2027-10-11,
            2027-11-03, 2027-11-23] covering: 2026..2027
closed = [2026-01-01..2026-01-03, 2026-08-13..2026-08-15, 2026-12-30..2026-12-31,
          2027-01-01..2027-01-03] covering: 2026..2027
sunday = everyDay |> filter(d => weekday(d) == Sun)
noDelivery = sunday | holidays | closed
deliveryDay = everyDay \ noDelivery

deliveryDay
#=> 2026-12-26 2026-12-28 2026-12-29 2027-01-04 2027-01-05 2027-01-06 2027-01-07

The annotations disappear, and 1/4 (Mon) becomes the first business day of the year (the first comment line is the Japanese title, “business days, with the tables extended through 2027”). Keep the year out of the table’s name (holidays) and the expression needs no edit each time the table is extended.

Next year’s holidays are copied from the Cabinet Office announcement (every February). When to update, where the data comes from and the steps are in the maintenance guide (Japanese, with an English summary).

Handing it to the master (TSV)

--tsv (from 1.0.10) writes the results as tab-separated values, one point per line. Put two body expressions in one file and the expr column distinguishes them as 1 and 2, so the business-day master and the non-delivery-day master come out in one run. End the preamble with the two lines deliveryDay and noDelivery:

kairos list --tsv --from 2026-01-01 --to 2027-01-01 --tz Asia/Tokyo delivery.kairos > delivery.tsv

The yearly update: extend the tables, run the same command again, and diff against the old master. If the difference is “only the rows of the added year”, the check is done.

Pitfalls

Pitfall What happens What to do
Company differences Some carriers deliver on Sundays and holidays. Saturday non-delivery, the Obon dates and the length of the year-end closure also differ by company satSun in place of sunday; the closed rows exactly as the official notice says
Outside the table Points still come out past the end of the table (without knowing the holidays). ⚠ 範囲外 and a negative remaining runway tell you Extend the table, including the end of covering:
Forgetting the yearly update Every range that reaches into next year shows ⚠ and a negative remaining runway Add next year’s rows every February (maintenance guide)
shift fed a non-delivery day unit: deliveryDay stops with an error when the input is not a business day roll(Following, on: deliveryDay) first
The edge of the evaluation range A point that shift moves outside the range does not come out (window-clip warning) Widen the range by N business days
roll(Following) and the year-end Listing a whole year raises ⚠ from 12/29 on (the next business day could fall outside the table) The points themselves are correct. Extending the table removes it
at and the outside of the table A point that falls outside the table disappears under at, leaving only the annotation Extend the table before crossing the year. Evaluate inside the table
No --tz Printed in the machine’s tz; dates may come out with a time of day --tz Asia/Tokyo, matching the tz in the definition

Upstream authority stays upstream

Try it in your browser

Self-contained forms (premise included; the holiday table and the closures inside the expression). Everything runs in the browser; nothing is sent anywhere:

Vocabulary and where the specification is: