Files
descrybe/apps/api/internal/catalog/import_csv.go
T
2026-08-23 20:49:40 +02:00

844 lines
23 KiB
Go

package catalog
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/google/uuid"
)
const (
importCSVBatchSize = 500
importCSVMaxErrors = 50
)
type ImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
func (r *ImportResult) addError(msg string) {
if len(r.Errors) >= importCSVMaxErrors {
return
}
r.Errors = append(r.Errors, msg)
}
func headerIndex(headers []string, names ...string) int {
want := map[string]struct{}{}
for _, n := range names {
want[strings.ToLower(strings.TrimSpace(n))] = struct{}{}
}
for i, h := range headers {
if _, ok := want[strings.ToLower(strings.TrimSpace(h))]; ok {
return i
}
}
return -1
}
func cell(row []string, idx int) string {
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
type categoryCSVRow struct {
name string
uniqueID string
parent *string
desc *string
}
type categoryPathInfo struct {
id uuid.UUID
path string
level int
}
func (s *Service) ImportCategoriesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
res := ImportResult{}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iName := headerIndex(headers, "name")
iUID := headerIndex(headers, "unique_id", "id", "category_id")
iParent := headerIndex(headers, "parent_unique_id", "parent_id", "parent")
iDesc := headerIndex(headers, "description")
if iName < 0 || iUID < 0 {
return res, ClientMsg("CSV must include name and unique_id columns")
}
known := map[string]categoryPathInfo{}
batch := make([]categoryCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
name := cell(row, iName)
uid := cell(row, iUID)
if name == "" || uid == "" {
res.Skipped++
continue
}
var parent *string
if p := cell(row, iParent); p != "" {
parent = &p
}
var desc *string
if d := cell(row, iDesc); d != "" {
desc = &d
}
batch = append(batch, categoryCSVRow{name: name, uniqueID: uid, parent: parent, desc: desc})
if len(batch) >= importCSVBatchSize {
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushCategoryBatch(ctx context.Context, companyID uuid.UUID, batch []categoryCSVRow, known map[string]categoryPathInfo, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
byUID := make(map[string]categoryCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byUID[row.uniqueID]; !ok {
order = append(order, row.uniqueID)
}
byUID[row.uniqueID] = row
}
lookup := make([]string, 0, len(byUID)*2)
seenLookup := map[string]struct{}{}
addLookup := func(uid string) {
if uid == "" {
return
}
if _, ok := known[uid]; ok {
return
}
if _, ok := seenLookup[uid]; ok {
return
}
seenLookup[uid] = struct{}{}
lookup = append(lookup, uid)
}
for _, row := range byUID {
addLookup(row.uniqueID)
if row.parent != nil {
addLookup(*row.parent)
}
}
if err := s.loadCategoryPaths(ctx, companyID, lookup, known); err != nil {
return err
}
updIDs := make([]uuid.UUID, 0, len(order))
updNames := make([]string, 0, len(order))
updDescs := make([]string, 0, len(order))
pendingInserts := make([]categoryCSVRow, 0, len(order))
for _, uid := range order {
row := byUID[uid]
if info, ok := known[uid]; ok {
updIDs = append(updIDs, info.id)
updNames = append(updNames, row.name)
updDescs = append(updDescs, deref(row.desc))
continue
}
pendingInserts = append(pendingInserts, row)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if len(updIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE categories AS c SET
name = v.name,
description = CASE WHEN v.description <> '' THEN v.description ELSE c.description END,
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, description)
WHERE c.id = v.id AND c.company_id = $1`,
companyID, updIDs, updNames, updDescs)
if err != nil {
return err
}
res.Updated += len(updIDs)
}
for len(pendingInserts) > 0 {
insNames := make([]string, 0, len(pendingInserts))
insUIDs := make([]string, 0, len(pendingInserts))
insParents := make([]string, 0, len(pendingInserts))
insDescs := make([]string, 0, len(pendingInserts))
insPaths := make([]string, 0, len(pendingInserts))
insLevels := make([]int32, 0, len(pendingInserts))
next := make([]categoryCSVRow, 0, len(pendingInserts))
for _, row := range pendingInserts {
path, level, parentVal, err := resolveCategoryPath(row.uniqueID, row.parent, known)
if err != nil {
next = append(next, row)
continue
}
insNames = append(insNames, row.name)
insUIDs = append(insUIDs, row.uniqueID)
insParents = append(insParents, parentVal)
insDescs = append(insDescs, deref(row.desc))
insPaths = append(insPaths, path)
insLevels = append(insLevels, int32(level))
}
if len(insUIDs) == 0 {
for _, row := range next {
res.Skipped++
res.addError(fmt.Sprintf("%s: parent category not found", row.uniqueID))
}
break
}
rows, err := tx.Query(ctx, `
INSERT INTO categories (company_id, name, unique_id, parent_unique_id, description, path, level)
SELECT $1, v.name, v.unique_id, NULLIF(v.parent_unique_id, ''), NULLIF(v.description, ''), v.path, v.level
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::int[])
AS v(name, unique_id, parent_unique_id, description, path, level)
RETURNING id, unique_id, COALESCE(path, ''), level`,
companyID, insNames, insUIDs, insParents, insDescs, insPaths, insLevels)
if err != nil {
return err
}
nameByUID := make(map[string]string, len(insUIDs))
for i, uid := range insUIDs {
if i < len(insNames) {
nameByUID[uid] = insNames[i]
}
}
var seedFormulas []DefaultFormulaTarget
for rows.Next() {
var id uuid.UUID
var uid, path string
var level int
if err := rows.Scan(&id, &uid, &path, &level); err != nil {
rows.Close()
return err
}
known[uid] = categoryPathInfo{id: id, path: path, level: level}
seedFormulas = append(seedFormulas, DefaultFormulaTarget{ID: id, UniqueID: uid, Name: nameByUID[uid]})
res.Created++
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
// Imported categories get the platform-default formula too, so an imported
// taxonomy enhances into formula-shaped copy without further setup.
if err := ApplyDefaultCategoryFormulas(ctx, tx, companyID, seedFormulas...); err != nil {
return err
}
pendingInserts = next
}
return tx.Commit(ctx)
}
func (s *Service) loadCategoryPaths(ctx context.Context, companyID uuid.UUID, uids []string, known map[string]categoryPathInfo) error {
if len(uids) == 0 {
return nil
}
rows, err := s.Pool.Query(ctx, `
SELECT id, unique_id, COALESCE(path, ''), level
FROM categories
WHERE company_id = $1 AND unique_id = ANY($2)`, companyID, uids)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var info categoryPathInfo
var uid string
if err := rows.Scan(&info.id, &uid, &info.path, &info.level); err != nil {
return err
}
known[uid] = info
}
return rows.Err()
}
func resolveCategoryPath(uniqueID string, parent *string, known map[string]categoryPathInfo) (path string, level int, parentVal string, err error) {
path = uniqueID
level = 0
if parent == nil || strings.TrimSpace(*parent) == "" {
return path, level, "", nil
}
p := strings.TrimSpace(*parent)
pinfo, ok := known[p]
if !ok {
return "", 0, "", errors.New("parent category not found")
}
if pinfo.path != "" {
path = pinfo.path + "/" + uniqueID
} else {
path = p + "/" + uniqueID
}
return path, pinfo.level + 1, p, nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
type attributeCSVRow struct {
key, name, valueType string
unit, example, parent *string
}
func (s *Service) ImportAttributesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
res := ImportResult{}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iKey := headerIndex(headers, "attribute_key", "key")
iName := headerIndex(headers, "name")
iType := headerIndex(headers, "value_type", "type")
iUnit := headerIndex(headers, "unit")
iExample := headerIndex(headers, "example")
iParent := headerIndex(headers, "parent_key", "parent")
if iKey < 0 || iName < 0 {
return res, ClientMsg("CSV must include attribute_key and name columns")
}
batch := make([]attributeCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
key := cell(row, iKey)
name := cell(row, iName)
if key == "" || name == "" {
res.Skipped++
continue
}
valueType := cell(row, iType)
if valueType == "" {
valueType = "string"
}
var unit, example, parent *string
if u := cell(row, iUnit); u != "" {
unit = &u
}
if e := cell(row, iExample); e != "" {
example = &e
}
if p := cell(row, iParent); p != "" {
parent = &p
}
batch = append(batch, attributeCSVRow{
key: key, name: name, valueType: valueType,
unit: unit, example: example, parent: parent,
})
if len(batch) >= importCSVBatchSize {
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushAttributeBatch(ctx context.Context, companyID uuid.UUID, batch []attributeCSVRow, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
byKey := make(map[string]attributeCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byKey[row.key]; !ok {
order = append(order, row.key)
}
byKey[row.key] = row
}
keys := make([]string, 0, len(byKey))
keys = append(keys, order...)
existing := map[string]uuid.UUID{}
rows, err := s.Pool.Query(ctx, `
SELECT id, attribute_key FROM attributes
WHERE company_id = $1 AND attribute_key = ANY($2)`, companyID, keys)
if err != nil {
return err
}
for rows.Next() {
var id uuid.UUID
var key string
if err := rows.Scan(&id, &key); err != nil {
rows.Close()
return err
}
existing[key] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
updIDs := make([]uuid.UUID, 0, len(order))
updNames := make([]string, 0, len(order))
updTypes := make([]string, 0, len(order))
insKeys := make([]string, 0, len(order))
insNames := make([]string, 0, len(order))
insTypes := make([]string, 0, len(order))
insUnits := make([]string, 0, len(order))
insExamples := make([]string, 0, len(order))
insParents := make([]string, 0, len(order))
for _, key := range order {
row := byKey[key]
if id, ok := existing[key]; ok {
updIDs = append(updIDs, id)
updNames = append(updNames, row.name)
updTypes = append(updTypes, row.valueType)
continue
}
insKeys = append(insKeys, key)
insNames = append(insNames, row.name)
insTypes = append(insTypes, row.valueType)
insUnits = append(insUnits, deref(row.unit))
insExamples = append(insExamples, deref(row.example))
insParents = append(insParents, deref(row.parent))
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if len(updIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE attributes AS a SET
name = CASE WHEN v.name <> '' THEN v.name ELSE a.name END,
value_type = CASE WHEN v.value_type <> '' THEN v.value_type ELSE a.value_type END,
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, value_type)
WHERE a.id = v.id AND a.company_id = $1`,
companyID, updIDs, updNames, updTypes)
if err != nil {
return err
}
res.Updated += len(updIDs)
}
if len(insKeys) > 0 {
ct, err := tx.Exec(ctx, `
INSERT INTO attributes (company_id, attribute_key, name, value_type, unit, example, parent_key)
SELECT $1, v.attribute_key, v.name, v.value_type,
NULLIF(v.unit, ''), NULLIF(v.example, ''), NULLIF(v.parent_key, '')
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(attribute_key, name, value_type, unit, example, parent_key)`,
companyID, insKeys, insNames, insTypes, insUnits, insExamples, insParents)
if err != nil {
return err
}
res.Created += int(ct.RowsAffected())
}
return tx.Commit(ctx)
}
func (s *Service) MergeProductsByGTIN(ctx context.Context, companyID uuid.UUID) (bool, error) {
var merge bool
err := s.Pool.QueryRow(ctx, `SELECT merge_products_by_gtin FROM companies WHERE id = $1`, companyID).Scan(&merge)
return merge, err
}
type productCSVRow struct {
gtin, productID, name, category, desc, status string
rawJSON string
mergeable bool
}
func (s *Service) ImportProductsCSV(ctx context.Context, companyID uuid.UUID, r io.Reader, fileID *uuid.UUID) (ImportResult, error) {
res := ImportResult{}
merge, err := s.MergeProductsByGTIN(ctx, companyID)
if err != nil {
return res, err
}
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
headers, err := cr.Read()
if err != nil {
return res, ClientMsg("empty or invalid CSV")
}
iGTIN := headerIndex(headers, "gtin", "ean", "barcode")
iPID := headerIndex(headers, "product_id", "sku", "id")
iName := headerIndex(headers, "name", "title")
iCat := headerIndex(headers, "category")
iDesc := headerIndex(headers, "description")
iStatus := headerIndex(headers, "status")
if iName < 0 && iGTIN < 0 && iPID < 0 {
return res, ClientMsg("CSV must include name, gtin, or product_id")
}
batch := make([]productCSVRow, 0, importCSVBatchSize)
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
res.Skipped++
res.addError(err.Error())
continue
}
gtin := cell(row, iGTIN)
productID := cell(row, iPID)
name := cell(row, iName)
category := cell(row, iCat)
desc := cell(row, iDesc)
status := cell(row, iStatus)
if status == "" {
status = "draft"
}
if gtin == "" && productID == "" && name == "" {
res.Skipped++
continue
}
if gtin == "" {
gtin = "nogtin-" + uuid.NewString()
}
rawData, _ := json.Marshal(map[string]any{
"product_id": productID,
"name": name,
"category": category,
"description": desc,
"status": status,
"gtin": gtin,
})
batch = append(batch, productCSVRow{
gtin: gtin, productID: productID, name: name, category: category,
desc: desc, status: status, rawJSON: string(rawData),
mergeable: merge && !strings.HasPrefix(gtin, "nogtin-"),
})
if len(batch) >= importCSVBatchSize {
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
return res, err
}
batch = batch[:0]
}
}
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
return res, err
}
return res, nil
}
func (s *Service) flushProductBatch(ctx context.Context, companyID uuid.UUID, fileID *uuid.UUID, batch []productCSVRow, res *ImportResult) error {
if len(batch) == 0 {
return nil
}
// Last row wins per GTIN so a single INSERT cannot hit the same unique key twice.
byGTIN := make(map[string]productCSVRow, len(batch))
order := make([]string, 0, len(batch))
for _, row := range batch {
if _, ok := byGTIN[row.gtin]; !ok {
order = append(order, row.gtin)
}
byGTIN[row.gtin] = row
}
deduped := make([]productCSVRow, 0, len(order))
for _, gtin := range order {
deduped = append(deduped, byGTIN[gtin])
}
batch = deduped
mergeGTINs := make([]string, 0, len(batch))
for _, row := range batch {
if row.mergeable {
mergeGTINs = append(mergeGTINs, row.gtin)
}
}
existingRaw := map[string]uuid.UUID{}
if len(mergeGTINs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT DISTINCT ON (gtin) id, gtin
FROM raw_products
WHERE company_id = $1 AND gtin = ANY($2)
ORDER BY gtin, updated_at DESC`, companyID, mergeGTINs)
if err != nil {
return err
}
for rows.Next() {
var id uuid.UUID
var gtin string
if err := rows.Scan(&id, &gtin); err != nil {
rows.Close()
return err
}
existingRaw[gtin] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
}
type pendingProcessed struct {
rawID uuid.UUID
productID, name, category, desc, status string
rawWasUpdate bool
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
updRawIDs := make([]uuid.UUID, 0, len(batch))
updRawJSON := make([]string, 0, len(batch))
updRawMeta := make([]pendingProcessed, 0, len(batch))
insGTINs := make([]string, 0, len(batch))
insJSON := make([]string, 0, len(batch))
insMeta := make([]pendingProcessed, 0, len(batch))
for _, row := range batch {
meta := pendingProcessed{
productID: row.productID, name: row.name, category: row.category,
desc: row.desc, status: row.status,
}
if row.mergeable {
if id, ok := existingRaw[row.gtin]; ok {
updRawIDs = append(updRawIDs, id)
updRawJSON = append(updRawJSON, row.rawJSON)
meta.rawID = id
meta.rawWasUpdate = true
updRawMeta = append(updRawMeta, meta)
continue
}
}
insGTINs = append(insGTINs, row.gtin)
insJSON = append(insJSON, row.rawJSON)
insMeta = append(insMeta, meta)
}
if len(updRawIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE raw_products AS r SET
raw_data = v.raw_data::jsonb,
mapped_data = v.raw_data::jsonb,
file_id = COALESCE($3, r.file_id),
updated_at = now()
FROM unnest($2::uuid[], $4::text[]) AS v(id, raw_data)
WHERE r.id = v.id AND r.company_id = $1`,
companyID, updRawIDs, fileID, updRawJSON)
if err != nil {
return err
}
}
pending := make([]pendingProcessed, 0, len(batch))
pending = append(pending, updRawMeta...)
if len(insGTINs) > 0 {
rows, err := tx.Query(ctx, `
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, file_id)
SELECT $1, v.gtin, v.raw_data::jsonb, v.raw_data::jsonb, 'unprocessed', $2
FROM unnest($3::text[], $4::text[]) AS v(gtin, raw_data)
ON CONFLICT (company_id, gtin) DO NOTHING
RETURNING id, gtin`,
companyID, fileID, insGTINs, insJSON)
if err != nil {
return err
}
insertedByGTIN := map[string]uuid.UUID{}
for rows.Next() {
var id uuid.UUID
var gtin string
if err := rows.Scan(&id, &gtin); err != nil {
rows.Close()
return err
}
insertedByGTIN[gtin] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
// Match inserts back to input order; conflicts (DO NOTHING) are skipped.
for i, gtin := range insGTINs {
id, ok := insertedByGTIN[gtin]
if !ok {
res.Skipped++
res.addError(fmt.Sprintf("%s: duplicate gtin", gtin))
continue
}
meta := insMeta[i]
meta.rawID = id
pending = append(pending, meta)
// Same gtin inserted twice in one batch: second RETURNING miss.
delete(insertedByGTIN, gtin)
}
}
if len(pending) == 0 {
return tx.Commit(ctx)
}
rawIDs := make([]uuid.UUID, len(pending))
for i, p := range pending {
rawIDs[i] = p.rawID
}
existingProcessed := map[uuid.UUID]uuid.UUID{}
prows, err := tx.Query(ctx, `
SELECT DISTINCT ON (raw_product_id) id, raw_product_id
FROM processed_products
WHERE company_id = $1 AND raw_product_id = ANY($2)
ORDER BY raw_product_id, updated_at DESC`, companyID, rawIDs)
if err != nil {
return err
}
for prows.Next() {
var id, rawID uuid.UUID
if err := prows.Scan(&id, &rawID); err != nil {
prows.Close()
return err
}
existingProcessed[rawID] = id
}
err = prows.Err()
prows.Close()
if err != nil {
return err
}
updProcIDs := make([]uuid.UUID, 0, len(pending))
updPIDs := make([]string, 0, len(pending))
updNames := make([]string, 0, len(pending))
updCats := make([]string, 0, len(pending))
updDescs := make([]string, 0, len(pending))
updStatuses := make([]string, 0, len(pending))
insRawIDs := make([]uuid.UUID, 0, len(pending))
insPIDs := make([]string, 0, len(pending))
insNames := make([]string, 0, len(pending))
insCats := make([]string, 0, len(pending))
insDescs := make([]string, 0, len(pending))
insStatuses := make([]string, 0, len(pending))
insWasUpdate := make([]bool, 0, len(pending))
for _, p := range pending {
if pid, ok := existingProcessed[p.rawID]; ok {
updProcIDs = append(updProcIDs, pid)
updPIDs = append(updPIDs, p.productID)
updNames = append(updNames, p.name)
updCats = append(updCats, p.category)
updDescs = append(updDescs, p.desc)
updStatuses = append(updStatuses, p.status)
continue
}
insRawIDs = append(insRawIDs, p.rawID)
insPIDs = append(insPIDs, p.productID)
insNames = append(insNames, p.name)
insCats = append(insCats, p.category)
insDescs = append(insDescs, p.desc)
insStatuses = append(insStatuses, p.status)
insWasUpdate = append(insWasUpdate, p.rawWasUpdate)
}
if len(updProcIDs) > 0 {
_, err = tx.Exec(ctx, `
UPDATE processed_products AS p SET
product_id = COALESCE(NULLIF(v.product_id, ''), p.product_id),
name = COALESCE(NULLIF(v.name, ''), p.name),
category = COALESCE(NULLIF(v.category, ''), p.category),
description = COALESCE(NULLIF(v.description, ''), p.description),
status = COALESCE(NULLIF(v.status, ''), p.status),
updated_at = now()
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(id, product_id, name, category, description, status)
WHERE p.id = v.id AND p.company_id = $1`,
companyID, updProcIDs, updPIDs, updNames, updCats, updDescs, updStatuses)
if err != nil {
return err
}
res.Updated += len(updProcIDs)
}
if len(insRawIDs) > 0 {
_, err = tx.Exec(ctx, `
INSERT INTO processed_products (company_id, product_id, name, category, description, status, raw_product_id)
SELECT $1,
NULLIF(v.product_id, ''), NULLIF(v.name, ''), NULLIF(v.category, ''),
NULLIF(v.description, ''), v.status, v.raw_product_id
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
AS v(raw_product_id, product_id, name, category, description, status)`,
companyID, insRawIDs, insPIDs, insNames, insCats, insDescs, insStatuses)
if err != nil {
return err
}
for _, wasUpdate := range insWasUpdate {
if wasUpdate {
res.Updated++
} else {
res.Created++
}
}
}
return tx.Commit(ctx)
}