Skip to content

Commit 1361f35

Browse files
robbiet480claude
andcommitted
fix: purchase_date wire format; skip Configurator order info
Two fixes for syncing order info to Snipe-IT's native purchase_date and order_number fields (introduced in v1.7.0): 1. purchase_date serialization Upstream go-snipeit's SnipeTime.MarshalJSON unconditionally emits "YYYY-MM-DD HH:MM:SS" (datetime), but Snipe-IT's purchase_date validator only accepts "YYYY-MM-DD" (date-only). Every PATCH was rejected with "The purchase date must be a valid date in YYYY-MM-DD format". Routed purchase_date through asset.CustomFields["purchase_date"] as a plain "YYYY-MM-DD" string. Upstream Asset.MarshalJSON flattens CustomFields to top-level keys *after* the native PurchaseDate line, so the plain string overrides the bad SnipeTime serialization. Snipe-IT still routes the "purchase_date" key to its native column. Will be removed once upstream serializes date-only fields correctly. 2. Configurator-enrolled devices overwrite real purchase data Devices added to ABM via Apple Configurator have purchaseSourceType=MANUALLY_ADDED. ABM returns a synthetic order number like "CE-2024-12-13-04-11-12-826" and the enrollment date as orderDateTime — NOT the real purchase data. Syncing these overwrote real reseller order numbers and purchase dates that were already correct in Snipe-IT. Skip order_date and order_number for MANUALLY_ADDED devices by default. New opt-in flag sync.sync_configurator_order_info bypasses the skip if you actually want the Configurator metadata synced. Bonus: new flag sync.preserve_order_info_on_update never overwrites existing purchase_date / order_number on update (first-time syncs still write them), so manual corrections in Snipe-IT survive future re-runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 713c3c6 commit 1361f35

5 files changed

Lines changed: 264 additions & 25 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ axm2snipe request https://mdmenrollment.apple.com/server/devices
180180
| `sync.model_images` | Fetch device images from appledb.dev for newly created models (default: false) |
181181
| `sync.supplier_mapping` | Map ABM/ASM purchase source IDs/types to Snipe-IT supplier IDs |
182182
| `sync.field_mapping` | Map Snipe-IT fields to ABM/AppleCare source values |
183+
| `sync.preserve_order_info_on_update` | Never overwrite existing `purchase_date` / `order_number` on update. First-time syncs still populate them. (default: false) |
184+
| `sync.sync_configurator_order_info` | Sync ABM's `order_date` / `order_number` for devices added via Apple Configurator (`purchaseSourceType=MANUALLY_ADDED`). Off by default because ABM emits a synthetic `CE-YYYY-MM-DD-...` enrollment ID, not the real purchase info. (default: false) |
183185
| `snipe_it.computer_category_id` | Category ID for Mac models (overrides `category_id` for Macs) |
184186
| `snipe_it.mobile_category_id` | Category ID for iPhone/iPad/Watch/Vision models |
185187
| `snipe_it.custom_fieldset_id` | Fieldset ID to attach to auto-created models |

config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ type SyncConfig struct {
6565
MDMOnlyCache bool `yaml:"mdm_only_cache"` // also exclude non-MDM devices from cache (requires mdm_only)
6666
SupplierMapping map[string]int `yaml:"supplier_mapping"` // ABM purchaseSourceId or purchaseSourceType -> snipe supplier ID
6767
ModelImages bool `yaml:"model_images"` // fetch device images from appledb.dev for newly created models
68+
PreserveOrderInfoOnUpdate bool `yaml:"preserve_order_info_on_update"` // never overwrite existing purchase_date / order_number on update
69+
SyncConfiguratorOrderInfo bool `yaml:"sync_configurator_order_info"` // sync order_date / order_number even for MANUALLY_ADDED devices (default: skip, since ABM's values for these are Configurator enrollment metadata, not real purchase data)
6870
}
6971

7072
// Load reads configuration from a YAML file and applies environment variable overrides.

settings.example.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ sync:
7878
# APPLE: 1 # Apple (by purchaseSourceType)
7979
# MANUALLY_ADDED: 2
8080

81+
# Never overwrite existing purchase_date / order_number on update.
82+
# First-time syncs still populate them. Useful if you manually correct
83+
# order info in Snipe-IT after a sync and want it preserved on re-runs.
84+
# preserve_order_info_on_update: false
85+
86+
# Sync ABM's order_date / order_number even for devices added via
87+
# Apple Configurator (purchaseSourceType=MANUALLY_ADDED). By default
88+
# we skip these because ABM emits a synthetic enrollment ID like
89+
# "CE-2024-12-13-04-11-12-826" and the enrollment date — not the real
90+
# purchase data — which would overwrite better data already in Snipe-IT.
91+
# sync_configurator_order_info: false
92+
8193
# Custom field mappings: Snipe-IT field name -> ABM attribute
8294
# Left side: Snipe-IT DB column name (e.g. _snipeit_fieldname_1)
8395
# or standard Snipe-IT field (purchase_date, order_number)

sync/sync.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,7 @@ func (e *Engine) updateAsset(ctx context.Context, logger *logrus.Entry, device a
821821

822822
e.applyFieldMapping(&desired, device, coverage)
823823
applyWarrantyNotes(&desired, coverage)
824+
stripOrderInfoOnUpdate(&desired, existing, e.cfg.Sync.PreserveOrderInfoOnUpdate)
824825

825826
// Unless force mode, compare desired values against current Snipe-IT values
826827
// and only send fields that are missing or different.
@@ -883,9 +884,18 @@ func (e *Engine) diffAsset(desired *snipeit.Asset, existing *snipeit.Asset) *sni
883884
}
884885

885886
// Compare native order info fields.
886-
if desired.PurchaseDate != nil && !desired.PurchaseDate.IsZero() {
887-
if existing.PurchaseDate == nil || !desired.PurchaseDate.Equal(existing.PurchaseDate.Time) {
888-
diff.PurchaseDate = desired.PurchaseDate
887+
// purchase_date is held in CustomFields as a workaround for upstream
888+
// SnipeTime.MarshalJSON (see applyFieldMapping). Compare the desired
889+
// date string against the existing asset's native PurchaseDate field
890+
// returned by GET, since Snipe-IT echoes purchase_date natively, not
891+
// in CustomFields.
892+
if desiredDate, ok := desired.CustomFields["purchase_date"]; ok {
893+
existingDate := ""
894+
if existing.PurchaseDate != nil && !existing.PurchaseDate.IsZero() {
895+
existingDate = existing.PurchaseDate.Format("2006-01-02")
896+
}
897+
if desiredDate != existingDate {
898+
diff.CustomFields["purchase_date"] = desiredDate
889899
hasChanges = true
890900
}
891901
}
@@ -908,6 +918,12 @@ func (e *Engine) diffAsset(desired *snipeit.Asset, existing *snipeit.Asset) *sni
908918
// (htmlspecialchars) in its API transformer, and BOOLEAN fields are stored
909919
// as "0"/"1" while we write "false"/"true". Normalize both before comparing.
910920
for key, desiredVal := range desired.CustomFields {
921+
// purchase_date is compared explicitly above against the native
922+
// existing.PurchaseDate field, not against existing.CustomFields
923+
// (which is always empty for this key on GET responses).
924+
if key == "purchase_date" {
925+
continue
926+
}
911927
currentVal := html.UnescapeString(existing.CustomFields[key])
912928
if normalizeBoolStr(currentVal) != normalizeBoolStr(desiredVal) {
913929
diff.CustomFields[key] = desiredVal
@@ -956,10 +972,22 @@ func (e *Engine) applyFieldMapping(asset *snipeit.Asset, device abmclient.Device
956972
case "producttype", "product_type":
957973
value = attrs.ProductType
958974
case "ordernumber", "order_number":
975+
// Skip Configurator-enrolled devices: ABM emits a synthetic
976+
// "CE-YYYY-MM-DD-HH-MM-SS-XXX" enrollment ID, not the real
977+
// order number. The sync.sync_configurator_order_info flag
978+
// is an opt-in escape hatch.
979+
if skipConfiguratorOrderInfo(e.cfg, attrs) {
980+
break
981+
}
959982
if attrs.OrderNumber != "" {
960983
value = cleanOrderNumber(attrs.OrderNumber)
961984
}
962985
case "orderdate", "order_date":
986+
// Same reasoning as order_number above — ABM emits the
987+
// Configurator enrollment date, not the actual purchase date.
988+
if skipConfiguratorOrderInfo(e.cfg, attrs) {
989+
break
990+
}
963991
if !attrs.OrderDateTime.IsZero() {
964992
value = attrs.OrderDateTime.Format("2006-01-02")
965993
}
@@ -1047,9 +1075,15 @@ func (e *Engine) applyFieldMapping(asset *snipeit.Asset, device abmclient.Device
10471075
case "order_number":
10481076
asset.OrderNumber = value
10491077
case "purchase_date":
1050-
if t, err := time.Parse("2006-01-02", value); err == nil {
1051-
asset.PurchaseDate = &snipeit.SnipeTime{Time: t}
1052-
}
1078+
// Snipe-IT's purchase_date validator requires YYYY-MM-DD, but
1079+
// upstream go-snipeit's SnipeTime.MarshalJSON unconditionally
1080+
// emits "YYYY-MM-DD HH:MM:SS" (datetime). Bypass by writing the
1081+
// date-only string to CustomFields: Asset.MarshalJSON flattens
1082+
// CustomFields to top-level keys *after* the native PurchaseDate
1083+
// line, so the plain string overrides the bad serialization.
1084+
// Snipe-IT routes the "purchase_date" key to its native column
1085+
// regardless. TODO: remove once upstream serializes date-only.
1086+
asset.CustomFields[snipeField] = value
10531087
default:
10541088
asset.CustomFields[snipeField] = value
10551089
}
@@ -1183,6 +1217,36 @@ func normalizeBoolStr(s string) string {
11831217

11841218
// cleanOrderNumber extracts the middle segment from CDW-style order numbers
11851219
// like "CDW/1CJ6QLW/002" → "1CJ6QLW". Other formats are returned as-is.
1220+
// skipConfiguratorOrderInfo reports whether order_date / order_number should
1221+
// be skipped for a device because ABM's values for it aren't real purchase
1222+
// data. A device added to ABM via Apple Configurator
1223+
// (purchaseSourceType=MANUALLY_ADDED) gets a synthetic order number like
1224+
// "CE-2024-12-13-04-11-12-826" and an order date equal to the enrollment
1225+
// time. Syncing those would overwrite better data already in Snipe-IT.
1226+
// The sync.sync_configurator_order_info flag is an opt-in escape hatch.
1227+
func skipConfiguratorOrderInfo(cfg *config.Config, attrs *abm.OrgDeviceAttributes) bool {
1228+
if cfg.Sync.SyncConfiguratorOrderInfo {
1229+
return false
1230+
}
1231+
return string(attrs.PurchaseSourceType) == "MANUALLY_ADDED"
1232+
}
1233+
1234+
// stripOrderInfoOnUpdate drops the order info from the desired asset when
1235+
// the sync.preserve_order_info_on_update flag is on and the existing asset
1236+
// already has values, so axm2snipe never overwrites order info on update.
1237+
// First-time syncs (existing has no value) still go through.
1238+
func stripOrderInfoOnUpdate(desired, existing *snipeit.Asset, preserve bool) {
1239+
if !preserve {
1240+
return
1241+
}
1242+
if existing.OrderNumber != "" {
1243+
desired.OrderNumber = ""
1244+
}
1245+
if existing.PurchaseDate != nil && !existing.PurchaseDate.IsZero() {
1246+
delete(desired.CustomFields, "purchase_date")
1247+
}
1248+
}
1249+
11861250
func cleanOrderNumber(order string) string {
11871251
parts := strings.Split(order, "/")
11881252
if len(parts) == 3 {
@@ -1329,9 +1393,6 @@ func formatAssetDiff(a *snipeit.Asset) map[string]any {
13291393
if a.OrderNumber != "" {
13301394
m["order_number"] = a.OrderNumber
13311395
}
1332-
if a.PurchaseDate != nil && !a.PurchaseDate.IsZero() {
1333-
m["purchase_date"] = a.PurchaseDate.Format("2006-01-02")
1334-
}
13351396
for k, v := range a.CustomFields {
13361397
m[k] = v
13371398
}

0 commit comments

Comments
 (0)