@salesforce/afv-skills 1.63.0 → 1.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +253 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +796 -0
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +111 -0
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +1053 -0
- package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +21 -11
- package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +10 -0
- package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
- package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +44 -1
- package/skills/automation-sandbox-post-copy-configure/SKILL.md +132 -124
- package/skills/automation-sandbox-post-copy-configure/references/flow_activation_path.md +151 -0
- package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +55 -9
- package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +54 -107
- package/skills/consumer-goods-promotion-bo-api-deploy/assets/set-comment-value/payloads/copy.json +1 -1
- package/skills/consumer-goods-promotion-bo-api-deploy/references/conventions-and-payload-rules.md +82 -5
- package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +35 -2
- package/skills/consumer-goods-promotion-bo-api-deploy/references/preflight-and-seed-queries.md +149 -0
- package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +17 -1
- package/skills/dx-devops-conflict-resolve/SKILL.md +1 -5
- package/skills/dx-devops-pipeline-manage/SKILL.md +1 -5
- package/skills/dx-devops-project-manage/SKILL.md +1 -5
- package/skills/dx-devops-promote/SKILL.md +1 -5
- package/skills/dx-devops-request-status/SKILL.md +1 -5
- package/skills/dx-lifecycle-center/SKILL.md +120 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +175 -0
- package/skills/dx-lifecycle-center/references/connect-api.md +123 -0
- package/skills/dx-lifecycle-center/references/connect-org.md +184 -0
- package/skills/dx-lifecycle-center/references/deploy.md +153 -0
- package/skills/dx-lifecycle-center/references/error-handling.md +21 -0
- package/skills/dx-lifecycle-center/references/hub-org.md +37 -0
- package/skills/dx-lifecycle-center/references/list-orgs.md +99 -0
- package/skills/dx-lifecycle-center/references/request-body.md +75 -0
- package/skills/dx-lifecycle-center/references/troubleshooting.md +31 -0
- package/skills/dx-lifecycle-center/scripts/poll-deploy-status.sh +200 -0
- package/skills/dx-lifecycle-center/scripts/resolve-environment.sh +120 -0
- package/skills/dx-org-devhub-configure/SKILL.md +1 -3
- package/skills/dx-org-shape-manage/SKILL.md +1 -5
- package/skills/dx-org-snapshot-manage/SKILL.md +1 -3
- package/skills/education-cloud-academic-calendar-generate/SKILL.md +1 -3
- package/skills/education-cloud-course-catalog-migrate/SKILL.md +1 -3
- package/skills/education-cloud-domain-configure/SKILL.md +1 -3
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -3
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -5
- package/skills/experience-ui-bundle-2gp-deploy/SKILL.md +1 -3
- package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -5
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -3
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -3
- package/skills/insurance-brokerage-agency-billing-configure/SKILL.md +1 -3
- package/skills/marketing-message-test/SKILL.md +1 -3
- package/skills/nonprofit-fundraising-configure/SKILL.md +240 -0
- package/skills/nonprofit-fundraising-configure/references/gotchas.md +26 -0
- package/skills/nonprofit-fundraising-configure/references/mcp-invocation.md +678 -0
- package/skills/nonprofit-fundraising-configure/references/permission-targeting.md +35 -0
- package/skills/nonprofit-volunteer-management-model-get/SKILL.md +166 -0
- package/skills/nonprofit-volunteer-management-model-get/references/entities.md +315 -0
- package/skills/nonprofit-volunteer-management-model-get/references/relationships.md +175 -0
- package/skills/nonprofit-volunteer-management-model-get/references/themes.md +143 -0
- package/skills/nonprofit-volunteer-management-records-create/SKILL.md +121 -0
- package/skills/nonprofit-volunteer-management-records-create/references/field-guide.md +284 -0
- package/skills/nonprofit-volunteer-management-records-create/references/mcp-invocation.md +248 -0
- package/skills/nonprofit-volunteer-management-records-create/references/review-format.md +140 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/expand-series.py +632 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/render-review-table.py +419 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_expand_series.py +690 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_render_review_table.py +421 -0
- package/skills/nonprofit-volunteer-management-structure-generate/SKILL.md +119 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/configuration-decisions.md +159 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/discovery-interview.md +137 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/edge-cases.md +48 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/mcp-invocation.md +75 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/plan-output.md +141 -0
- package/skills/platform-custom-field-generate/SKILL.md +1 -0
- package/skills/platform-custom-field-generate/references/advanced-picklists.md +5 -5
- package/skills/platform-custom-metadata-type-generate/SKILL.md +3 -0
- package/skills/platform-custom-object-generate/SKILL.md +4 -0
- package/skills/platform-custom-setting-generate/SKILL.md +3 -0
- package/skills/platform-datamask-run/SKILL.md +1 -5
- package/skills/platform-permission-set-generate/SKILL.md +3 -0
- package/skills/platform-salesforce-connect-adapter-generate/SKILL.md +1 -3
- package/skills/platform-sandbox-configure/SKILL.md +1 -3
- package/skills/platform-value-set-generate/SKILL.md +3 -0
- package/skills/sales-call-scoring-configure/SKILL.md +1 -5
- package/skills/service-agentforce-channel-configure/SKILL.md +1 -3
- package/skills/service-agentforce-human-escalation-configure/SKILL.md +1 -3
- package/skills/service-catalog-template-coordinate/SKILL.md +1 -3
- package/skills/service-concierge-portal-generate/SKILL.md +1 -3
- package/skills/service-email-to-case-configure/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +1 -0
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-uel-user-create/SKILL.md +1 -9
- package/skills/service-itsm-incident-mgmt-configure/SKILL.md +1 -5
- package/skills/service-itsm-incident-priority-configure/SKILL.md +1 -3
- package/skills/service-itsm-slack-itservice-configure/SKILL.md +1 -3
- package/skills/service-itsm-swarming-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-debug/SKILL.md +1 -3
- package/skills/service-itsm-teams-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itdesk-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itservice-configure/SKILL.md +1 -3
- package/skills/service-native-voice-recording-transcription-configure/SKILL.md +1 -3
- package/skills/service-omni-agent-users-create/SKILL.md +1 -3
- package/skills/service-omni-agent-work-sharing-configure/SKILL.md +1 -3
- package/skills/service-omni-attribute-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-base-settings-configure/SKILL.md +1 -3
- package/skills/service-omni-channel-inventory-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-limits-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +1 -3
- package/skills/service-omni-command-center-analyze/SKILL.md +1 -3
- package/skills/service-omni-command-center-configure/SKILL.md +1 -3
- package/skills/service-omni-permission-set-assign/SKILL.md +1 -3
- package/skills/service-omni-presence-status-deploy/SKILL.md +1 -3
- package/skills/service-omni-presence-user-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-members-assign/SKILL.md +1 -3
- package/skills/service-omni-queue-routing-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-routing-flow-deploy/SKILL.md +1 -3
- package/skills/service-omni-service-channel-configure/SKILL.md +1 -3
- package/skills/service-omni-sidebar-configure/SKILL.md +1 -3
- package/skills/service-omni-skills-based-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-supervisor-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-permset-assign/SKILL.md +1 -3
- package/skills/service-omni-supervisor-surface-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-users-create/SKILL.md +1 -3
- package/skills/service-omni-work-skill-routing-configure/SKILL.md +1 -3
- package/skills/field-service-setup-orchestrator-get/SKILL.md +0 -47
- package/skills/tableau-next-asset-create/README.md +0 -65
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Emit the discrete VM shift / time-slot records that should be imported for a series.
|
|
3
|
+
|
|
4
|
+
This is the *deterministic materialization step* for the two VM entities whose count can
|
|
5
|
+
exceed hand-authoring size — ``JobPositionShift`` (a dated series) and ``TimeSlot`` (a
|
|
6
|
+
weekly window set). It does NOT write to the org: it emits a ``{"records": [...]}`` tree
|
|
7
|
+
object on stdout — the request body of a ``composite/tree`` call, and the file format of a
|
|
8
|
+
Salesforce CLI tree import — which the skill sends with the write call documented in
|
|
9
|
+
``references/mcp-invocation.md``. A tree call is all-or-nothing, so each batch either
|
|
10
|
+
lands whole or not at all.
|
|
11
|
+
|
|
12
|
+
Its contract is "given a series spec, emit the records that should be *created*" — which
|
|
13
|
+
includes NOT re-emitting shifts that already exist (``excludeExisting``). Expansion and
|
|
14
|
+
that set-difference both live here, in reviewed/tested code, because the alternative —
|
|
15
|
+
day/date arithmetic or dedup done by hand in the skill — is exactly the error-prone,
|
|
16
|
+
unreviewed work this script exists to replace (see SKILL.md: "Never build a series or a
|
|
17
|
+
records table by hand"). The agent passes a **spec** (args as data)
|
|
18
|
+
and never authors code per run.
|
|
19
|
+
|
|
20
|
+
Field API names come from ``references/field-guide.md`` — the standard-field authority.
|
|
21
|
+
No field is emitted that the field guide does not document, and fields that default
|
|
22
|
+
server-side are deliberately left unset: ``OperatingHours`` has no ``Type`` field at all,
|
|
23
|
+
while ``TimeSlot.Type`` DOES exist but is required-but-defaulted (unset yields ``Normal``,
|
|
24
|
+
which VM doesn't use) and ``TimeSlot.Name`` is auto-number — none are ever set here.
|
|
25
|
+
|
|
26
|
+
USAGE
|
|
27
|
+
-----
|
|
28
|
+
python3 scripts/expand-series.py < spec.json # one tree object to stdout (<=200 records)
|
|
29
|
+
python3 scripts/expand-series.py --out-dir ./import < spec.json # one importable file per <=200 batch
|
|
30
|
+
|
|
31
|
+
stdout carries a SINGLE importable tree object, so a series over 200 records
|
|
32
|
+
(which must split into multiple files) requires --out-dir; the script fails with
|
|
33
|
+
a message rather than printing a JSON array a tree call cannot read.
|
|
34
|
+
|
|
35
|
+
WINDOWS: uniform vs. per-day
|
|
36
|
+
----------------------------
|
|
37
|
+
Supply EXACTLY ONE of:
|
|
38
|
+
* ``windows`` — a flat list applied UNIFORMLY to every day in the series. Use only
|
|
39
|
+
when the time windows are the same on every ``daysOfWeek`` (or every explicit date).
|
|
40
|
+
* ``windowsByDay`` — a map of day-name -> window list, when the windows VARY by weekday
|
|
41
|
+
(e.g. Sat 08:00–12:00 + 12:00–17:00 but Sun 09:00–14:00). The map keys define which
|
|
42
|
+
weekdays the series runs on, so do NOT also pass ``daysOfWeek`` with ``windowsByDay``.
|
|
43
|
+
|
|
44
|
+
Overlapping windows on the same day (e.g. 08:00–13:00 and 12:00–17:00) are ALLOWED and
|
|
45
|
+
emitted as-is — VM permits overlapping shift records; the script does not second-guess
|
|
46
|
+
intent. (For a same-day window — ``endDayOffset`` 0 — it still rejects an end that is not
|
|
47
|
+
after the start.) An EXACT duplicate window (same start/end/offset listed twice) collapses
|
|
48
|
+
to one shift — never emitted twice.
|
|
49
|
+
|
|
50
|
+
MULTI-DAY / OVERNIGHT shifts (JobPositionShift only)
|
|
51
|
+
----------------------------------------------------
|
|
52
|
+
A ``JobPositionShift`` has separate StartDate and EndDate, so a shift may span dates. Each
|
|
53
|
+
window may carry ``endDayOffset`` (int >= 0, default 0) — how many days AFTER its StartDate
|
|
54
|
+
the window ENDS:
|
|
55
|
+
* 0 (default) — single-day shift; EndDate == StartDate; end time must be after start.
|
|
56
|
+
* 1 — crosses midnight into the next day (e.g. 23:00 -> 03:00 the next morning).
|
|
57
|
+
* N — an N-day span (e.g. a Mon 09:00 -> Fri 17:00 shift is endDayOffset 4).
|
|
58
|
+
The recurring/one-off date is the StartDate and EndDate = StartDate + offset; with an offset
|
|
59
|
+
the end time may be earlier than the start time (EndDate > StartDate already orders them).
|
|
60
|
+
EndDate can fall past ``dateRange.end`` — the range bounds START dates, not end dates.
|
|
61
|
+
``TimeSlot`` is a single-weekday window (no EndDate) and rejects ``endDayOffset``.
|
|
62
|
+
|
|
63
|
+
DATES: ranges + one-offs + removals (JobPositionShift)
|
|
64
|
+
------------------------------------------------------
|
|
65
|
+
The date set is the UNION of two optional sources (supply at least one):
|
|
66
|
+
* ``dateRanges`` — a LIST of {start, end}; each range recurs on the series' weekdays
|
|
67
|
+
(``daysOfWeek`` in flat-window mode, or the ``windowsByDay`` keys in per-day mode).
|
|
68
|
+
``dateRange`` (singular) is accepted as sugar for a one-element list.
|
|
69
|
+
* ``oneOffDates`` — individual 'YYYY-MM-DD' dates added AS-IS (no weekday filter), for
|
|
70
|
+
one-off extras/exceptions outside the recurring pattern.
|
|
71
|
+
So "every Tue/Thu across June and September, plus Aug 15" is ONE spec (multiple ranges +
|
|
72
|
+
a one-off) — no need to run the script per range. Overlapping ranges / a one-off already
|
|
73
|
+
inside a range collapse: each date appears once.
|
|
74
|
+
|
|
75
|
+
``excludeDates`` — an optional list of 'YYYY-MM-DD' dates to DROP from the union
|
|
76
|
+
(regardless of window). This is the in-memory removal ("skip the July 4th shift I was
|
|
77
|
+
about to create"); it is independent of ``excludeExisting`` below (which is org dedup by
|
|
78
|
+
full shift key, not date removal).
|
|
79
|
+
|
|
80
|
+
``count`` — an optional integer >= 1 that bounds the series by NUMBER OF DATES instead of
|
|
81
|
+
(or as well as) an end date: the series keeps the first ``count`` dates of the union, after
|
|
82
|
+
``excludeDates`` is applied, and every window on a kept date becomes a shift. With ``count``
|
|
83
|
+
a range may omit its ``end`` ("start 2027-06-05, 10 Saturdays"); with both an end and a
|
|
84
|
+
count the series stops at whichever comes first. A series always needs an end date or a
|
|
85
|
+
count, so an open-ended series can't be expressed. ``count`` is applied before
|
|
86
|
+
``excludeExisting``, so it counts the dates the user asked for, not only the missing ones.
|
|
87
|
+
|
|
88
|
+
SPEC (stdin JSON)
|
|
89
|
+
-----------------
|
|
90
|
+
JobPositionShift — ranges + one-offs, uniform windows:
|
|
91
|
+
{
|
|
92
|
+
"entity": "JobPositionShift",
|
|
93
|
+
"parentId": "0Jp...", # literal JobPosition Id (or "parentRef")
|
|
94
|
+
"parentRef": "JobPosition_1", # @refId — only when the JobPosition is in the SAME tree file;
|
|
95
|
+
# a parent from an earlier import is always a literal parentId
|
|
96
|
+
"daysOfWeek": ["Tuesday", "Thursday"], # weekdays the ranges recur on
|
|
97
|
+
"windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
|
|
98
|
+
"dateRanges": [ # LIST of ranges (or "dateRange" for a single one)
|
|
99
|
+
{"start": "2027-06-01", "end": "2027-06-30"},
|
|
100
|
+
{"start": "2027-09-01", "end": "2027-09-30"}
|
|
101
|
+
],
|
|
102
|
+
"oneOffDates": ["2027-08-15"], # optional individual dates outside the ranges
|
|
103
|
+
"excludeDates": ["2027-06-08"], # optional whole dates to drop (in-memory removal)
|
|
104
|
+
"timeZone": "America/New_York",
|
|
105
|
+
"maximumAttendeesCount": 8, # optional
|
|
106
|
+
"status": "Upcoming", # optional — omitted means "Upcoming", so pass the
|
|
107
|
+
# value the user confirmed in the review
|
|
108
|
+
"count": 10, # optional — keep only the first N dates
|
|
109
|
+
"referenceIdPrefix": "JPS", # optional
|
|
110
|
+
"excludeExisting": [ # optional — shifts already in the org; skip re-creating these
|
|
111
|
+
{"StartDate": "2027-06-01", "EndDate": "2027-06-01",
|
|
112
|
+
"StartTime": "10:00:00.000Z", "EndTime": "12:00:00.000Z"}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
JobPositionShift — per-day windows (windows vary by weekday):
|
|
117
|
+
{
|
|
118
|
+
"entity": "JobPositionShift",
|
|
119
|
+
"parentId": "0Jp...",
|
|
120
|
+
"windowsByDay": {
|
|
121
|
+
"Saturday": [{"start": "08:00", "end": "12:00"}, {"start": "12:00", "end": "17:00"}],
|
|
122
|
+
"Sunday": [{"start": "09:00", "end": "14:00"}]
|
|
123
|
+
},
|
|
124
|
+
"dateRange": {"start": "2027-06-05", "end": "2027-08-29"},
|
|
125
|
+
"timeZone": "America/New_York"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
JobPositionShift — bounded by a count instead of an end date (10 Saturdays):
|
|
129
|
+
{
|
|
130
|
+
"entity": "JobPositionShift",
|
|
131
|
+
"parentId": "0Jp...",
|
|
132
|
+
"daysOfWeek": ["Saturday"],
|
|
133
|
+
"windows": [{"start": "09:00", "end": "12:00"}],
|
|
134
|
+
"dateRange": {"start": "2027-06-05"},
|
|
135
|
+
"count": 10,
|
|
136
|
+
"timeZone": "America/New_York"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
``oneOffDates`` may be used with either window form; with ``windowsByDay`` every date
|
|
140
|
+
(range-derived OR one-off) must fall on a weekday present in the map. A spec of ONLY
|
|
141
|
+
``oneOffDates`` (no ranges) needs no ``daysOfWeek``.
|
|
142
|
+
{
|
|
143
|
+
"entity": "JobPositionShift",
|
|
144
|
+
"parentId": "0Jp...",
|
|
145
|
+
"oneOffDates": ["2026-09-06", "2026-09-20", "2026-10-11"],
|
|
146
|
+
"windows": [{"start": "12:00", "end": "16:00"}],
|
|
147
|
+
"timeZone": "America/New_York",
|
|
148
|
+
"maximumAttendeesCount": 30
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
excludeExisting (JobPositionShift only): a list of already-existing shifts to skip, matched
|
|
152
|
+
on the shift key ``JobPositionId + StartDate + EndDate + StartTime + EndTime``. A spec has one
|
|
153
|
+
JobPosition, so pass only that JobPosition's shifts and the four date/time fields are compared. Times accept "HH:MM",
|
|
154
|
+
"HH:MM:SS", or the Salesforce literal "HH:MM:SS.000Z" that a SOQL query returns, so a
|
|
155
|
+
query result can be passed straight through. ``EndDate`` defaults to ``StartDate`` if
|
|
156
|
+
omitted. If every generated shift is excluded, the script exits non-zero with a clear
|
|
157
|
+
"nothing to create" message rather than emitting an empty import.
|
|
158
|
+
|
|
159
|
+
TimeSlot (weekly windows — no dateRange; parent OperatingHours must be a LITERAL Id
|
|
160
|
+
because OperatingHoursId is master-detail). Supports uniform ``windows`` (+ ``daysOfWeek``)
|
|
161
|
+
or per-day ``windowsByDay``:
|
|
162
|
+
{
|
|
163
|
+
"entity": "TimeSlot",
|
|
164
|
+
"parentId": "0OH...", # literal OperatingHours Id (master-detail: no @ref)
|
|
165
|
+
"daysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
166
|
+
"windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
|
|
167
|
+
"referenceIdPrefix": "TS" # optional
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
Exit non-zero with a message on any spec error (unknown entity, bad day name, bad
|
|
171
|
+
time/date, both/neither windows forms, empty result, TimeSlot given a @ref parent, etc.).
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
import argparse
|
|
175
|
+
import datetime as dt
|
|
176
|
+
import json
|
|
177
|
+
import os
|
|
178
|
+
import sys
|
|
179
|
+
|
|
180
|
+
TREE_IMPORT_CAP = 200 # Composite Tree API caps at 200 records per call.
|
|
181
|
+
|
|
182
|
+
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
183
|
+
DAY_INDEX = {name: i for i, name in enumerate(DAY_NAMES)}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def fail(msg):
|
|
187
|
+
sys.stderr.write("expand-series: %s\n" % msg)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_time(value, label):
|
|
192
|
+
"""Accept 'HH:MM', 'HH:MM:SS', or the Salesforce literal 'HH:MM:SS.mmmZ'
|
|
193
|
+
(as a SOQL query returns it) -> canonical 'HH:MM:SS.000Z'."""
|
|
194
|
+
if not isinstance(value, str):
|
|
195
|
+
fail("%s must be a string like '10:00' or '10:00:00'" % label)
|
|
196
|
+
parts = value.strip().split(":")
|
|
197
|
+
if len(parts) == 2:
|
|
198
|
+
parts.append("00")
|
|
199
|
+
if len(parts) != 3:
|
|
200
|
+
fail("%s '%s' is not HH:MM or HH:MM:SS" % (label, value))
|
|
201
|
+
# Tolerate a fractional-seconds / trailing-Z suffix on the seconds component so a
|
|
202
|
+
# Salesforce time literal ('12:00:00.000Z') round-trips into the same canonical form.
|
|
203
|
+
sec = parts[2].split(".")[0].rstrip("Zz")
|
|
204
|
+
try:
|
|
205
|
+
h, m, s = int(parts[0]), int(parts[1]), int(sec)
|
|
206
|
+
dt.time(h, m, s) # validate ranges
|
|
207
|
+
except ValueError:
|
|
208
|
+
fail("%s '%s' is not a valid time" % (label, value))
|
|
209
|
+
return "%02d:%02d:%02d.000Z" % (h, m, s)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_date(value, label):
|
|
213
|
+
if not isinstance(value, str):
|
|
214
|
+
fail("%s must be a 'YYYY-MM-DD' string" % label)
|
|
215
|
+
try:
|
|
216
|
+
return dt.date.fromisoformat(value.strip())
|
|
217
|
+
except ValueError:
|
|
218
|
+
fail("%s '%s' is not a valid YYYY-MM-DD date" % (label, value))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def normalize_window_list(value, label, allow_span=True):
|
|
222
|
+
"""A list of {start, end[, endDayOffset]} -> list of (start, end, offset).
|
|
223
|
+
|
|
224
|
+
``endDayOffset`` (int >= 0, default 0) is how many days after StartDate the window
|
|
225
|
+
ENDS: 0 = same day, 1 = crosses midnight into the next day, N = an N-day span. Only
|
|
226
|
+
``JobPositionShift`` supports it (it has a separate EndDate); ``TimeSlot`` windows are
|
|
227
|
+
single-weekday (``allow_span=False``) and reject a nonzero offset. When the offset is 0
|
|
228
|
+
the end must be after the start (a valid same-day window); once the window spans a later
|
|
229
|
+
day any end time is valid, because EndDate > StartDate already makes the end datetime
|
|
230
|
+
later than the start.
|
|
231
|
+
"""
|
|
232
|
+
if not isinstance(value, list) or not value:
|
|
233
|
+
fail("%s must be a non-empty list of {start, end}" % label)
|
|
234
|
+
out = []
|
|
235
|
+
for w in value:
|
|
236
|
+
if not isinstance(w, dict) or "start" not in w or "end" not in w:
|
|
237
|
+
fail("each %s entry needs a 'start' and 'end' (HH:MM)" % label)
|
|
238
|
+
start = parse_time(w["start"], "%s.start" % label)
|
|
239
|
+
end = parse_time(w["end"], "%s.end" % label)
|
|
240
|
+
offset = w.get("endDayOffset", 0)
|
|
241
|
+
# bool is an int subclass — reject it so a stray `true` isn't read as offset 1.
|
|
242
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
243
|
+
fail("%s endDayOffset must be an integer >= 0 "
|
|
244
|
+
"(0 = same day, 1 = next day, N = N-day span)" % label)
|
|
245
|
+
if offset and not allow_span:
|
|
246
|
+
fail("%s cannot span days — endDayOffset is only for JobPositionShift, "
|
|
247
|
+
"which has an EndDate (a TimeSlot is one weekday's window)" % label)
|
|
248
|
+
if offset == 0 and end <= start:
|
|
249
|
+
fail("%s end %s must be after start %s — for a shift that crosses midnight or "
|
|
250
|
+
"spans days, set endDayOffset (1 = next day, N = N-day span)"
|
|
251
|
+
% (label, w["end"], w["start"]))
|
|
252
|
+
out.append((start, end, offset))
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def normalize_windows_by_day(raw, allow_span=True):
|
|
257
|
+
"""A map dayName -> [{start,end}] -> {dayName: [(start,end,offset)]}."""
|
|
258
|
+
if not isinstance(raw, dict) or not raw:
|
|
259
|
+
fail("windowsByDay must be a non-empty map of dayName -> [{start, end}]")
|
|
260
|
+
out = {}
|
|
261
|
+
for day, wins in raw.items():
|
|
262
|
+
if day not in DAY_INDEX:
|
|
263
|
+
fail("unknown day '%s' in windowsByDay — use %s" % (day, ", ".join(DAY_NAMES)))
|
|
264
|
+
out[day] = normalize_window_list(wins, "windowsByDay['%s']" % day, allow_span)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resolve_windows(spec, allow_span=True):
|
|
269
|
+
"""Return ('flat', [(start,end,offset)]) or ('by_day', {day: [(start,end,offset)]}).
|
|
270
|
+
|
|
271
|
+
Exactly one of ``windows`` / ``windowsByDay`` must be supplied. ``allow_span`` gates the
|
|
272
|
+
per-window ``endDayOffset`` (JobPositionShift only); TimeSlot passes ``allow_span=False``.
|
|
273
|
+
"""
|
|
274
|
+
flat = spec.get("windows")
|
|
275
|
+
by_day = spec.get("windowsByDay")
|
|
276
|
+
if (flat is None) == (by_day is None):
|
|
277
|
+
fail("supply exactly one of 'windows' (uniform) or 'windowsByDay' (per-day)")
|
|
278
|
+
if flat is not None:
|
|
279
|
+
return "flat", normalize_window_list(flat, "windows", allow_span)
|
|
280
|
+
return "by_day", normalize_windows_by_day(by_day, allow_span)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def normalize_days(spec):
|
|
284
|
+
days = spec.get("daysOfWeek")
|
|
285
|
+
if not isinstance(days, list) or not days:
|
|
286
|
+
fail("daysOfWeek must be a non-empty list of day names")
|
|
287
|
+
out = []
|
|
288
|
+
for d in days:
|
|
289
|
+
if d not in DAY_INDEX:
|
|
290
|
+
fail("unknown day '%s' — use %s" % (d, ", ".join(DAY_NAMES)))
|
|
291
|
+
out.append(d)
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def dates_in_range(date_range, wanted_days, open_limit=None):
|
|
296
|
+
"""Dates in ``date_range`` that fall on ``wanted_days``.
|
|
297
|
+
|
|
298
|
+
A range without an ``end`` is only valid when the spec sets ``count``; it then yields
|
|
299
|
+
``open_limit`` matching dates from its start, which is enough to fill ``count`` after
|
|
300
|
+
``excludeDates`` removes any of them.
|
|
301
|
+
"""
|
|
302
|
+
start = parse_date(date_range.get("start"), "dateRange.start")
|
|
303
|
+
end = None
|
|
304
|
+
if date_range.get("end") is None:
|
|
305
|
+
if open_limit is None:
|
|
306
|
+
fail("dateRange.end is required unless the spec sets 'count' — a series needs "
|
|
307
|
+
"an end date or a count")
|
|
308
|
+
else:
|
|
309
|
+
end = parse_date(date_range.get("end"), "dateRange.end")
|
|
310
|
+
if end < start:
|
|
311
|
+
fail("dateRange.end must be on/after dateRange.start")
|
|
312
|
+
wanted = {DAY_INDEX[d] for d in wanted_days}
|
|
313
|
+
out = []
|
|
314
|
+
cur = start
|
|
315
|
+
one = dt.timedelta(days=1)
|
|
316
|
+
while end is None or cur <= end:
|
|
317
|
+
if end is None and len(out) >= open_limit:
|
|
318
|
+
break
|
|
319
|
+
if cur.weekday() in wanted:
|
|
320
|
+
out.append(cur)
|
|
321
|
+
cur += one
|
|
322
|
+
return out
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def parse_count(spec):
|
|
326
|
+
"""``count`` (optional int >= 1) — the number of series dates to keep."""
|
|
327
|
+
count = spec.get("count")
|
|
328
|
+
if count is None:
|
|
329
|
+
return None
|
|
330
|
+
# bool is an int subclass — reject it so a stray `true` isn't read as 1.
|
|
331
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 1:
|
|
332
|
+
fail("count must be an integer >= 1 (the number of dates in the series)")
|
|
333
|
+
return count
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def parent_value(spec, entity):
|
|
337
|
+
literal = spec.get("parentId")
|
|
338
|
+
ref = spec.get("parentRef")
|
|
339
|
+
if literal and ref:
|
|
340
|
+
fail("supply only one of parentId / parentRef")
|
|
341
|
+
if literal:
|
|
342
|
+
return literal
|
|
343
|
+
if ref:
|
|
344
|
+
if entity == "TimeSlot":
|
|
345
|
+
fail("TimeSlot.OperatingHoursId is master-detail — the parent must be a "
|
|
346
|
+
"literal Id (parentId), not a @ref. Create OperatingHours first.")
|
|
347
|
+
return "@" + str(ref).lstrip("@")
|
|
348
|
+
fail("supply a parentId (literal) or parentRef (@refId) for the parent")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def resolve_shift_dates(spec, mode, windows):
|
|
352
|
+
"""Return the ordered, de-duplicated list of dt.date for a shift series.
|
|
353
|
+
|
|
354
|
+
The date set is the UNION of two optional sources (supply at least one):
|
|
355
|
+
* ``dateRanges`` — a list of {start, end} ranges, each expanded on the series'
|
|
356
|
+
recurring weekdays (``daysOfWeek`` in flat mode, or the ``windowsByDay`` keys in
|
|
357
|
+
per-day mode). ``dateRange`` (singular) is accepted as sugar for a one-element list.
|
|
358
|
+
* ``oneOffDates`` — individual 'YYYY-MM-DD' dates added AS-IS (no weekday filter);
|
|
359
|
+
use for one-off extras/exceptions that fall outside the recurring pattern.
|
|
360
|
+
``excludeDates`` (optional) then removes whole dates from the union — an in-memory
|
|
361
|
+
removal (e.g. a holiday), independent of ``excludeExisting`` (which is org-dedup by
|
|
362
|
+
the full shift key). Overlapping ranges and a one-off that also lands in a range both
|
|
363
|
+
collapse — a date appears once. ``count`` (optional) finally keeps the first N dates.
|
|
364
|
+
"""
|
|
365
|
+
if spec.get("dates") is not None:
|
|
366
|
+
fail("'dates' was renamed for clarity — use 'oneOffDates' for individual dates "
|
|
367
|
+
"and 'dateRanges' (or 'dateRange') for recurring ranges")
|
|
368
|
+
|
|
369
|
+
# Collect ranges: singular sugar + list form.
|
|
370
|
+
ranges = []
|
|
371
|
+
single = spec.get("dateRange")
|
|
372
|
+
if single is not None:
|
|
373
|
+
if not isinstance(single, dict):
|
|
374
|
+
fail("dateRange must be a single {start, end} object; for multiple ranges use 'dateRanges'")
|
|
375
|
+
ranges.append(single)
|
|
376
|
+
many = spec.get("dateRanges")
|
|
377
|
+
if many is not None:
|
|
378
|
+
if not isinstance(many, list) or not many:
|
|
379
|
+
fail("dateRanges must be a non-empty list of {start, end} objects")
|
|
380
|
+
for r in many:
|
|
381
|
+
if not isinstance(r, dict):
|
|
382
|
+
fail("each dateRanges entry must be a {start, end} object")
|
|
383
|
+
ranges.extend(many)
|
|
384
|
+
|
|
385
|
+
one_offs = spec.get("oneOffDates")
|
|
386
|
+
if one_offs is not None and (not isinstance(one_offs, list) or not one_offs):
|
|
387
|
+
fail("oneOffDates must be a non-empty list of 'YYYY-MM-DD' strings")
|
|
388
|
+
|
|
389
|
+
if not ranges and one_offs is None:
|
|
390
|
+
fail("JobPositionShift needs 'dateRanges' (or 'dateRange') and/or 'oneOffDates'")
|
|
391
|
+
|
|
392
|
+
# Weekdays the ranges recur on.
|
|
393
|
+
if mode == "by_day":
|
|
394
|
+
if spec.get("daysOfWeek") is not None:
|
|
395
|
+
fail("windowsByDay already defines the weekdays — do not also pass daysOfWeek")
|
|
396
|
+
wanted_days = list(windows.keys())
|
|
397
|
+
else:
|
|
398
|
+
wanted_days = normalize_days(spec) if ranges else None
|
|
399
|
+
|
|
400
|
+
count = parse_count(spec)
|
|
401
|
+
excl = spec.get("excludeDates")
|
|
402
|
+
if excl is not None and not isinstance(excl, list):
|
|
403
|
+
fail("excludeDates must be a list of 'YYYY-MM-DD' strings")
|
|
404
|
+
open_limit = count + len(excl or []) if count is not None else None
|
|
405
|
+
|
|
406
|
+
collected = set()
|
|
407
|
+
for r in ranges:
|
|
408
|
+
for day in dates_in_range(r, wanted_days, open_limit):
|
|
409
|
+
collected.add(day)
|
|
410
|
+
if one_offs is not None:
|
|
411
|
+
seen = set()
|
|
412
|
+
for d in one_offs:
|
|
413
|
+
day = parse_date(d, "oneOffDates[]")
|
|
414
|
+
if day in seen:
|
|
415
|
+
fail("duplicate date '%s' in oneOffDates" % d)
|
|
416
|
+
seen.add(day)
|
|
417
|
+
collected.add(day)
|
|
418
|
+
|
|
419
|
+
# Per-day mode: every date (incl. one-offs) must map to a windowsByDay entry.
|
|
420
|
+
if mode == "by_day":
|
|
421
|
+
for day in collected:
|
|
422
|
+
name = DAY_NAMES[day.weekday()]
|
|
423
|
+
if name not in windows:
|
|
424
|
+
fail("date %s falls on %s, which has no entry in windowsByDay"
|
|
425
|
+
% (day.isoformat(), name))
|
|
426
|
+
|
|
427
|
+
if excl is not None:
|
|
428
|
+
for d in excl:
|
|
429
|
+
collected.discard(parse_date(d, "excludeDates[]"))
|
|
430
|
+
|
|
431
|
+
ordered = sorted(collected)
|
|
432
|
+
return ordered[:count] if count is not None else ordered
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def normalize_exclusions(spec):
|
|
436
|
+
"""Build the set of already-existing shift keys to skip.
|
|
437
|
+
|
|
438
|
+
Key = (StartDate, EndDate, StartTime, EndTime), all canonicalized to the same form
|
|
439
|
+
the generator emits so a SOQL query result matches byte-for-byte. EndDate
|
|
440
|
+
defaults to StartDate when omitted (single-day shift).
|
|
441
|
+
"""
|
|
442
|
+
raw = spec.get("excludeExisting")
|
|
443
|
+
if raw is None:
|
|
444
|
+
return set()
|
|
445
|
+
if not isinstance(raw, list):
|
|
446
|
+
fail("excludeExisting must be a list of {StartDate, EndDate, StartTime, EndTime}")
|
|
447
|
+
out = set()
|
|
448
|
+
for i, e in enumerate(raw):
|
|
449
|
+
if not isinstance(e, dict):
|
|
450
|
+
fail("excludeExisting[%d] must be an object" % i)
|
|
451
|
+
sd = parse_date(e.get("StartDate"), "excludeExisting[%d].StartDate" % i).isoformat()
|
|
452
|
+
ed_raw = e.get("EndDate")
|
|
453
|
+
ed = parse_date(ed_raw, "excludeExisting[%d].EndDate" % i).isoformat() if ed_raw else sd
|
|
454
|
+
st = parse_time(e.get("StartTime"), "excludeExisting[%d].StartTime" % i)
|
|
455
|
+
et = parse_time(e.get("EndTime"), "excludeExisting[%d].EndTime" % i)
|
|
456
|
+
out.add((sd, ed, st, et))
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def build_shifts(spec):
|
|
461
|
+
"""Return (records, generated_count, skipped_count).
|
|
462
|
+
|
|
463
|
+
``generated_count`` is every (date x window) the series describes; ``skipped_count`` is
|
|
464
|
+
how many of those were dropped as already-existing (excludeExisting). ``records`` is
|
|
465
|
+
what remains to import.
|
|
466
|
+
"""
|
|
467
|
+
mode, windows = resolve_windows(spec)
|
|
468
|
+
shift_dates = resolve_shift_dates(spec, mode, windows)
|
|
469
|
+
tz = spec.get("timeZone")
|
|
470
|
+
if not tz:
|
|
471
|
+
fail("JobPositionShift needs a timeZone (IANA, e.g. America/New_York)")
|
|
472
|
+
parent = parent_value(spec, "JobPositionShift")
|
|
473
|
+
status = spec.get("status", "Upcoming")
|
|
474
|
+
cap = spec.get("maximumAttendeesCount")
|
|
475
|
+
prefix = spec.get("referenceIdPrefix", "JPS")
|
|
476
|
+
exclude = normalize_exclusions(spec)
|
|
477
|
+
|
|
478
|
+
records = []
|
|
479
|
+
generated = 0
|
|
480
|
+
skipped = 0
|
|
481
|
+
n = 0
|
|
482
|
+
seen = set()
|
|
483
|
+
for day in shift_dates:
|
|
484
|
+
iso = day.isoformat()
|
|
485
|
+
day_windows = windows if mode == "flat" else windows[DAY_NAMES[day.weekday()]]
|
|
486
|
+
for (start, end, offset) in day_windows:
|
|
487
|
+
# EndDate = StartDate + offset days: 0 = single-day, 1 = crosses midnight, N =
|
|
488
|
+
# an N-day span. The key mirrors the org natural key (JobPositionId is fixed per
|
|
489
|
+
# spec) so a queried existing shift matches exactly.
|
|
490
|
+
end_iso = (day + dt.timedelta(days=offset)).isoformat()
|
|
491
|
+
key = (iso, end_iso, start, end)
|
|
492
|
+
if key in seen:
|
|
493
|
+
continue # exact-duplicate (dates, window) — never emit the same shift twice
|
|
494
|
+
seen.add(key)
|
|
495
|
+
generated += 1
|
|
496
|
+
if key in exclude:
|
|
497
|
+
skipped += 1
|
|
498
|
+
continue
|
|
499
|
+
n += 1
|
|
500
|
+
fields = {
|
|
501
|
+
"attributes": {"type": "JobPositionShift",
|
|
502
|
+
"referenceId": "%s_%d" % (prefix, n)},
|
|
503
|
+
"JobPositionId": parent,
|
|
504
|
+
"Status": status,
|
|
505
|
+
"StartDate": iso,
|
|
506
|
+
"EndDate": end_iso,
|
|
507
|
+
"StartTime": start,
|
|
508
|
+
"EndTime": end,
|
|
509
|
+
"TimeZone": tz,
|
|
510
|
+
}
|
|
511
|
+
if cap is not None:
|
|
512
|
+
fields["MaximumAttendeesCount"] = cap
|
|
513
|
+
records.append(fields)
|
|
514
|
+
return records, generated, skipped
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_timeslots(spec):
|
|
518
|
+
mode, windows = resolve_windows(spec, allow_span=False)
|
|
519
|
+
if spec.get("dateRange"):
|
|
520
|
+
fail("TimeSlot is a weekly window set — do not pass a dateRange")
|
|
521
|
+
if spec.get("dates"):
|
|
522
|
+
fail("TimeSlot is a weekly window set — do not pass a 'dates' list "
|
|
523
|
+
"(that belongs to a JobPositionShift spec)")
|
|
524
|
+
if spec.get("count") is not None:
|
|
525
|
+
fail("TimeSlot is a weekly window set — 'count' belongs to a JobPositionShift spec")
|
|
526
|
+
parent = parent_value(spec, "TimeSlot") # enforces literal Id
|
|
527
|
+
prefix = spec.get("referenceIdPrefix", "TS")
|
|
528
|
+
|
|
529
|
+
if mode == "flat":
|
|
530
|
+
seen_day, days = set(), []
|
|
531
|
+
for d in normalize_days(spec):
|
|
532
|
+
if d not in seen_day: # collapse a day listed twice → no duplicate slots
|
|
533
|
+
seen_day.add(d)
|
|
534
|
+
days.append(d)
|
|
535
|
+
day_windows = [(d, windows) for d in days]
|
|
536
|
+
else:
|
|
537
|
+
if spec.get("daysOfWeek") is not None:
|
|
538
|
+
fail("windowsByDay already defines the days — do not also pass daysOfWeek")
|
|
539
|
+
day_windows = [(d, windows[d]) for d in windows.keys()]
|
|
540
|
+
|
|
541
|
+
records = []
|
|
542
|
+
n = 0
|
|
543
|
+
seen = set()
|
|
544
|
+
for day, wins in day_windows:
|
|
545
|
+
for (start, end, _offset) in wins: # offset is always 0 here (allow_span=False)
|
|
546
|
+
if (day, start, end) in seen:
|
|
547
|
+
continue # exact-duplicate (day, window) — never emit the same slot twice
|
|
548
|
+
seen.add((day, start, end))
|
|
549
|
+
n += 1
|
|
550
|
+
records.append({
|
|
551
|
+
"attributes": {"type": "TimeSlot",
|
|
552
|
+
"referenceId": "%s_%d" % (prefix, n)},
|
|
553
|
+
"OperatingHoursId": parent,
|
|
554
|
+
"DayOfWeek": day,
|
|
555
|
+
"StartTime": start,
|
|
556
|
+
"EndTime": end,
|
|
557
|
+
})
|
|
558
|
+
if not records:
|
|
559
|
+
fail("no time slots produced — check daysOfWeek / windows")
|
|
560
|
+
return records
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def chunk(records, size):
|
|
564
|
+
return [records[i:i + size] for i in range(0, len(records), size)]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def main():
|
|
568
|
+
ap = argparse.ArgumentParser(description="Expand a VM shift/time-slot series to tree-import JSON.")
|
|
569
|
+
ap.add_argument("--out-dir", help="write one JSON file per <=200-record batch here; "
|
|
570
|
+
"print the paths instead of the JSON")
|
|
571
|
+
args = ap.parse_args()
|
|
572
|
+
|
|
573
|
+
raw = sys.stdin.read()
|
|
574
|
+
if not raw.strip():
|
|
575
|
+
fail("no spec on stdin — pipe a JSON spec (see the module docstring)")
|
|
576
|
+
try:
|
|
577
|
+
spec = json.loads(raw)
|
|
578
|
+
except json.JSONDecodeError as e:
|
|
579
|
+
fail("spec is not valid JSON: %s" % e)
|
|
580
|
+
|
|
581
|
+
if not isinstance(spec, dict):
|
|
582
|
+
fail("spec must be a JSON object (got %s) — see the module docstring" % type(spec).__name__)
|
|
583
|
+
|
|
584
|
+
entity = spec.get("entity")
|
|
585
|
+
if entity == "JobPositionShift":
|
|
586
|
+
records, generated, skipped = build_shifts(spec)
|
|
587
|
+
if generated == 0:
|
|
588
|
+
fail("no shifts produced — check dateRanges/oneOffDates against the weekdays "
|
|
589
|
+
"(daysOfWeek or windowsByDay), or whether excludeDates removed them all")
|
|
590
|
+
if not records:
|
|
591
|
+
fail("all %d requested shift(s) already exist (excludeExisting); nothing to create"
|
|
592
|
+
% generated)
|
|
593
|
+
if skipped:
|
|
594
|
+
sys.stderr.write("expand-series: excluded %d already-existing shift(s); "
|
|
595
|
+
"%d to create\n" % (skipped, len(records)))
|
|
596
|
+
elif entity == "TimeSlot":
|
|
597
|
+
records = build_timeslots(spec)
|
|
598
|
+
else:
|
|
599
|
+
fail("entity must be 'JobPositionShift' or 'TimeSlot' (got %r)" % entity)
|
|
600
|
+
|
|
601
|
+
batches = chunk(records, TREE_IMPORT_CAP)
|
|
602
|
+
|
|
603
|
+
if args.out_dir:
|
|
604
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
605
|
+
paths = []
|
|
606
|
+
for i, batch in enumerate(batches, 1):
|
|
607
|
+
path = os.path.join(args.out_dir, "%s-%02d.json" % (entity, i))
|
|
608
|
+
with open(path, "w") as fh:
|
|
609
|
+
json.dump({"records": batch}, fh, indent=2)
|
|
610
|
+
fh.write("\n")
|
|
611
|
+
paths.append(path)
|
|
612
|
+
sys.stderr.write("expand-series: %d record(s) -> %d file(s)\n"
|
|
613
|
+
% (len(records), len(paths)))
|
|
614
|
+
print("\n".join(paths))
|
|
615
|
+
else:
|
|
616
|
+
# stdout carries a SINGLE importable tree object ({records:[...]}). Once the
|
|
617
|
+
# series splits past the 200-record cap, no single stdout payload is valid
|
|
618
|
+
# tree-import input, so require --out-dir and write one importable file per
|
|
619
|
+
# batch rather than emitting a JSON array a tree call cannot read.
|
|
620
|
+
if len(batches) > 1:
|
|
621
|
+
fail("%d record(s) exceed the %d-record tree-import cap (%d batches). "
|
|
622
|
+
"Re-run with --out-dir <dir> to write one importable file per batch, "
|
|
623
|
+
"then import each file separately."
|
|
624
|
+
% (len(records), TREE_IMPORT_CAP, len(batches)))
|
|
625
|
+
json.dump({"records": batches[0]}, sys.stdout, indent=2)
|
|
626
|
+
sys.stdout.write("\n")
|
|
627
|
+
sys.stderr.write("expand-series: %d record(s) in %d batch(es)\n"
|
|
628
|
+
% (len(records), len(batches)))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
if __name__ == "__main__":
|
|
632
|
+
main()
|