223 lines
5.6 KiB
Go
223 lines
5.6 KiB
Go
package feeds
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Reserved mapped_data key holding seller-facing change flags from the latest
|
||
|
|
// sync that modified the row. Cleared/replaced on the next content update.
|
||
|
|
const syncChangesMappedKey = "_sync_changes"
|
||
|
|
|
||
|
|
// Seller-high-value mapped field groups compared during feed upserts.
|
||
|
|
var (
|
||
|
|
syncPriceFields = []string{"price", "sale_price", "purchase_price"}
|
||
|
|
syncStockFields = []string{"stock", "quantity", "qty"}
|
||
|
|
syncAvailFields = []string{"availability", "stock_status", "in_stock"}
|
||
|
|
syncTitleFields = []string{"title", "name", "product_name"}
|
||
|
|
)
|
||
|
|
|
||
|
|
// syncDeltaCounts is the MVP seller summary written to input_feeds.options.last_sync_deltas.
|
||
|
|
type syncDeltaCounts struct {
|
||
|
|
JobID string `json:"job_id,omitempty"`
|
||
|
|
New int `json:"new"`
|
||
|
|
PriceChanged int `json:"price_changed"`
|
||
|
|
StockChanged int `json:"stock_changed"`
|
||
|
|
AvailabilityChanged int `json:"availability_changed"`
|
||
|
|
TitleChanged int `json:"title_changed"`
|
||
|
|
OtherChanged int `json:"other_changed"`
|
||
|
|
Unchanged int `json:"unchanged"`
|
||
|
|
Skipped int `json:"skipped"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d *syncDeltaCounts) addChanges(changes []string) {
|
||
|
|
if len(changes) == 0 {
|
||
|
|
d.OtherChanged++
|
||
|
|
return
|
||
|
|
}
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
for _, c := range changes {
|
||
|
|
if _, ok := seen[c]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[c] = struct{}{}
|
||
|
|
switch c {
|
||
|
|
case "new":
|
||
|
|
d.New++
|
||
|
|
case "price":
|
||
|
|
d.PriceChanged++
|
||
|
|
case "stock":
|
||
|
|
d.StockChanged++
|
||
|
|
case "availability":
|
||
|
|
d.AvailabilityChanged++
|
||
|
|
case "title":
|
||
|
|
d.TitleChanged++
|
||
|
|
default:
|
||
|
|
d.OtherChanged++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (d syncDeltaCounts) asMap() map[string]any {
|
||
|
|
return map[string]any{
|
||
|
|
"job_id": d.JobID,
|
||
|
|
"new": d.New,
|
||
|
|
"price_changed": d.PriceChanged,
|
||
|
|
"stock_changed": d.StockChanged,
|
||
|
|
"availability_changed": d.AvailabilityChanged,
|
||
|
|
"title_changed": d.TitleChanged,
|
||
|
|
"other_changed": d.OtherChanged,
|
||
|
|
"unchanged": d.Unchanged,
|
||
|
|
"skipped": d.Skipped,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mappedScalar(m map[string]any, key string) string {
|
||
|
|
if m == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
v, ok := m[key]
|
||
|
|
if !ok || v == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
switch t := v.(type) {
|
||
|
|
case string:
|
||
|
|
return strings.TrimSpace(t)
|
||
|
|
case float64:
|
||
|
|
return strings.TrimSpace(strings.TrimRight(strings.TrimRight(
|
||
|
|
strings.ReplaceAll(jsonNumber(t), "e+0", "e+"), "0"), "."))
|
||
|
|
case json.Number:
|
||
|
|
return strings.TrimSpace(t.String())
|
||
|
|
case bool:
|
||
|
|
if t {
|
||
|
|
return "true"
|
||
|
|
}
|
||
|
|
return "false"
|
||
|
|
default:
|
||
|
|
b, err := json.Marshal(t)
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return strings.TrimSpace(string(b))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func jsonNumber(f float64) string {
|
||
|
|
b, err := json.Marshal(f)
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return string(b)
|
||
|
|
}
|
||
|
|
|
||
|
|
func fieldGroupChanged(oldM, newM map[string]any, keys []string) bool {
|
||
|
|
for _, k := range keys {
|
||
|
|
if mappedScalar(oldM, k) != mappedScalar(newM, k) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// detectMappedChanges compares prior mapped_data JSON to the new mapped map.
|
||
|
|
// Returns change kind tags: price, stock, availability, title, other.
|
||
|
|
func detectMappedChanges(oldJSON []byte, newMapped map[string]any) []string {
|
||
|
|
var oldM map[string]any
|
||
|
|
if len(oldJSON) > 0 {
|
||
|
|
_ = json.Unmarshal(oldJSON, &oldM)
|
||
|
|
}
|
||
|
|
if oldM == nil {
|
||
|
|
oldM = map[string]any{}
|
||
|
|
}
|
||
|
|
cleanNew := stripSyncChanges(newMapped)
|
||
|
|
var out []string
|
||
|
|
if fieldGroupChanged(oldM, cleanNew, syncPriceFields) {
|
||
|
|
out = append(out, "price")
|
||
|
|
}
|
||
|
|
if fieldGroupChanged(oldM, cleanNew, syncStockFields) {
|
||
|
|
out = append(out, "stock")
|
||
|
|
}
|
||
|
|
if fieldGroupChanged(oldM, cleanNew, syncAvailFields) {
|
||
|
|
out = append(out, "availability")
|
||
|
|
}
|
||
|
|
if fieldGroupChanged(oldM, cleanNew, syncTitleFields) {
|
||
|
|
out = append(out, "title")
|
||
|
|
}
|
||
|
|
// Any other mapped key change (excluding reserved meta).
|
||
|
|
if len(out) == 0 && !mappedEqualIgnoringSyncMeta(oldM, cleanNew) {
|
||
|
|
out = append(out, "other")
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func stripSyncChanges(m map[string]any) map[string]any {
|
||
|
|
if m == nil {
|
||
|
|
return map[string]any{}
|
||
|
|
}
|
||
|
|
out := make(map[string]any, len(m))
|
||
|
|
for k, v := range m {
|
||
|
|
if k == syncChangesMappedKey {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out[k] = v
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// preserveSyncedExtras keeps fields that feed mappings never set (notably
|
||
|
|
// category unique_id from legacy assignment / seed backfill) when a sync
|
||
|
|
// remaps the row. Without this, upserts replace mapped_data wholesale and
|
||
|
|
// wipe category even though the feed has no category column.
|
||
|
|
func preserveSyncedExtras(existingJSON []byte, mapped map[string]any) map[string]any {
|
||
|
|
out := stripSyncChanges(mapped)
|
||
|
|
if len(existingJSON) == 0 {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
var old map[string]any
|
||
|
|
if err := json.Unmarshal(existingJSON, &old); err != nil || old == nil {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
old = stripSyncChanges(old)
|
||
|
|
if mappedScalar(out, "category") == "" {
|
||
|
|
if cat := mappedScalar(old, "category"); cat != "" && !strings.EqualFold(cat, "none") {
|
||
|
|
out["category"] = cat
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func mappedEqualIgnoringSyncMeta(a, b map[string]any) bool {
|
||
|
|
aa := stripSyncChanges(a)
|
||
|
|
bb := stripSyncChanges(b)
|
||
|
|
ab, err1 := json.Marshal(aa)
|
||
|
|
bb2, err2 := json.Marshal(bb)
|
||
|
|
if err1 != nil || err2 != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return bytesEqualJSON(ab, bb2)
|
||
|
|
}
|
||
|
|
|
||
|
|
func withSyncChanges(mapped map[string]any, changes []string) map[string]any {
|
||
|
|
out := stripSyncChanges(mapped)
|
||
|
|
if len(changes) == 0 {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
tags := make([]any, 0, len(changes))
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
for _, c := range changes {
|
||
|
|
c = strings.TrimSpace(strings.ToLower(c))
|
||
|
|
if c == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seen[c]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[c] = struct{}{}
|
||
|
|
tags = append(tags, c)
|
||
|
|
}
|
||
|
|
if len(tags) > 0 {
|
||
|
|
out[syncChangesMappedKey] = tags
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|