From 5be7315bdb2d8bfccee737d7b5150d3126032fd1 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Wed, 15 Apr 2026 15:54:23 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/queries/orderproduction_items.go | 746 +++++++++++++++--- svc/routes/orderproductionitems.go | 415 ++++++++-- ui/.quasar/dev-spa/client-entry.js | 4 +- ui/.quasar/prod-spa/app.js | 75 -- ui/.quasar/prod-spa/client-entry.js | 154 ---- ui/.quasar/prod-spa/client-prefetch.js | 116 --- ui/.quasar/prod-spa/quasar-user-options.js | 23 - ui/quasar.config.js | 9 +- ...g.js.temporary.compiled.1776255586414.mjs} | 9 +- ui/src/boot/resizeObserverGuard.js | 36 + ui/src/pages/OrderEntry.vue | 140 +++- ui/src/pages/OrderProductionUpdate.vue | 310 +++++--- ui/src/stores/OrderProductionItemStore.js | 105 ++- 13 files changed, 1412 insertions(+), 730 deletions(-) delete mode 100644 ui/.quasar/prod-spa/app.js delete mode 100644 ui/.quasar/prod-spa/client-entry.js delete mode 100644 ui/.quasar/prod-spa/client-prefetch.js delete mode 100644 ui/.quasar/prod-spa/quasar-user-options.js rename ui/{quasar.config.js.temporary.compiled.1776172100358.mjs => quasar.config.js.temporary.compiled.1776255586414.mjs} (95%) create mode 100644 ui/src/boot/resizeObserverGuard.js diff --git a/svc/queries/orderproduction_items.go b/svc/queries/orderproduction_items.go index b6e7181..0f538da 100644 --- a/svc/queries/orderproduction_items.go +++ b/svc/queries/orderproduction_items.go @@ -560,6 +560,8 @@ func UpdateOrderLinesTx(tx *sql.Tx, orderHeaderID string, lines []models.OrderPr query := fmt.Sprintf(` SET NOCOUNT ON; +DECLARE @updated TABLE (OrderLineID UNIQUEIDENTIFIER); + ;WITH src (OrderLineID, NewItemCode, NewColor, ItemDim1Code, NewDim2, NewDesc, OldDueDate, NewDueDate) AS ( SELECT * FROM (VALUES %s) AS v (OrderLineID, NewItemCode, NewColor, ItemDim1Code, NewDim2, NewDesc, OldDueDate, NewDueDate) @@ -574,27 +576,114 @@ SET l.DeliveryDate = CASE WHEN ISDATE(s.NewDueDate) = 1 THEN CAST(s.NewDueDate AS DATETIME) ELSE l.DeliveryDate END, l.LastUpdatedUserName = @p%d, l.LastUpdatedDate = GETDATE() +OUTPUT inserted.OrderLineID INTO @updated(OrderLineID) FROM dbo.trOrderLine l JOIN src s - ON CAST(l.OrderLineID AS NVARCHAR(50)) = s.OrderLineID -WHERE l.OrderHeaderID = @p%d; + ON l.OrderLineID = CONVERT(UNIQUEIDENTIFIER, s.OrderLineID) +WHERE l.OrderHeaderID = CONVERT(UNIQUEIDENTIFIER, @p%d); + +SELECT COUNT(1) AS UpdatedCount FROM @updated; `, strings.Join(values, ","), usernameParam, orderHeaderParam) chunkStart := time.Now() - res, execErr := tx.Exec(query, args...) + var chunkUpdated int64 + execErr := tx.QueryRow(query, args...).Scan(&chunkUpdated) if execErr != nil { log.Printf("[UpdateOrderLinesTx] ERROR orderHeaderID=%s chunk=%d-%d err=%v", orderHeaderID, i, end, execErr) return updated, fmt.Errorf("update lines chunk failed chunkStart=%d chunkEnd=%d duration_ms=%d: %w", i, end, time.Since(chunkStart).Milliseconds(), execErr) } - log.Printf("[UpdateOrderLinesTx] orderHeaderID=%s chunk=%d-%d duration_ms=%d", orderHeaderID, i, end, time.Since(chunkStart).Milliseconds()) - - if rows, rowsErr := res.RowsAffected(); rowsErr == nil { - updated += rows - } + log.Printf("[UpdateOrderLinesTx] orderHeaderID=%s chunk=%d-%d updated=%d duration_ms=%d", orderHeaderID, i, end, chunkUpdated, time.Since(chunkStart).Milliseconds()) + updated += chunkUpdated } return updated, nil } +func VerifyOrderLineUpdatesTx(tx *sql.Tx, orderHeaderID string, lines []models.OrderProductionUpdateLine) (int64, []string, error) { + if len(lines) == 0 { + return 0, nil, nil + } + + const chunkSize = 300 + var mismatchCount int64 + samples := make([]string, 0, 5) + + for i := 0; i < len(lines); i += chunkSize { + end := i + chunkSize + if end > len(lines) { + end = len(lines) + } + chunk := lines[i:end] + + values := make([]string, 0, len(chunk)) + args := make([]any, 0, len(chunk)*4+1) + paramPos := 1 + for _, line := range chunk { + values = append(values, fmt.Sprintf("(@p%d,@p%d,@p%d,@p%d)", paramPos, paramPos+1, paramPos+2, paramPos+3)) + args = append(args, + strings.TrimSpace(line.OrderLineID), + strings.ToUpper(strings.TrimSpace(line.NewItemCode)), + strings.ToUpper(strings.TrimSpace(line.NewColor)), + strings.ToUpper(strings.TrimSpace(line.NewDim2)), + ) + paramPos += 4 + } + orderHeaderParam := paramPos + args = append(args, orderHeaderID) + + query := fmt.Sprintf(` +SET NOCOUNT ON; + +WITH src (OrderLineID, NewItemCode, NewColor, NewDim2) AS ( + SELECT * + FROM (VALUES %s) v(OrderLineID, NewItemCode, NewColor, NewDim2) +) +SELECT + s.OrderLineID, + ISNULL(UPPER(LTRIM(RTRIM(l.ItemCode))), '') AS ActualItemCode, + ISNULL(UPPER(LTRIM(RTRIM(l.ColorCode))), '') AS ActualColorCode, + ISNULL(UPPER(LTRIM(RTRIM(l.ItemDim2Code))), '') AS ActualDim2Code, + s.NewItemCode, + s.NewColor, + s.NewDim2 +FROM src s +JOIN dbo.trOrderLine l + ON l.OrderLineID = CONVERT(UNIQUEIDENTIFIER, s.OrderLineID) +WHERE l.OrderHeaderID = CONVERT(UNIQUEIDENTIFIER, @p%d) + AND ( + ISNULL(UPPER(LTRIM(RTRIM(l.ItemCode))), '') <> s.NewItemCode OR + ISNULL(UPPER(LTRIM(RTRIM(l.ColorCode))), '') <> s.NewColor OR + ISNULL(UPPER(LTRIM(RTRIM(l.ItemDim2Code))), '') <> s.NewDim2 + ); +`, strings.Join(values, ","), orderHeaderParam) + + rows, err := tx.Query(query, args...) + if err != nil { + return mismatchCount, samples, err + } + for rows.Next() { + var lineID, actualItem, actualColor, actualDim2, expectedItem, expectedColor, expectedDim2 string + if err := rows.Scan(&lineID, &actualItem, &actualColor, &actualDim2, &expectedItem, &expectedColor, &expectedDim2); err != nil { + rows.Close() + return mismatchCount, samples, err + } + mismatchCount++ + if len(samples) < 5 { + samples = append(samples, fmt.Sprintf( + "lineID=%s expected=(%s,%s,%s) actual=(%s,%s,%s)", + lineID, expectedItem, expectedColor, expectedDim2, actualItem, actualColor, actualDim2, + )) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return mismatchCount, samples, err + } + rows.Close() + } + + return mismatchCount, samples, nil +} + func UpdateOrderHeaderAverageDueDateTx(tx *sql.Tx, orderHeaderID string, averageDueDate *string, username string) error { if averageDueDate == nil { return nil @@ -618,6 +707,24 @@ WHERE OrderHeaderID = @p3; return err } +func TouchOrderHeaderTx(tx *sql.Tx, orderHeaderID string, username string) (int64, error) { + res, err := tx.Exec(` +UPDATE dbo.trOrderHeader +SET + LastUpdatedUserName = @p1, + LastUpdatedDate = GETDATE() +WHERE OrderHeaderID = @p2; +`, username, orderHeaderID) + if err != nil { + return 0, err + } + rows, rowsErr := res.RowsAffected() + if rowsErr != nil { + return 0, nil + } + return rows, nil +} + type sqlQueryRower interface { QueryRow(query string, args ...any) *sql.Row } @@ -849,83 +956,10 @@ func InsertItemBarcodesTx(tx *sql.Tx, orderHeaderID string, lines []models.Order return 0, nil } - const chunkSize = 900 var inserted int64 - for i := 0; i < len(lineIDs); i += chunkSize { - - end := i + chunkSize - if end > len(lineIDs) { - end = len(lineIDs) - } - - chunk := lineIDs[i:end] - - values := make([]string, 0, len(chunk)) - args := make([]any, 0, len(chunk)+2) - - paramPos := 1 - - for _, lineID := range chunk { - values = append(values, fmt.Sprintf("(@p%d)", paramPos)) - args = append(args, lineID) - paramPos++ - } - - orderHeaderParam := paramPos - usernameParam := paramPos + 1 - - args = append(args, orderHeaderID, username) - - query := fmt.Sprintf(` -SET NOCOUNT ON -; - -WITH srcLine (OrderLineID) AS ( - SELECT * - FROM (VALUES %s) v(OrderLineID) -), - -src AS ( - SELECT DISTINCT - l.ItemTypeCode, - UPPER(LTRIM(RTRIM(ISNULL(l.ItemCode,'')))) ItemCode, - UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode,'')))) ColorCode, - UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim1Code,'')))) ItemDim1Code, - UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code,'')))) ItemDim2Code, - CAST('' AS NVARCHAR(50)) ItemDim3Code - FROM dbo.trOrderLine l - JOIN srcLine s - ON CAST(l.OrderLineID AS NVARCHAR(50)) = s.OrderLineID - WHERE l.OrderHeaderID = @p%d - AND NULLIF(LTRIM(RTRIM(ISNULL(l.ItemCode,''))), '') IS NOT NULL -), - -missing AS ( - SELECT - s.*, - ROW_NUMBER() OVER ( - ORDER BY - s.ItemCode, - s.ColorCode, - s.ItemDim1Code, - s.ItemDim2Code, - s.ItemDim3Code - ) RowNo - FROM src s - WHERE NOT EXISTS ( - SELECT 1 - FROM dbo.prItemBarcode b - WHERE b.BarcodeTypeCode = 'BAGGI3' - AND b.UnitOfMeasureCode = 'AD' - AND b.ItemTypeCode = s.ItemTypeCode - AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemCode,'')))) = s.ItemCode - AND UPPER(LTRIM(RTRIM(ISNULL(b.ColorCode,'')))) = s.ColorCode - AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim1Code,'')))) = s.ItemDim1Code - AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim2Code,'')))) = s.ItemDim2Code - AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim3Code,'')))) = s.ItemDim3Code - ) -) + singleLineQuery := ` +SET NOCOUNT ON; INSERT INTO dbo.prItemBarcode ( @@ -945,16 +979,147 @@ INSERT INTO dbo.prItemBarcode LastUpdatedDate, RowGuid ) - SELECT - CAST(seed.MaxBarcode + m.RowNo AS NVARCHAR(50)), + CAST(seed.MaxBarcode + 1 AS NVARCHAR(50)), 'BAGGI3', - m.ItemTypeCode, - m.ItemCode, - m.ColorCode, - m.ItemDim1Code, - m.ItemDim2Code, - m.ItemDim3Code, + src.ItemTypeCode, + src.ItemCode, + src.ColorCode, + src.ItemDim1Code, + src.ItemDim2Code, + src.ItemDim3Code, + 'AD', + 1, + @p3, + GETDATE(), + @p3, + GETDATE(), + NEWID() +FROM ( + SELECT DISTINCT + l.ItemTypeCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemCode,'')))) AS ItemCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode,'')))) AS ColorCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim1Code,'')))) AS ItemDim1Code, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code,'')))) AS ItemDim2Code, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim3Code,'')))) AS ItemDim3Code + FROM dbo.trOrderLine l + WHERE l.OrderHeaderID = @p2 + AND CAST(l.OrderLineID AS NVARCHAR(50)) = @p1 + AND NULLIF(LTRIM(RTRIM(ISNULL(l.ItemCode,''))), '') IS NOT NULL +) src +CROSS JOIN ( + SELECT + CASE + WHEN ISNULL(MAX( + CASE + WHEN LTRIM(RTRIM(ISNULL(Barcode,''))) NOT LIKE '%%[^0-9]%%' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(Barcode,''))) AS BIGINT) + ELSE NULL + END + ), 0) < 36999999 + THEN 36999999 + ELSE ISNULL(MAX( + CASE + WHEN LTRIM(RTRIM(ISNULL(Barcode,''))) NOT LIKE '%%[^0-9]%%' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(Barcode,''))) AS BIGINT) + ELSE NULL + END + ), 0) + END AS MaxBarcode + FROM dbo.prItemBarcode + WHERE BarcodeTypeCode = 'BAGGI3' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) <= 8 +) seed +WHERE NOT EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode b + WHERE b.ItemTypeCode = src.ItemTypeCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemCode,'')))) = src.ItemCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ColorCode,'')))) = src.ColorCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim1Code,'')))) = src.ItemDim1Code + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim2Code,'')))) = src.ItemDim2Code + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim3Code,'')))) = src.ItemDim3Code +); +` + + execSingle := func(globalIndex int, lineID string) error { + lineStart := time.Now() + res, err := tx.Exec(singleLineQuery, lineID, orderHeaderID, username) + if err != nil { + if isDuplicateBarcodeInsertErr(err) { + log.Printf("[InsertItemBarcodesTx] skip duplicate lineIndex=%d lineID=%s err=%v", globalIndex, lineID, err) + return nil + } + return fmt.Errorf("upsert item barcodes chunk failed chunkStart=%d chunkEnd=%d duration_ms=%d: %w", globalIndex, globalIndex+1, time.Since(lineStart).Milliseconds(), err) + } + rows, _ := res.RowsAffected() + inserted += rows + log.Printf( + "[InsertItemBarcodesTx] lineIndex=%d lineID=%s inserted=%d cumulative=%d duration_ms=%d", + globalIndex, + lineID, + rows, + inserted, + time.Since(lineStart).Milliseconds(), + ) + return nil + } + + const chunkSize = 200 + for i := 0; i < len(lineIDs); i += chunkSize { + end := i + chunkSize + if end > len(lineIDs) { + end = len(lineIDs) + } + + chunk := lineIDs[i:end] + values := make([]string, 0, len(chunk)) + args := make([]any, 0, len(chunk)+2) + paramPos := 1 + for _, lineID := range chunk { + values = append(values, fmt.Sprintf("(@p%d)", paramPos)) + args = append(args, lineID) + paramPos++ + } + orderHeaderParam := paramPos + usernameParam := paramPos + 1 + args = append(args, orderHeaderID, username) + + batchQuery := fmt.Sprintf(` +SET NOCOUNT ON; + +INSERT INTO dbo.prItemBarcode +( + Barcode, + BarcodeTypeCode, + ItemTypeCode, + ItemCode, + ColorCode, + ItemDim1Code, + ItemDim2Code, + ItemDim3Code, + UnitOfMeasureCode, + Qty, + CreatedUserName, + CreatedDate, + LastUpdatedUserName, + LastUpdatedDate, + RowGuid +) +SELECT + CAST(seed.MaxBarcode + ROW_NUMBER() OVER ( + ORDER BY src.ItemTypeCode, src.ItemCode, src.ColorCode, src.ItemDim1Code, src.ItemDim2Code, src.ItemDim3Code + ) AS NVARCHAR(50)), + 'BAGGI3', + src.ItemTypeCode, + src.ItemCode, + src.ColorCode, + src.ItemDim1Code, + src.ItemDim2Code, + src.ItemDim3Code, 'AD', 1, @p%d, @@ -962,42 +1127,80 @@ SELECT @p%d, GETDATE(), NEWID() - -FROM missing m - +FROM ( + SELECT DISTINCT + l.ItemTypeCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemCode,'')))) AS ItemCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode,'')))) AS ColorCode, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim1Code,'')))) AS ItemDim1Code, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code,'')))) AS ItemDim2Code, + UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim3Code,'')))) AS ItemDim3Code + FROM dbo.trOrderLine l + JOIN (VALUES %s) ids(OrderLineID) + ON CAST(l.OrderLineID AS NVARCHAR(50)) = ids.OrderLineID + WHERE l.OrderHeaderID = @p%d + AND NULLIF(LTRIM(RTRIM(ISNULL(l.ItemCode,''))), '') IS NOT NULL +) src CROSS JOIN ( - SELECT ISNULL(MAX( + SELECT CASE - WHEN ISNUMERIC(Barcode)=1 - THEN CAST(Barcode AS BIGINT) - ELSE NULL - END - ),0) AS MaxBarcode + WHEN ISNULL(MAX( + CASE + WHEN LTRIM(RTRIM(ISNULL(Barcode,''))) NOT LIKE '%%[^0-9]%%' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(Barcode,''))) AS BIGINT) + ELSE NULL + END + ), 0) < 36999999 + THEN 36999999 + ELSE ISNULL(MAX( + CASE + WHEN LTRIM(RTRIM(ISNULL(Barcode,''))) NOT LIKE '%%[^0-9]%%' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(Barcode,''))) AS BIGINT) + ELSE NULL + END + ), 0) + END AS MaxBarcode FROM dbo.prItemBarcode + WHERE BarcodeTypeCode = 'BAGGI3' + AND LEN(LTRIM(RTRIM(ISNULL(Barcode,'')))) <= 8 ) seed -`, strings.Join(values, ","), orderHeaderParam, usernameParam, usernameParam) +WHERE NOT EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode b + WHERE b.ItemTypeCode = src.ItemTypeCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemCode,'')))) = src.ItemCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ColorCode,'')))) = src.ColorCode + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim1Code,'')))) = src.ItemDim1Code + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim2Code,'')))) = src.ItemDim2Code + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim3Code,'')))) = src.ItemDim3Code +); +`, usernameParam, usernameParam, strings.Join(values, ","), orderHeaderParam) chunkStart := time.Now() - - res, err := tx.Exec(query, args...) - - if err != nil { - log.Printf("[InsertItemBarcodesTx] ERROR chunk=%d-%d err=%v", i, end, err) - return inserted, err + res, err := tx.Exec(batchQuery, args...) + if err == nil { + rows, _ := res.RowsAffected() + inserted += rows + log.Printf( + "[InsertItemBarcodesTx] batch=%d-%d inserted=%d cumulative=%d duration_ms=%d", + i, + end, + rows, + inserted, + time.Since(chunkStart).Milliseconds(), + ) + continue } - rows, _ := res.RowsAffected() - - inserted += rows - - log.Printf( - "[InsertItemBarcodesTx] chunk=%d-%d inserted=%d cumulative=%d duration_ms=%d", - i, - end, - rows, - inserted, - time.Since(chunkStart).Milliseconds(), - ) + log.Printf("[InsertItemBarcodesTx] batch fallback=%d-%d err=%v", i, end, err) + for j, lineID := range chunk { + if lineErr := execSingle(i+j, lineID); lineErr != nil { + log.Printf("[InsertItemBarcodesTx] ERROR lineIndex=%d lineID=%s err=%v", i+j, lineID, lineErr) + return inserted, lineErr + } + } } log.Printf( @@ -1011,6 +1214,289 @@ CROSS JOIN ( return inserted, nil } +func InsertItemBarcodesByTargetsTx(tx *sql.Tx, targets []models.OrderProductionMissingVariant, username string) (int64, error) { + start := time.Now() + if len(targets) == 0 { + log.Printf("[InsertItemBarcodesByTargetsTx] targets=0 inserted=0 duration_ms=0") + return 0, nil + } + + uniqueTargets := make([]models.OrderProductionMissingVariant, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, t := range targets { + itemCode := strings.ToUpper(strings.TrimSpace(t.ItemCode)) + if itemCode == "" { + continue + } + key := fmt.Sprintf("%d|%s|%s|%s|%s|%s", + t.ItemTypeCode, + itemCode, + strings.ToUpper(strings.TrimSpace(t.ColorCode)), + strings.ToUpper(strings.TrimSpace(t.ItemDim1Code)), + strings.ToUpper(strings.TrimSpace(t.ItemDim2Code)), + strings.ToUpper(strings.TrimSpace(t.ItemDim3Code)), + ) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + t.ItemCode = itemCode + t.ColorCode = strings.ToUpper(strings.TrimSpace(t.ColorCode)) + t.ItemDim1Code = strings.ToUpper(strings.TrimSpace(t.ItemDim1Code)) + t.ItemDim2Code = strings.ToUpper(strings.TrimSpace(t.ItemDim2Code)) + t.ItemDim3Code = strings.ToUpper(strings.TrimSpace(t.ItemDim3Code)) + uniqueTargets = append(uniqueTargets, t) + } + + if len(uniqueTargets) == 0 { + log.Printf("[InsertItemBarcodesByTargetsTx] targets=%d unique=0 inserted=0 duration_ms=%d", len(targets), time.Since(start).Milliseconds()) + return 0, nil + } + if err := ensureTxStillActive(tx, "InsertItemBarcodesByTargetsTx/start"); err != nil { + return 0, err + } + + // Barcode seed'i hem prItemBarcode hem de (varsa) tbStokBarkodu uzerinden + // kilitli okuyarak hesapla; trigger tarafindaki duplicate riskini azalt. + var maxBarcode int64 + maxPrQuery := ` +SELECT ISNULL(MAX(v.BarcodeNum), 0) +FROM ( + SELECT + CASE + WHEN LTRIM(RTRIM(ISNULL(pb.Barcode,''))) NOT LIKE '%[^0-9]%' + AND LEN(LTRIM(RTRIM(ISNULL(pb.Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(pb.Barcode,''))) AS BIGINT) + ELSE NULL + END AS BarcodeNum + FROM dbo.prItemBarcode pb WITH (UPDLOCK, HOLDLOCK, TABLOCKX) + WHERE pb.BarcodeTypeCode = 'BAGGI3' +) v +WHERE v.BarcodeNum IS NOT NULL; +` + if err := tx.QueryRow(maxPrQuery).Scan(&maxBarcode); err != nil { + return 0, fmt.Errorf("barcode seed query failed: %w", err) + } + var hasTb int + if err := tx.QueryRow(`SELECT CASE WHEN OBJECT_ID(N'dbo.tbStokBarkodu', N'U') IS NULL THEN 0 ELSE 1 END`).Scan(&hasTb); err != nil { + return 0, fmt.Errorf("barcode seed object check failed: %w", err) + } + if hasTb == 1 { + var maxTb int64 + maxTbQuery := ` +SELECT ISNULL(MAX(v.BarcodeNum), 0) +FROM ( + SELECT + CASE + WHEN LTRIM(RTRIM(ISNULL(sb.Barcode,''))) NOT LIKE '%[^0-9]%' + AND LEN(LTRIM(RTRIM(ISNULL(sb.Barcode,'')))) BETWEEN 1 AND 18 + THEN CAST(LTRIM(RTRIM(ISNULL(sb.Barcode,''))) AS BIGINT) + ELSE NULL + END AS BarcodeNum + FROM dbo.tbStokBarkodu sb WITH (UPDLOCK, HOLDLOCK, TABLOCKX) +) v +WHERE v.BarcodeNum IS NOT NULL; +` + if err := tx.QueryRow(maxTbQuery).Scan(&maxTb); err != nil { + return 0, fmt.Errorf("barcode seed tbStokBarkodu query failed: %w", err) + } + if maxTb > maxBarcode { + maxBarcode = maxTb + } + } + if maxBarcode < 36999999 { + maxBarcode = 36999999 + } + existsBarcodeQuery := ` +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode pb WITH (UPDLOCK, HOLDLOCK) + WHERE LTRIM(RTRIM(ISNULL(pb.Barcode,''))) = @p1 +) THEN 1 ELSE 0 END; +` + existsBarcodeWithTbQuery := ` +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode pb WITH (UPDLOCK, HOLDLOCK) + WHERE LTRIM(RTRIM(ISNULL(pb.Barcode,''))) = @p1 +) OR EXISTS ( + SELECT 1 + FROM dbo.tbStokBarkodu sb WITH (UPDLOCK, HOLDLOCK) + WHERE LTRIM(RTRIM(ISNULL(sb.Barcode,''))) = @p1 +) THEN 1 ELSE 0 END; +` + hasVariantBarcodeQuery := ` +SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode b WITH (UPDLOCK, HOLDLOCK) + WHERE b.ItemTypeCode = @p1 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemCode,'')))) = @p2 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ColorCode,'')))) = @p3 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim1Code,'')))) = @p4 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim2Code,'')))) = @p5 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim3Code,'')))) = @p6 +) THEN 1 ELSE 0 END; +` + insertOneQuery := ` +INSERT INTO dbo.prItemBarcode +( + Barcode, + BarcodeTypeCode, + ItemTypeCode, + ItemCode, + ColorCode, + ItemDim1Code, + ItemDim2Code, + ItemDim3Code, + UnitOfMeasureCode, + Qty, + CreatedUserName, + CreatedDate, + LastUpdatedUserName, + LastUpdatedDate, + RowGuid +) +SELECT + @p1, + 'BAGGI3', + @p2, + @p3, + @p4, + @p5, + @p6, + @p7, + 'AD', + 1, + @p8, + GETDATE(), + @p8, + GETDATE(), + NEWID() +WHERE NOT EXISTS ( + SELECT 1 + FROM dbo.prItemBarcode b + WHERE b.ItemTypeCode = @p2 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemCode,'')))) = @p3 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ColorCode,'')))) = @p4 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim1Code,'')))) = @p5 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim2Code,'')))) = @p6 + AND UPPER(LTRIM(RTRIM(ISNULL(b.ItemDim3Code,'')))) = @p7 +); +` + + var inserted int64 + for _, t := range uniqueTargets { + if err := ensureTxStillActive(tx, "InsertItemBarcodesByTargetsTx/before_target"); err != nil { + return inserted, err + } + var hasVariant int + if err := tx.QueryRow( + hasVariantBarcodeQuery, + t.ItemTypeCode, + t.ItemCode, + t.ColorCode, + t.ItemDim1Code, + t.ItemDim2Code, + t.ItemDim3Code, + ).Scan(&hasVariant); err != nil { + return inserted, fmt.Errorf("variant barcode exists check failed: %w", err) + } + if hasVariant == 1 { + continue + } + + retry := 0 + for { + retry++ + if retry > 2000 { + return inserted, fmt.Errorf("barcode allocation exceeded retry limit item=%s color=%s dim1=%s", t.ItemCode, t.ColorCode, t.ItemDim1Code) + } + candidateNum := maxBarcode + 1 + candidate := strconv.FormatInt(candidateNum, 10) + + var exists int + if hasTb == 1 { + if err := tx.QueryRow(existsBarcodeWithTbQuery, candidate).Scan(&exists); err != nil { + return inserted, fmt.Errorf("barcode exists check(tb) failed: %w", err) + } + } else { + if err := tx.QueryRow(existsBarcodeQuery, candidate).Scan(&exists); err != nil { + return inserted, fmt.Errorf("barcode exists check failed: %w", err) + } + } + if exists == 1 { + maxBarcode = candidateNum + continue + } + + res, err := tx.Exec( + insertOneQuery, + candidate, + t.ItemTypeCode, + t.ItemCode, + t.ColorCode, + t.ItemDim1Code, + t.ItemDim2Code, + t.ItemDim3Code, + username, + ) + if err != nil { + if isDuplicateBarcodeInsertErr(err) { + maxBarcode = candidateNum + continue + } + return inserted, fmt.Errorf("insert item barcode failed item=%s color=%s dim1=%s duration_ms=%d: %w", + t.ItemCode, t.ColorCode, t.ItemDim1Code, time.Since(start).Milliseconds(), err) + } + affected, _ := res.RowsAffected() + if affected > 0 { + inserted += affected + maxBarcode = candidateNum + } + break + } + } + + if txErr := ensureTxStillActive(tx, "InsertItemBarcodesByTargetsTx/after_batch"); txErr != nil { + return inserted, txErr + } + + log.Printf("[InsertItemBarcodesByTargetsTx] targets=%d unique=%d inserted=%d duration_ms=%d", + len(targets), len(uniqueTargets), inserted, time.Since(start).Milliseconds()) + return inserted, nil +} + +func ensureTxStillActive(tx *sql.Tx, where string) error { + if tx == nil { + return fmt.Errorf("tx is nil at %s", where) + } + var tranCount int + if err := tx.QueryRow(`SELECT @@TRANCOUNT`).Scan(&tranCount); err != nil { + return fmt.Errorf("tx state query failed at %s: %w", where, err) + } + if tranCount <= 0 { + return fmt.Errorf("tx closed unexpectedly at %s (trancount=%d)", where, tranCount) + } + return nil +} + +func isDuplicateBarcodeInsertErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "duplicate key") { + return false + } + if strings.Contains(msg, "tbstokbarkodu") { + return true + } + if strings.Contains(msg, "pritembarcode") { + return true + } + return strings.Contains(msg, "unique") +} + func UpsertItemAttributesTx(tx *sql.Tx, attrs []models.OrderProductionItemAttributeRow, username string) (int64, error) { start := time.Now() if len(attrs) == 0 { @@ -1018,6 +1504,28 @@ func UpsertItemAttributesTx(tx *sql.Tx, attrs []models.OrderProductionItemAttrib return 0, nil } + // FK_prItemAttribute_ItemCode hatasini engellemek icin, attribute yazmadan once + // ilgili item kodlarinin cdItem tarafinda varligini transaction icinde garanti et. + seenCodes := make(map[string]struct{}, len(attrs)) + for _, a := range attrs { + itemTypeCode := a.ItemTypeCode + if itemTypeCode <= 0 { + itemTypeCode = 1 + } + itemCode := strings.ToUpper(strings.TrimSpace(a.ItemCode)) + if itemCode == "" { + continue + } + key := NormalizeCdItemMapKey(int16(itemTypeCode), itemCode) + if _, ok := seenCodes[key]; ok { + continue + } + seenCodes[key] = struct{}{} + if err := ensureCdItemTx(tx, int16(itemTypeCode), itemCode, username, nil); err != nil { + return 0, fmt.Errorf("ensure cdItem before item attributes failed itemCode=%s: %w", itemCode, err) + } + } + // SQL Server parameter limiti (2100) nedeniyle batch'li set-based upsert kullanilir. const chunkSize = 400 // 400 * 4 param + 1 username = 1601 var affected int64 diff --git a/svc/routes/orderproductionitems.go b/svc/routes/orderproductionitems.go index 03d32c4..6e4b1dd 100644 --- a/svc/routes/orderproductionitems.go +++ b/svc/routes/orderproductionitems.go @@ -180,16 +180,33 @@ func OrderProductionValidateRoute(mssql *sql.DB) http.Handler { log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s payload lineCount=%d insertMissing=%t cdItemCount=%d attributeCount=%d", rid, id, len(payload.Lines), payload.InsertMissing, len(payload.CdItems), len(payload.ProductAttributes)) + newLines, existingLines := splitLinesByCdItemDraft(payload.Lines, payload.CdItems) + newCodes := uniqueCodesFromLines(newLines) + existingCodes := uniqueCodesFromLines(existingLines) + missing := make([]models.OrderProductionMissingVariant, 0) + targets := make([]models.OrderProductionMissingVariant, 0) stepStart := time.Now() - missing, err := buildMissingVariants(mssql, id, payload.Lines) - if err != nil { - log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s step=build_missing failed duration_ms=%d err=%v", - rid, id, time.Since(stepStart).Milliseconds(), err) - writeDBError(w, http.StatusInternalServerError, "validate_missing_variants", id, "", len(payload.Lines), err) - return + if len(newLines) > 0 { + err := runWithTransientMSSQLRetry("validate_build_targets_missing", 3, 500*time.Millisecond, func() error { + var stepErr error + targets, stepErr = buildTargetVariants(mssql, id, newLines) + if stepErr != nil { + return stepErr + } + missing, stepErr = buildMissingVariantsFromTargets(mssql, id, targets) + return stepErr + }) + if err != nil { + log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s step=build_missing failed duration_ms=%d err=%v", + rid, id, time.Since(stepStart).Milliseconds(), err) + writeDBError(w, http.StatusInternalServerError, "validate_missing_variants", id, "", len(newLines), err) + return + } } - log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s lineCount=%d missingCount=%d build_missing_ms=%d total_ms=%d", - rid, id, len(payload.Lines), len(missing), time.Since(stepStart).Milliseconds(), time.Since(start).Milliseconds()) + log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s lineCount=%d newLineCount=%d existingLineCount=%d targetVariantCount=%d missingCount=%d build_missing_ms=%d total_ms=%d", + rid, id, len(payload.Lines), len(newLines), len(existingLines), len(targets), len(missing), time.Since(stepStart).Milliseconds(), time.Since(start).Milliseconds()) + log.Printf("[OrderProductionValidateRoute] rid=%s orderHeaderID=%s scope newCodes=%v existingCodes=%v", + rid, id, newCodes, existingCodes) resp := map[string]any{ "missingCount": len(missing), @@ -230,17 +247,57 @@ func OrderProductionApplyRoute(mssql *sql.DB, ml *mailer.GraphMailer) http.Handl } log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s payload lineCount=%d insertMissing=%t cdItemCount=%d attributeCount=%d", rid, id, len(payload.Lines), payload.InsertMissing, len(payload.CdItems), len(payload.ProductAttributes)) - - stepMissingStart := time.Now() - missing, err := buildMissingVariants(mssql, id, payload.Lines) - if err != nil { - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=build_missing failed duration_ms=%d err=%v", - rid, id, time.Since(stepMissingStart).Milliseconds(), err) - writeDBError(w, http.StatusInternalServerError, "apply_validate_missing_variants", id, "", len(payload.Lines), err) - return + if len(payload.Lines) > 0 { + limit := 5 + if len(payload.Lines) < limit { + limit = len(payload.Lines) + } + samples := make([]string, 0, limit) + for i := 0; i < limit; i++ { + ln := payload.Lines[i] + dim1 := "" + if ln.ItemDim1Code != nil { + dim1 = strings.TrimSpace(*ln.ItemDim1Code) + } + samples = append(samples, fmt.Sprintf( + "lineID=%s newItem=%s newColor=%s newDim1=%s newDim2=%s", + strings.TrimSpace(ln.OrderLineID), + strings.ToUpper(strings.TrimSpace(ln.NewItemCode)), + strings.ToUpper(strings.TrimSpace(ln.NewColor)), + strings.ToUpper(strings.TrimSpace(dim1)), + strings.ToUpper(strings.TrimSpace(ln.NewDim2)), + )) + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s payload lineSamples=%v", rid, id, samples) } - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s lineCount=%d missingCount=%d build_missing_ms=%d", - rid, id, len(payload.Lines), len(missing), time.Since(stepMissingStart).Milliseconds()) + + newLines, existingLines := splitLinesByCdItemDraft(payload.Lines, payload.CdItems) + newCodes := uniqueCodesFromLines(newLines) + existingCodes := uniqueCodesFromLines(existingLines) + stepMissingStart := time.Now() + missing := make([]models.OrderProductionMissingVariant, 0) + barcodeTargets := make([]models.OrderProductionMissingVariant, 0) + if len(newLines) > 0 { + err := runWithTransientMSSQLRetry("apply_build_targets_missing", 3, 500*time.Millisecond, func() error { + var stepErr error + barcodeTargets, stepErr = buildTargetVariants(mssql, id, newLines) + if stepErr != nil { + return stepErr + } + missing, stepErr = buildMissingVariantsFromTargets(mssql, id, barcodeTargets) + return stepErr + }) + if err != nil { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=build_missing failed duration_ms=%d err=%v", + rid, id, time.Since(stepMissingStart).Milliseconds(), err) + writeDBError(w, http.StatusInternalServerError, "apply_validate_missing_variants", id, "", len(newLines), err) + return + } + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s lineCount=%d newLineCount=%d existingLineCount=%d targetVariantCount=%d missingCount=%d build_missing_ms=%d", + rid, id, len(payload.Lines), len(newLines), len(existingLines), len(barcodeTargets), len(missing), time.Since(stepMissingStart).Milliseconds()) + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s scope newCodes=%v existingCodes=%v", + rid, id, newCodes, existingCodes) if len(missing) > 0 && !payload.InsertMissing { log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s early_exit=missing_variants total_ms=%d", @@ -269,30 +326,83 @@ func OrderProductionApplyRoute(mssql *sql.DB, ml *mailer.GraphMailer) http.Handl writeDBError(w, http.StatusInternalServerError, "begin_tx", id, username, len(payload.Lines), err) return } - defer tx.Rollback() log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=begin_tx duration_ms=%d", rid, id, time.Since(stepBeginStart).Milliseconds()) + committed := false + currentStep := "begin_tx" + applyTxSettings := func(tx *sql.Tx) error { + // XACT_ABORT OFF: + // Barcode insert path intentionally tolerates duplicate-key errors (fallback/skip duplicate). + // With XACT_ABORT ON, that expected error aborts the whole transaction and causes COMMIT 3902. + _, execErr := tx.Exec(`SET XACT_ABORT OFF; SET LOCK_TIMEOUT 15000;`) + return execErr + } + defer func() { + if committed { + return + } + rbStart := time.Now() + if rbErr := tx.Rollback(); rbErr != nil && rbErr != sql.ErrTxDone { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s rollback step=%s failed duration_ms=%d err=%v", + rid, id, currentStep, time.Since(rbStart).Milliseconds(), rbErr) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s rollback step=%s ok duration_ms=%d", + rid, id, currentStep, time.Since(rbStart).Milliseconds()) + }() stepTxSettingsStart := time.Now() - if _, err := tx.Exec(`SET XACT_ABORT ON; SET LOCK_TIMEOUT 15000;`); err != nil { + currentStep = "tx_settings" + if err := applyTxSettings(tx); err != nil { writeDBError(w, http.StatusInternalServerError, "tx_settings", id, username, len(payload.Lines), err) return } log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=tx_settings duration_ms=%d", rid, id, time.Since(stepTxSettingsStart).Milliseconds()) + if err := ensureTxAlive(tx, "after_tx_settings"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_tx_settings", id, username, len(payload.Lines), err) + return + } var inserted int64 - if payload.InsertMissing { + if payload.InsertMissing && len(newLines) > 0 { + currentStep = "insert_missing_variants" cdItemByCode := buildCdItemDraftMap(payload.CdItems) stepInsertMissingStart := time.Now() inserted, err = queries.InsertMissingVariantsTx(tx, missing, username, cdItemByCode) + if err != nil && isTransientMSSQLNetworkErr(err) { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=insert_missing transient_error retry=1 err=%v", + rid, id, err) + _ = tx.Rollback() + tx, err = mssql.Begin() + if err != nil { + writeDBError(w, http.StatusInternalServerError, "begin_tx_retry_insert_missing", id, username, len(payload.Lines), err) + return + } + currentStep = "tx_settings_retry_insert_missing" + if err = applyTxSettings(tx); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_settings_retry_insert_missing", id, username, len(payload.Lines), err) + return + } + if err = ensureTxAlive(tx, "after_tx_settings_retry_insert_missing"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_tx_settings_retry_insert_missing", id, username, len(payload.Lines), err) + return + } + currentStep = "insert_missing_variants_retry" + inserted, err = queries.InsertMissingVariantsTx(tx, missing, username, cdItemByCode) + } if err != nil { writeDBError(w, http.StatusInternalServerError, "insert_missing_variants", id, username, len(missing), err) return } + if err := ensureTxAlive(tx, "after_insert_missing_variants"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_insert_missing_variants", id, username, len(missing), err) + return + } log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=insert_missing inserted=%d duration_ms=%d", rid, id, inserted, time.Since(stepInsertMissingStart).Milliseconds()) } stepValidateAttrStart := time.Now() + currentStep = "validate_attributes" if err := validateProductAttributes(payload.ProductAttributes); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -300,37 +410,8 @@ func OrderProductionApplyRoute(mssql *sql.DB, ml *mailer.GraphMailer) http.Handl log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=validate_attributes count=%d duration_ms=%d", rid, id, len(payload.ProductAttributes), time.Since(stepValidateAttrStart).Milliseconds()) - stepUpdateHeaderStart := time.Now() - if err := queries.UpdateOrderHeaderAverageDueDateTx(tx, id, payload.HeaderAverageDueDate, username); err != nil { - writeDBError(w, http.StatusInternalServerError, "update_order_header_average_due_date", id, username, 0, err) - return - } - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_header_average_due_date changed=%t duration_ms=%d", - rid, id, payload.HeaderAverageDueDate != nil, time.Since(stepUpdateHeaderStart).Milliseconds()) - - stepUpdateLinesStart := time.Now() - updated, err := queries.UpdateOrderLinesTx(tx, id, payload.Lines, username) - if err != nil { - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_lines failed duration_ms=%d err=%v", - rid, id, time.Since(stepUpdateLinesStart).Milliseconds(), err) - writeDBError(w, http.StatusInternalServerError, "update_order_lines", id, username, len(payload.Lines), err) - return - } - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_lines updated=%d duration_ms=%d", - rid, id, updated, time.Since(stepUpdateLinesStart).Milliseconds()) - - stepUpsertBarcodeStart := time.Now() - barcodeInserted, err := queries.InsertItemBarcodesTx(tx, id, payload.Lines, username) - if err != nil { - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_barcodes failed duration_ms=%d err=%v", - rid, id, time.Since(stepUpsertBarcodeStart).Milliseconds(), err) - writeDBError(w, http.StatusInternalServerError, "upsert_item_barcodes", id, username, len(payload.Lines), err) - return - } - log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_barcodes inserted=%d duration_ms=%d", - rid, id, barcodeInserted, time.Since(stepUpsertBarcodeStart).Milliseconds()) - stepUpsertAttrStart := time.Now() + currentStep = "upsert_item_attributes" attributeAffected, err := queries.UpsertItemAttributesTx(tx, payload.ProductAttributes, username) if err != nil { log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_attributes failed duration_ms=%d err=%v", @@ -340,14 +421,120 @@ func OrderProductionApplyRoute(mssql *sql.DB, ml *mailer.GraphMailer) http.Handl } log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_attributes affected=%d duration_ms=%d", rid, id, attributeAffected, time.Since(stepUpsertAttrStart).Milliseconds()) + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=prItemAttribute inputRows=%d affectedRows=%d", + rid, id, len(payload.ProductAttributes), attributeAffected) + if err := ensureTxAlive(tx, "after_upsert_item_attributes"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_upsert_item_attributes", id, username, len(payload.ProductAttributes), err) + return + } + + var barcodeInserted int64 + // Barkod adimi: + // - Eski kodlara girmemeli + // - Yeni kod satirlari icin, varyant daha once olusmus olsa bile eksik barkod varsa tamamlamali + // Bu nedenle "inserted > 0" yerine "newLineCount > 0" kosulu kullanilir. + if len(newLines) > 0 && len(barcodeTargets) > 0 { + stepUpsertBarcodeStart := time.Now() + currentStep = "upsert_item_barcodes" + barcodeInserted, err = queries.InsertItemBarcodesByTargetsTx(tx, barcodeTargets, username) + if err != nil { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_barcodes failed duration_ms=%d err=%v", + rid, id, time.Since(stepUpsertBarcodeStart).Milliseconds(), err) + writeDBError(w, http.StatusInternalServerError, "upsert_item_barcodes", id, username, len(barcodeTargets), err) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_barcodes inserted=%d duration_ms=%d", + rid, id, barcodeInserted, time.Since(stepUpsertBarcodeStart).Milliseconds()) + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=prItemBarcode targetVariantRows=%d insertedRows=%d", + rid, id, len(barcodeTargets), barcodeInserted) + if err := ensureTxAlive(tx, "after_upsert_item_barcodes"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_upsert_item_barcodes", id, username, len(barcodeTargets), err) + return + } + } else { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=upsert_barcodes skipped newLineCount=%d targetVariantRows=%d", + rid, id, len(newLines), len(barcodeTargets)) + } + + stepUpdateHeaderStart := time.Now() + currentStep = "update_order_header_average_due_date" + if err := queries.UpdateOrderHeaderAverageDueDateTx(tx, id, payload.HeaderAverageDueDate, username); err != nil { + writeDBError(w, http.StatusInternalServerError, "update_order_header_average_due_date", id, username, 0, err) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_header_average_due_date changed=%t duration_ms=%d", + rid, id, payload.HeaderAverageDueDate != nil, time.Since(stepUpdateHeaderStart).Milliseconds()) + if err := ensureTxAlive(tx, "after_update_order_header_average_due_date"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_update_order_header_average_due_date", id, username, 0, err) + return + } + + currentStep = "touch_order_header" + headerTouched, err := queries.TouchOrderHeaderTx(tx, id, username) + if err != nil { + writeDBError(w, http.StatusInternalServerError, "touch_order_header", id, username, len(payload.Lines), err) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=trOrderHeader touchedRows=%d", + rid, id, headerTouched) + if err := ensureTxAlive(tx, "after_touch_order_header"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_touch_order_header", id, username, len(payload.Lines), err) + return + } + + stepUpdateLinesStart := time.Now() + currentStep = "update_order_lines" + updated, err := queries.UpdateOrderLinesTx(tx, id, payload.Lines, username) + if err != nil { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_lines failed duration_ms=%d err=%v", + rid, id, time.Since(stepUpdateLinesStart).Milliseconds(), err) + writeDBError(w, http.StatusInternalServerError, "update_order_lines", id, username, len(payload.Lines), err) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=update_lines updated=%d duration_ms=%d", + rid, id, updated, time.Since(stepUpdateLinesStart).Milliseconds()) + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=trOrderLine targetRows=%d updatedRows=%d", + rid, id, len(payload.Lines), updated) + if err := ensureTxAlive(tx, "after_update_order_lines"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_after_update_order_lines", id, username, len(payload.Lines), err) + return + } + + currentStep = "verify_order_lines" + verifyMismatchCount, verifySamples, verifyErr := queries.VerifyOrderLineUpdatesTx(tx, id, payload.Lines) + if verifyErr != nil { + writeDBError(w, http.StatusInternalServerError, "verify_order_lines", id, username, len(payload.Lines), verifyErr) + return + } + if verifyMismatchCount > 0 { + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=trOrderLine verifyMismatchCount=%d samples=%v", + rid, id, verifyMismatchCount, verifySamples) + currentStep = "verify_order_lines_mismatch" + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Order satirlari beklenen kod/renk degerlerine guncellenemedi", + "step": "verify_order_lines_mismatch", + "detail": fmt.Sprintf("mismatchCount=%d", verifyMismatchCount), + "samples": verifySamples, + }) + return + } + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s table=trOrderLine verifyMismatchCount=0", + rid, id) + if err := ensureTxAlive(tx, "before_commit_tx"); err != nil { + writeDBError(w, http.StatusInternalServerError, "tx_not_active_before_commit_tx", id, username, len(payload.Lines), err) + return + } stepCommitStart := time.Now() + currentStep = "commit_tx" if err := tx.Commit(); err != nil { log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=commit failed duration_ms=%d err=%v", rid, id, time.Since(stepCommitStart).Milliseconds(), err) writeDBError(w, http.StatusInternalServerError, "commit_tx", id, username, len(payload.Lines), err) return } + committed = true log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s step=commit duration_ms=%d total_ms=%d", rid, id, time.Since(stepCommitStart).Milliseconds(), time.Since(start).Milliseconds()) @@ -372,6 +559,8 @@ func OrderProductionApplyRoute(mssql *sql.DB, ml *mailer.GraphMailer) http.Handl } log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s result updated=%d inserted=%d barcodeInserted=%d attributeUpserted=%d", rid, id, updated, inserted, barcodeInserted, attributeAffected) + log.Printf("[OrderProductionApplyRoute] rid=%s orderHeaderID=%s summary tables cdItem/prItemVariant(newOnly)=%d trOrderLine(updated)=%d prItemBarcode(inserted,newOnly)=%d prItemAttribute(affected)=%d trOrderHeader(touched)=%d", + rid, id, inserted, updated, barcodeInserted, attributeAffected, headerTouched) if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("❌ encode error: %v", err) } @@ -416,6 +605,14 @@ func buildCdItemDraftMap(list []models.OrderProductionCdItemDraft) map[string]mo return out } +func isNoCorrespondingBeginTxErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(msg, "commit transaction request has no corresponding begin transaction") +} + func buildTargetVariants(mssql *sql.DB, orderHeaderID string, lines []models.OrderProductionUpdateLine) ([]models.OrderProductionMissingVariant, error) { start := time.Now() lineDimsMap, err := queries.GetOrderLineDimsMap(mssql, orderHeaderID) @@ -468,11 +665,15 @@ func buildTargetVariants(mssql *sql.DB, orderHeaderID string, lines []models.Ord } func buildMissingVariants(mssql *sql.DB, orderHeaderID string, lines []models.OrderProductionUpdateLine) ([]models.OrderProductionMissingVariant, error) { - start := time.Now() targets, err := buildTargetVariants(mssql, orderHeaderID, lines) if err != nil { return nil, err } + return buildMissingVariantsFromTargets(mssql, orderHeaderID, targets) +} + +func buildMissingVariantsFromTargets(mssql *sql.DB, orderHeaderID string, targets []models.OrderProductionMissingVariant) ([]models.OrderProductionMissingVariant, error) { + start := time.Now() missing := make([]models.OrderProductionMissingVariant, 0, len(targets)) existsCache := make(map[string]bool, len(targets)) @@ -499,11 +700,69 @@ func buildMissingVariants(mssql *sql.DB, orderHeaderID string, lines []models.Or } } - log.Printf("[buildMissingVariants] orderHeaderID=%s lineCount=%d dimMapCount=%d missingCount=%d total_ms=%d", - orderHeaderID, len(lines), len(targets), len(missing), time.Since(start).Milliseconds()) + log.Printf("[buildMissingVariants] orderHeaderID=%s targetCount=%d missingCount=%d total_ms=%d", + orderHeaderID, len(targets), len(missing), time.Since(start).Milliseconds()) return missing, nil } +func runWithTransientMSSQLRetry(op string, maxAttempts int, baseDelay time.Duration, fn func() error) error { + if maxAttempts <= 1 { + return fn() + } + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + err := fn() + if err == nil { + return nil + } + lastErr = err + if !isTransientMSSQLNetworkErr(err) || attempt == maxAttempts { + return err + } + wait := time.Duration(attempt) * baseDelay + log.Printf("[MSSQLRetry] op=%s attempt=%d/%d wait_ms=%d err=%v", + op, attempt, maxAttempts, wait.Milliseconds(), err) + time.Sleep(wait) + } + return lastErr +} + +func isTransientMSSQLNetworkErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + needles := []string{ + "wsarecv", + "read tcp", + "connection reset", + "connection refused", + "broken pipe", + "i/o timeout", + "timeout", + } + for _, needle := range needles { + if strings.Contains(msg, needle) { + return true + } + } + return false +} + +func ensureTxAlive(tx *sql.Tx, where string) error { + if tx == nil { + return fmt.Errorf("tx is nil at %s", where) + } + var tranCount int + if err := tx.QueryRow(`SELECT @@TRANCOUNT`).Scan(&tranCount); err != nil { + return fmt.Errorf("tx state query failed at %s: %w", where, err) + } + if tranCount <= 0 { + return fmt.Errorf("transaction no longer active at %s (trancount=%d)", where, tranCount) + } + return nil +} + func validateUpdateLines(lines []models.OrderProductionUpdateLine) error { for _, line := range lines { if strings.TrimSpace(line.OrderLineID) == "" { @@ -520,6 +779,54 @@ func validateUpdateLines(lines []models.OrderProductionUpdateLine) error { return nil } +func splitLinesByCdItemDraft(lines []models.OrderProductionUpdateLine, cdItems []models.OrderProductionCdItemDraft) ([]models.OrderProductionUpdateLine, []models.OrderProductionUpdateLine) { + if len(lines) == 0 { + return nil, nil + } + newCodeSet := make(map[string]struct{}, len(cdItems)) + for _, item := range cdItems { + code := strings.ToUpper(strings.TrimSpace(item.ItemCode)) + if code == "" { + continue + } + newCodeSet[code] = struct{}{} + } + if len(newCodeSet) == 0 { + existingLines := make([]models.OrderProductionUpdateLine, 0, len(lines)) + existingLines = append(existingLines, lines...) + return nil, existingLines + } + + newLines := make([]models.OrderProductionUpdateLine, 0, len(lines)) + existingLines := make([]models.OrderProductionUpdateLine, 0, len(lines)) + for _, line := range lines { + code := strings.ToUpper(strings.TrimSpace(line.NewItemCode)) + if _, ok := newCodeSet[code]; ok { + newLines = append(newLines, line) + continue + } + existingLines = append(existingLines, line) + } + return newLines, existingLines +} + +func uniqueCodesFromLines(lines []models.OrderProductionUpdateLine) []string { + set := make(map[string]struct{}, len(lines)) + out := make([]string, 0, len(lines)) + for _, line := range lines { + code := strings.ToUpper(strings.TrimSpace(line.NewItemCode)) + if code == "" { + continue + } + if _, ok := set[code]; ok { + continue + } + set[code] = struct{}{} + out = append(out, code) + } + return out +} + func writeDBError(w http.ResponseWriter, status int, step string, orderHeaderID string, username string, lineCount int, err error) { var sqlErr mssql.Error if errors.As(err, &sqlErr) { diff --git a/ui/.quasar/dev-spa/client-entry.js b/ui/.quasar/dev-spa/client-entry.js index 0adee13..b56c131 100644 --- a/ui/.quasar/dev-spa/client-entry.js +++ b/ui/.quasar/dev-spa/client-entry.js @@ -146,7 +146,9 @@ createQuasarApp(createApp, quasarUserOptions) return Promise[ method ]([ - import(/* webpackMode: "eager" */ 'boot/dayjs') + import(/* webpackMode: "eager" */ 'boot/dayjs'), + + import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') ]).then(bootFiles => { const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - - - -import { Quasar } from 'quasar' -import { markRaw } from 'vue' -import RootComponent from 'app/src/App.vue' - -import createStore from 'app/src/stores/index' -import createRouter from 'app/src/router/index' - - - - - -export default async function (createAppFn, quasarUserOptions) { - - - // Create the app instance. - // Here we inject into it the Quasar UI, the router & possibly the store. - const app = createAppFn(RootComponent) - - - - app.use(Quasar, quasarUserOptions) - - - - - const store = typeof createStore === 'function' - ? await createStore({}) - : createStore - - - app.use(store) - - - - - - const router = markRaw( - typeof createRouter === 'function' - ? await createRouter({store}) - : createRouter - ) - - - // make router instance available in store - - store.use(({ store }) => { store.router = router }) - - - - // Expose the app, the router and the store. - // Note that we are not mounting the app here, since bootstrapping will be - // different depending on whether we are in a browser or on the server. - return { - app, - store, - router - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5de66d0..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,154 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - -import { createApp } from 'vue' - - - - - - - -import '@quasar/extras/roboto-font/roboto-font.css' - -import '@quasar/extras/material-icons/material-icons.css' - - - - -// We load Quasar stylesheet file -import 'quasar/dist/quasar.sass' - - - - -import 'src/css/app.css' - - -import createQuasarApp from './app.js' -import quasarUserOptions from './quasar-user-options.js' - - - - - - - - -const publicPath = `/` - - -async function start ({ - app, - router - , store -}, bootFiles) { - - let hasRedirected = false - const getRedirectUrl = url => { - try { return router.resolve(url).href } - catch (err) {} - - return Object(url) === url - ? null - : url - } - const redirect = url => { - hasRedirected = true - - if (typeof url === 'string' && /^https?:\/\//.test(url)) { - window.location.href = url - return - } - - const href = getRedirectUrl(url) - - // continue if we didn't fail to resolve the url - if (href !== null) { - window.location.href = href - window.location.reload() - } - } - - const urlPath = window.location.href.replace(window.location.origin, '') - - for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) { - try { - await bootFiles[i]({ - app, - router, - store, - ssrContext: null, - redirect, - urlPath, - publicPath - }) - } - catch (err) { - if (err && err.url) { - redirect(err.url) - return - } - - console.error('[Quasar] boot error:', err) - return - } - } - - if (hasRedirected === true) return - - - app.use(router) - - - - - - - app.mount('#q-app') - - - -} - -createQuasarApp(createApp, quasarUserOptions) - - .then(app => { - // eventually remove this when Cordova/Capacitor/Electron support becomes old - const [ method, mapFn ] = Promise.allSettled !== void 0 - ? [ - 'allSettled', - bootFiles => bootFiles.map(result => { - if (result.status === 'rejected') { - console.error('[Quasar] boot error:', result.reason) - return - } - return result.value.default - }) - ] - : [ - 'all', - bootFiles => bootFiles.map(entry => entry.default) - ] - - return Promise[ method ]([ - - import(/* webpackMode: "eager" */ 'boot/dayjs') - - ]).then(bootFiles => { - const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') - start(app, boot) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - - - -import App from 'app/src/App.vue' -let appPrefetch = typeof App.preFetch === 'function' - ? App.preFetch - : ( - // Class components return the component options (and the preFetch hook) inside __c property - App.__c !== void 0 && typeof App.__c.preFetch === 'function' - ? App.__c.preFetch - : false - ) - - -function getMatchedComponents (to, router) { - const route = to - ? (to.matched ? to : router.resolve(to).route) - : router.currentRoute.value - - if (!route) { return [] } - - const matched = route.matched.filter(m => m.components !== void 0) - - if (matched.length === 0) { return [] } - - return Array.prototype.concat.apply([], matched.map(m => { - return Object.keys(m.components).map(key => { - const comp = m.components[key] - return { - path: m.path, - c: comp - } - }) - })) -} - -export function addPreFetchHooks ({ router, store, publicPath }) { - // Add router hook for handling preFetch. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using router.beforeResolve() so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const - urlPath = window.location.href.replace(window.location.origin, ''), - matched = getMatchedComponents(to, router), - prevMatched = getMatchedComponents(from, router) - - let diffed = false - const preFetchList = matched - .filter((m, i) => { - return diffed || (diffed = ( - !prevMatched[i] || - prevMatched[i].c !== m.c || - m.path.indexOf('/:') > -1 // does it has params? - )) - }) - .filter(m => m.c !== void 0 && ( - typeof m.c.preFetch === 'function' - // Class components return the component options (and the preFetch hook) inside __c property - || (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function') - )) - .map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch) - - - if (appPrefetch !== false) { - preFetchList.unshift(appPrefetch) - appPrefetch = false - } - - - if (preFetchList.length === 0) { - return next() - } - - let hasRedirected = false - const redirect = url => { - hasRedirected = true - next(url) - } - const proceed = () => { - - if (hasRedirected === false) { next() } - } - - - - preFetchList.reduce( - (promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({ - store, - currentRoute: to, - previousRoute: from, - redirect, - urlPath, - publicPath - })), - Promise.resolve() - ) - .then(proceed) - .catch(e => { - console.error(e) - proceed() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * THIS FILE IS GENERATED AUTOMATICALLY. - * DO NOT EDIT. - * - * You are probably looking on adding startup/initialization code. - * Use "quasar new boot " and add it there. - * One boot file per concern. Then reference the file(s) in quasar.config file > boot: - * boot: ['file', ...] // do not add ".js" extension to it. - * - * Boot files are your "main.js" - **/ - -import lang from 'quasar/lang/tr.js' - - - -import {Loading,Dialog,Notify} from 'quasar' - - - -export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} } - diff --git a/ui/quasar.config.js b/ui/quasar.config.js index f75dea7..d0833f6 100644 --- a/ui/quasar.config.js +++ b/ui/quasar.config.js @@ -15,7 +15,7 @@ export default defineConfig(() => { /* ===================================================== BOOT FILES ===================================================== */ - boot: ['dayjs'], + boot: ['dayjs', 'resizeObserverGuard'], /* ===================================================== GLOBAL CSS @@ -56,6 +56,13 @@ export default defineConfig(() => { server: { type: 'http' }, port: 9000, open: true, + client: { + overlay: { + errors: true, + warnings: false, + runtimeErrors: false + } + }, // DEV proxy (CORS'suz) proxy: [ diff --git a/ui/quasar.config.js.temporary.compiled.1776172100358.mjs b/ui/quasar.config.js.temporary.compiled.1776255586414.mjs similarity index 95% rename from ui/quasar.config.js.temporary.compiled.1776172100358.mjs rename to ui/quasar.config.js.temporary.compiled.1776255586414.mjs index 4a501bd..e91cb56 100644 --- a/ui/quasar.config.js.temporary.compiled.1776172100358.mjs +++ b/ui/quasar.config.js.temporary.compiled.1776255586414.mjs @@ -27,7 +27,7 @@ var quasar_config_default = defineConfig(() => { /* ===================================================== BOOT FILES ===================================================== */ - boot: ["dayjs"], + boot: ["dayjs", "resizeObserverGuard"], /* ===================================================== GLOBAL CSS ===================================================== */ @@ -62,6 +62,13 @@ var quasar_config_default = defineConfig(() => { server: { type: "http" }, port: 9e3, open: true, + client: { + overlay: { + errors: true, + warnings: false, + runtimeErrors: false + } + }, // DEV proxy (CORS'suz) proxy: [ { diff --git a/ui/src/boot/resizeObserverGuard.js b/ui/src/boot/resizeObserverGuard.js new file mode 100644 index 0000000..2e87630 --- /dev/null +++ b/ui/src/boot/resizeObserverGuard.js @@ -0,0 +1,36 @@ +export default () => { + if (process.env.PROD || typeof window === 'undefined') return + + const isResizeObserverOverlayError = (message) => { + const text = String(message || '') + return ( + text.includes('ResizeObserver loop completed with undelivered notifications') || + text.includes('ResizeObserver loop limit exceeded') + ) + } + + window.addEventListener( + 'error', + (event) => { + if (!isResizeObserverOverlayError(event?.message)) return + event.preventDefault() + event.stopImmediatePropagation() + }, + true + ) + + window.addEventListener( + 'unhandledrejection', + (event) => { + const reason = event?.reason + const msg = + typeof reason === 'string' + ? reason + : (reason?.message || reason?.toString?.() || '') + if (!isResizeObserverOverlayError(msg)) return + event.preventDefault() + event.stopImmediatePropagation() + }, + true + ) +} diff --git a/ui/src/pages/OrderEntry.vue b/ui/src/pages/OrderEntry.vue index fda660d..3d887ec 100644 --- a/ui/src/pages/OrderEntry.vue +++ b/ui/src/pages/OrderEntry.vue @@ -278,8 +278,8 @@ color="primary" icon="save" class="q-ml-sm" - :loading="orderStore.loading" - :disable="!canSubmitOrder" + :loading="orderStore.loading || isSubmitAllInFlight" + :disable="!canSubmitOrder || orderStore.loading || isSubmitAllInFlight" @click="confirmAndSubmit" /> @@ -773,16 +773,18 @@ v-if="canMutateRows" :color="isEditing ? 'positive' : 'primary'" :label="isEditing ? 'Güncelle' : 'Kaydet'" + :loading="isRowSaveInFlight" @click="onSaveOrUpdateRow" - :disable="isClosedRow || isViewOnly || !canMutateRows" + :disable="isClosedRow || isViewOnly || !canMutateRows || isRowSaveInFlight" />
Detay:
${details}` : '' + $q.dialog({ + title: 'Kayit Engellendi', + message: `${message}${detailHtml}`, + html: true, + ok: { label: 'Tamam', color: 'negative' } + }) +} + +function validateEditorRowBeforeSave() { + const adet = Number(form.adet || 0) + const fiyatRaw = String(form.fiyat ?? '').trim() + const fiyat = Number(form.fiyat || 0) + + if (adet <= 0) { + showEditorQtyPriceBlockingDialog('Siparis adeti toplam 0 olamaz.') + return false + } + if (!fiyatRaw || !Number.isFinite(fiyat) || fiyat <= 0) { + showEditorQtyPriceBlockingDialog('Urun fiyati girmeden ilerleyemezsiniz.') + return false + } + return true +} + +function validateSummaryRowsBeforeSubmit() { + const rows = Array.isArray(orderStore.summaryRows) ? orderStore.summaryRows : [] + const invalidRows = rows.filter(r => { + const adet = Number(r?.adet || 0) + const fiyatRaw = String(r?.fiyat ?? '').trim() + const fiyat = Number(r?.fiyat || 0) + return adet <= 0 || !fiyatRaw || !Number.isFinite(fiyat) || fiyat <= 0 + }) + + if (!invalidRows.length) return true + + const preview = invalidRows + .slice(0, 8) + .map(r => `${String(r?.model || '').trim() || '-'} / ${String(r?.renk || '').trim() || '-'} (adet=${Number(r?.adet || 0)}, fiyat=${String(r?.fiyat ?? '')})`) + .join('
') + + showEditorQtyPriceBlockingDialog( + 'Urun fiyati girmeden ilerleyemezsiniz.', + preview + ) + return false +} + const confirmAndSubmit = async () => { - if (orderStore.loading) return + if (orderStore.loading || isSubmitAllInFlight.value) return if (!hasSubmitPermission()) { notifyNoPermission( @@ -951,6 +1005,11 @@ const confirmAndSubmit = async () => { return } + if (!validateSummaryRowsBeforeSubmit()) { + return + } + + isSubmitAllInFlight.value = true try { // NEW veya EDIT ayrımı store.mode üzerinden await orderStore.submitAllReal( @@ -962,6 +1021,8 @@ const confirmAndSubmit = async () => { ) } catch (err) { console.error('❌ confirmAndSubmit hata:', err) + } finally { + isSubmitAllInFlight.value = false } } @@ -3077,6 +3138,8 @@ function warnIfSecondColorMissing() { } const onSaveOrUpdateRow = async () => { + if (isRowSaveInFlight.value) return + if (!hasRowMutationPermission()) { notifyNoPermission( isEditMode.value @@ -3086,23 +3149,32 @@ const onSaveOrUpdateRow = async () => { return } + if (!validateEditorRowBeforeSave()) return + warnIfSecondColorMissing() - await orderStore.saveOrUpdateRowUnified({ - form, + isRowSaveInFlight.value = true + try { + const ok = await orderStore.saveOrUpdateRowUnified({ + form, - recalcVat: typeof recalcVat === 'function' ? recalcVat : null, - resetEditor: typeof resetEditor === 'function' ? resetEditor : null, - loadProductSizes: async () => { - await orderStore.loadProductSizes(form, true, $q, productCache) - await loadOrderInventory(true) - }, + recalcVat: typeof recalcVat === 'function' ? recalcVat : null, + resetEditor: typeof resetEditor === 'function' ? resetEditor : null, + loadProductSizes: async () => { + await orderStore.loadProductSizes(form, true, $q, productCache) + await loadOrderInventory(true) + }, - // gerekiyorsa pass edebilirsin (store tarafında zaten optional) - stockMap, - $q - }) - showEditor.value = false + // gerekiyorsa pass edebilirsin (store tarafında zaten optional) + stockMap, + $q + }) + if (ok !== false) { + showEditor.value = false + } + } finally { + isRowSaveInFlight.value = false + } } function normalizeColorValue(val) { @@ -3122,6 +3194,8 @@ function getNextColorValue() { } const onSaveAndNextColor = async () => { + if (isRowSaveInFlight.value) return + if (!hasRowMutationPermission()) { notifyNoPermission( isEditMode.value @@ -3141,19 +3215,27 @@ const onSaveAndNextColor = async () => { return } + if (!validateEditorRowBeforeSave()) return + warnIfSecondColorMissing() - const ok = await orderStore.saveOrUpdateRowUnified({ - form, - recalcVat: typeof recalcVat === 'function' ? recalcVat : null, - resetEditor: () => {}, - loadProductSizes: async () => { - await orderStore.loadProductSizes(form, true, $q, productCache) - await loadOrderInventory(true) - }, - stockMap, - $q - }) + isRowSaveInFlight.value = true + let ok = false + try { + ok = await orderStore.saveOrUpdateRowUnified({ + form, + recalcVat: typeof recalcVat === 'function' ? recalcVat : null, + resetEditor: () => {}, + loadProductSizes: async () => { + await orderStore.loadProductSizes(form, true, $q, productCache) + await loadOrderInventory(true) + }, + stockMap, + $q + }) + } finally { + isRowSaveInFlight.value = false + } if (!ok) return diff --git a/ui/src/pages/OrderProductionUpdate.vue b/ui/src/pages/OrderProductionUpdate.vue index cc6684b..10a7d6a 100644 --- a/ui/src/pages/OrderProductionUpdate.vue +++ b/ui/src/pages/OrderProductionUpdate.vue @@ -17,7 +17,7 @@ icon="save" label="Secili Degisiklikleri Kaydet" :loading="store.saving" - :disable="store.loading" + :disable="store.loading || store.saving || isBulkSubmitting" @click="onBulkSubmit" /> @@ -391,7 +391,7 @@ - + @@ -441,10 +441,12 @@ const headerAverageDueDate = ref('') const cdItemDialogOpen = ref(false) const cdItemTargetCode = ref('') const copySourceCode = ref(null) +const suppressAutoSetupDialogs = ref(false) const cdItemDraftForm = ref(createEmptyCdItemDraft('')) const attributeDialogOpen = ref(false) const attributeTargetCode = ref('') const attributeRows = ref([]) +const isBulkSubmitting = ref(false) const columns = [ { name: 'select', label: '', field: 'select', align: 'center', sortable: false, style: 'width:44px;', headerStyle: 'width:44px;' }, @@ -669,13 +671,14 @@ function onNewItemChange (row, val, source = 'typed') { row.NewColor = '' row.NewDim2 = '' row.NewDesc = mergeDescWithAutoNote(row, row.NewDesc || row.OldDesc) - if (row.NewItemCode) { + if (row.NewItemCode && isValidBaggiModelCode(row.NewItemCode)) { if (row.NewItemMode === 'new') { store.fetchNewColors(row.NewItemCode) } else { store.fetchColors(row.NewItemCode) } } + if (suppressAutoSetupDialogs.value) return if (row.NewItemMode === 'new' && isValidBaggiModelCode(row.NewItemCode) && row.NewItemCode !== prevCode) { openNewCodeSetupFlow(row.NewItemCode) } else if (row.NewItemMode === 'existing' && isValidBaggiModelCode(row.NewItemCode) && row.NewItemCode !== prevCode) { @@ -902,6 +905,59 @@ function collectLinesFromRows (selectedRows) { return { errMsg: '', lines } } +function hasRowChange (row) { + const newItemCode = String(row?.NewItemCode || '').trim().toUpperCase() + const newColor = normalizeShortCode(row?.NewColor, 3) + const newDim2 = normalizeShortCode(row?.NewDim2, 3) + const newDesc = mergeDescWithAutoNote(row, row?.NewDesc || row?.OldDesc) + const oldItemCode = String(row?.OldItemCode || '').trim().toUpperCase() + const oldColor = normalizeShortCode(row?.OldColor, 3) + const oldDim2 = normalizeShortCode(row?.OldDim2, 3) + const oldDesc = String(row?.OldDesc || '').trim() + const oldDueDateValue = row?.OldDueDate || '' + const newDueDateValue = row?.NewDueDate || '' + + return ( + newItemCode !== oldItemCode || + newColor !== oldColor || + newDim2 !== oldDim2 || + String(newDesc || '').trim() !== oldDesc || + newDueDateValue !== oldDueDateValue + ) +} + +function collectOptionalColorWarnings (rows) { + const warnings = [] + for (const row of (rows || [])) { + const code = String(row?.NewItemCode || '').trim().toUpperCase() + if (!code) continue + const color = normalizeShortCode(row?.NewColor, 3) + const dim2 = normalizeShortCode(row?.NewDim2, 3) + if (!color) { + warnings.push(`${code} icin renk secmediniz.`) + continue + } + if (!dim2) { + warnings.push(`${code} icin 2. renk bos kalacak.`) + } + } + return [...new Set(warnings)] +} + +function confirmOptionalColorWarnings (rows) { + const warnings = collectOptionalColorWarnings(rows) + if (!warnings.length) return Promise.resolve(true) + return new Promise((resolve) => { + $q.dialog({ + title: 'Renk Uyarisi', + message: `${warnings.join('
')}

Devam etmek istiyor musunuz?`, + html: true, + ok: { label: 'Evet, Devam Et', color: 'warning' }, + cancel: { label: 'Vazgec', flat: true } + }).onOk(() => resolve(true)).onCancel(() => resolve(false)).onDismiss(() => resolve(false)) + }) +} + function createEmptyCdItemDraft (itemCode) { return { ItemTypeCode: '1', @@ -970,13 +1026,15 @@ async function copyFromOldProduct (targetType = 'cdItem') { if (targetType === 'cdItem') { const data = await store.fetchCdItemByCode(sourceCode) if (data) { - const targetCode = cdItemTargetCode.value + const targetCode = String(cdItemTargetCode.value || '').trim().toUpperCase() const draft = createEmptyCdItemDraft(targetCode) for (const k of Object.keys(draft)) { if (data[k] !== undefined && data[k] !== null) { draft[k] = String(data[k]) } } + // Source item kopyalansa da hedef popup kodu degismemeli. + draft.ItemCode = targetCode cdItemDraftForm.value = draft persistCdItemDraft() $q.notify({ type: 'positive', message: 'Boyutlandirma bilgileri kopyalandi.' }) @@ -1038,7 +1096,11 @@ async function openCdItemDialog (itemCode) { } function persistCdItemDraft () { - const payload = normalizeCdItemDraftForPayload(cdItemDraftForm.value) + const targetCode = String(cdItemTargetCode.value || '').trim().toUpperCase() + const payload = normalizeCdItemDraftForPayload({ + ...(cdItemDraftForm.value || {}), + ItemCode: targetCode || String(cdItemDraftForm.value?.ItemCode || '').trim().toUpperCase() + }) if (!payload.ItemCode) return null store.setCdItemDraft(payload.ItemCode, payload) return payload @@ -1183,7 +1245,7 @@ async function openAttributeDialog (itemCode) { if (!code) return copySourceCode.value = null attributeTargetCode.value = code - const existingDraft = store.getProductAttributeDraft(code) + const existingDraft = JSON.parse(JSON.stringify(store.getProductAttributeDraft(code) || [])) const modeInfo = store.classifyItemCode(code) const fetched = await store.fetchProductAttributes(1) const fromLookup = buildAttributeRowsFromLookup(fetched) @@ -1197,6 +1259,32 @@ async function openAttributeDialog (itemCode) { $q.notify({ type: 'negative', message: 'Urun ozellikleri listesi alinamadi. Lutfen daha sonra tekrar deneyin.' }) return } + + // Draft varsa popup her zaman draft'tan acilir (yeniden acinca secimler kaybolmasin). + if (Array.isArray(existingDraft) && existingDraft.length) { + attributeRows.value = JSON.parse(JSON.stringify( + mergeAttributeDraftWithLookupOptions(existingDraft, fromLookup) + )) + console.info('[OrderProductionUpdate] openAttributeDialog rowsPrepared', { + code, + mode: modeInfo.mode, + useDraft: true, + rowCount: Array.isArray(attributeRows.value) ? attributeRows.value.length : 0, + optionCounts: (attributeRows.value || []).map(r => ({ + type: Number(r?.AttributeTypeCodeNumber || 0), + options: Array.isArray(r?.Options) ? r.Options.length : 0, + allOptions: Array.isArray(r?.AllOptions) ? r.AllOptions.length : 0, + selected: String(r?.AttributeCode || '').trim() + })) + }) + for (const row of (attributeRows.value || [])) { + if (!Array.isArray(row.AllOptions)) row.AllOptions = Array.isArray(row.Options) ? [...row.Options] : [] + if (!Array.isArray(row.Options)) row.Options = [...row.AllOptions] + } + attributeDialogOpen.value = true + return + } + const dbCurrent = await store.fetchProductItemAttributes(code, 1, true) console.info('[OrderProductionUpdate] openAttributeDialog dbCurrent', { code, @@ -1232,13 +1320,11 @@ async function openAttributeDialog (itemCode) { }) const useDraft = Array.isArray(existingDraft) && existingDraft.length - attributeRows.value = useDraft - ? JSON.parse(JSON.stringify(mergeAttributeDraftWithLookupOptions(existingDraft, baseRows))) - : JSON.parse(JSON.stringify(baseRows)) + attributeRows.value = JSON.parse(JSON.stringify(baseRows)) console.info('[OrderProductionUpdate] openAttributeDialog rowsPrepared', { code, mode: modeInfo.mode, - useDraft, + useDraft: false, rowCount: Array.isArray(attributeRows.value) ? attributeRows.value.length : 0, optionCounts: (attributeRows.value || []).map(r => ({ type: Number(r?.AttributeTypeCodeNumber || 0), @@ -1255,27 +1341,26 @@ async function openAttributeDialog (itemCode) { row.Options = [...row.AllOptions] } } - if ((!existingDraft || !existingDraft.length) && baseRows.length) { - store.setProductAttributeDraft(code, JSON.parse(JSON.stringify(baseRows))) - } attributeDialogOpen.value = true } function saveAttributeDraft () { const code = String(attributeTargetCode.value || '').trim().toUpperCase() if (!code) return - for (const row of (attributeRows.value || [])) { + const rows = JSON.parse(JSON.stringify(attributeRows.value || [])) + for (const row of rows) { const selected = String(row?.AttributeCode || '').trim() if (!selected) { $q.notify({ type: 'negative', message: `Urun ozelliklerinde secim zorunlu: ${row?.TypeLabel || ''}` }) return } } - store.setProductAttributeDraft(code, JSON.parse(JSON.stringify(attributeRows.value || []))) + store.setProductAttributeDraft(code, rows) console.info('[OrderProductionUpdate] saveAttributeDraft', { code, - rowCount: (attributeRows.value || []).length, - selected: (attributeRows.value || []).map(r => ({ + rowCount: rows.length, + selectedCount: rows.length, + selected: rows.map(r => ({ type: Number(r?.AttributeTypeCodeNumber || 0), code: String(r?.AttributeCode || '').trim() })) @@ -1293,17 +1378,6 @@ watch( { deep: true } ) -watch( - attributeRows, - (rows) => { - if (!attributeDialogOpen.value) return - const code = String(attributeTargetCode.value || '').trim().toUpperCase() - if (!code) return - store.setProductAttributeDraft(code, JSON.parse(JSON.stringify(rows || []))) - }, - { deep: true } -) - async function collectProductAttributesFromSelectedRows (selectedRows) { const codeSet = [...new Set( (selectedRows || []) @@ -1315,22 +1389,24 @@ async function collectProductAttributesFromSelectedRows (selectedRows) { for (const code of codeSet) { const modeInfo = store.classifyItemCode(code) let rows = store.getProductAttributeDraft(code) - let dbMap = new Map() + const dbCurrent = await store.fetchProductItemAttributes(code, 1, true) + const dbMap = new Map( + (dbCurrent || []).map(x => [ + Number(x?.attribute_type_code || x?.AttributeTypeCode || 0), + String(x?.attribute_code || x?.AttributeCode || '').trim() + ]).filter(x => x[0] > 0) + ) + const hasDbAttributes = dbMap.size > 0 + const effectiveMode = hasDbAttributes ? 'existing' : modeInfo.mode console.info('[OrderProductionUpdate] collectProductAttributes start', { code, mode: modeInfo.mode, + effectiveMode, + hasDbAttributes, draftRowCount: Array.isArray(rows) ? rows.length : 0 }) - if (modeInfo.mode === 'existing') { - const dbCurrent = await store.fetchProductItemAttributes(code, 1, true) - dbMap = new Map( - (dbCurrent || []).map(x => [ - Number(x?.attribute_type_code || x?.AttributeTypeCode || 0), - String(x?.attribute_code || x?.AttributeCode || '').trim() - ]).filter(x => x[0] > 0) - ) - + if (effectiveMode === 'existing') { // Existing kodda kullanıcı değişiklik yaptıysa draftı koru. // Draft yoksa DB'den zorunlu/fresh çek. if (!Array.isArray(rows) || !rows.length) { @@ -1360,26 +1436,7 @@ async function collectProductAttributesFromSelectedRows (selectedRows) { store.setProductAttributeDraft(code, JSON.parse(JSON.stringify(rows))) } } else if (!Array.isArray(rows) || !rows.length) { - const lookup = await store.fetchProductAttributes(1) - const baseRows = buildAttributeRowsFromLookup(lookup) - const dbCurrent = await store.fetchProductItemAttributes(code, 1, true) - const dbMap = new Map( - (dbCurrent || []).map(x => [ - Number(x?.attribute_type_code || x?.AttributeTypeCode || 0), - String(x?.attribute_code || x?.AttributeCode || '').trim() - ]).filter(x => x[0] > 0) - ) - rows = baseRows.map(row => ({ - ...row, - AttributeCode: dbMap.get(Number(row.AttributeTypeCodeNumber || 0)) || '' - })) - console.info('[OrderProductionUpdate] collectProductAttributes new init', { - code, - lookupCount: Array.isArray(lookup) ? lookup.length : 0, - baseRowCount: baseRows.length, - dbCurrentCount: Array.isArray(dbCurrent) ? dbCurrent.length : 0 - }) - store.setProductAttributeDraft(code, JSON.parse(JSON.stringify(rows))) + return { errMsg: `${code} icin urun ozellikleri taslagi kaydedilmedi`, productAttributes: [] } } if (!Array.isArray(rows) || !rows.length) { @@ -1393,7 +1450,7 @@ async function collectProductAttributesFromSelectedRows (selectedRows) { return { errMsg: `${code} icin urun ozellikleri eksik`, productAttributes: [] } } - if (modeInfo.mode === 'existing') { + if (effectiveMode === 'existing') { const originalCode = dbMap.get(attributeTypeCode) || String(row?.OriginalAttributeCode || '').trim() @@ -1416,6 +1473,7 @@ async function collectProductAttributesFromSelectedRows (selectedRows) { console.info('[OrderProductionUpdate] collectProductAttributes done', { code, mode: modeInfo.mode, + effectiveMode, outCount: out.filter(x => x.ItemCode === code).length, rowCount: rows.length, optionCounts: rows.map(r => ({ @@ -1744,60 +1802,77 @@ async function refreshAll () { } async function onBulkSubmit () { + if (isBulkSubmitting.value || store.saving) { + console.info('[OrderProductionUpdate] onBulkSubmit ignored (already running)', { + orderHeaderID: orderHeaderID.value, + isBulkSubmitting: isBulkSubmitting.value, + storeSaving: store.saving + }) + return + } + + isBulkSubmitting.value = true const flowStart = nowMs() - const selectedRows = rows.value.filter(r => !!selectedMap.value[r.RowKey]) - const headerAverageDueDateValue = normalizeDateInput(headerAverageDueDate.value) - const headerDateChanged = hasHeaderAverageDueDateChange.value - if (!selectedRows.length && !headerDateChanged) { - $q.notify({ type: 'warning', message: 'Lutfen en az bir satir seciniz veya ustteki termin tarihini degistiriniz.' }) - return - } - - const prepStart = nowMs() - const { errMsg, lines } = collectLinesFromRows(selectedRows) - if (errMsg) { - $q.notify({ type: 'negative', message: errMsg }) - return - } - if (!lines.length && !headerDateChanged) { - $q.notify({ type: 'warning', message: 'Secili satirlarda degisiklik yok.' }) - return - } - - let cdItems = [] - let productAttributes = [] - if (lines.length > 0) { - const { errMsg: cdErrMsg, cdItems: nextCdItems } = await collectCdItemsFromSelectedRows(selectedRows) - if (cdErrMsg) { - $q.notify({ type: 'negative', message: cdErrMsg }) - const firstCode = String(cdErrMsg.split(' ')[0] || '').trim() - if (firstCode) openCdItemDialog(firstCode) - return - } - cdItems = nextCdItems - - const { errMsg: attrErrMsg, productAttributes: nextProductAttributes } = await collectProductAttributesFromSelectedRows(selectedRows) - if (attrErrMsg) { - $q.notify({ type: 'negative', message: attrErrMsg }) - const firstCode = String(attrErrMsg.split(' ')[0] || '').trim() - if (firstCode) openAttributeDialog(firstCode) - return - } - productAttributes = nextProductAttributes - } - - console.info('[OrderProductionUpdate] onBulkSubmit prepared', { - orderHeaderID: orderHeaderID.value, - selectedRowCount: selectedRows.length, - lineCount: lines.length, - cdItemCount: cdItems.length, - attributeCount: productAttributes.length, - headerAverageDueDate: headerAverageDueDateValue, - headerDateChanged, - prepDurationMs: Math.round(nowMs() - prepStart) - }) - try { + suppressAutoSetupDialogs.value = true + const selectedRows = rows.value.filter(r => !!selectedMap.value[r.RowKey]) + const headerAverageDueDateValue = normalizeDateInput(headerAverageDueDate.value) + const headerDateChanged = hasHeaderAverageDueDateChange.value + if (!selectedRows.length && !headerDateChanged) { + $q.notify({ type: 'warning', message: 'Lutfen en az bir satir seciniz veya ustteki termin tarihini degistiriniz.' }) + return + } + + const prepStart = nowMs() + const { errMsg, lines } = collectLinesFromRows(selectedRows) + if (errMsg) { + $q.notify({ type: 'negative', message: errMsg }) + return + } + if (!lines.length && !headerDateChanged) { + $q.notify({ type: 'warning', message: 'Secili satirlarda degisiklik yok.' }) + return + } + + if (lines.length > 0) { + const changedRows = selectedRows.filter(hasRowChange) + const confirmed = await confirmOptionalColorWarnings(changedRows) + if (!confirmed) return + } + + let cdItems = [] + let productAttributes = [] + if (lines.length > 0) { + const { errMsg: cdErrMsg, cdItems: nextCdItems } = await collectCdItemsFromSelectedRows(selectedRows) + if (cdErrMsg) { + $q.notify({ type: 'negative', message: cdErrMsg }) + return + } + cdItems = nextCdItems + + const { errMsg: attrErrMsg, productAttributes: nextProductAttributes } = await collectProductAttributesFromSelectedRows(selectedRows) + if (attrErrMsg) { + $q.notify({ type: 'negative', message: attrErrMsg }) + const firstCode = String(attrErrMsg.split(' ')[0] || '').trim().toUpperCase() + if (isValidBaggiModelCode(firstCode)) { + await openAttributeDialog(firstCode) + } + return + } + productAttributes = nextProductAttributes + } + + console.info('[OrderProductionUpdate] onBulkSubmit prepared', { + orderHeaderID: orderHeaderID.value, + selectedRowCount: selectedRows.length, + lineCount: lines.length, + cdItemCount: cdItems.length, + attributeCount: productAttributes.length, + headerAverageDueDate: headerAverageDueDateValue, + headerDateChanged, + prepDurationMs: Math.round(nowMs() - prepStart) + }) + const applyChanges = async (insertMissing) => { const applyStart = nowMs() const applyResult = await store.applyUpdates( @@ -1831,7 +1906,7 @@ async function onBulkSubmit () { if (lines.length > 0) { const validateStart = nowMs() - const validate = await store.validateUpdates(orderHeaderID.value, lines) + const validate = await store.validateUpdates(orderHeaderID.value, lines, cdItems) console.info('[OrderProductionUpdate] validate finished', { orderHeaderID: orderHeaderID.value, lineCount: lines.length, @@ -1875,11 +1950,14 @@ async function onBulkSubmit () { return } $q.notify({ type: 'negative', message: store.error || 'Toplu kayit islemi basarisiz.' }) + } finally { + isBulkSubmitting.value = false + suppressAutoSetupDialogs.value = false + console.info('[OrderProductionUpdate] onBulkSubmit total', { + orderHeaderID: orderHeaderID.value, + durationMs: Math.round(nowMs() - flowStart) + }) } - console.info('[OrderProductionUpdate] onBulkSubmit total', { - orderHeaderID: orderHeaderID.value, - durationMs: Math.round(nowMs() - flowStart) - }) } diff --git a/ui/src/stores/OrderProductionItemStore.js b/ui/src/stores/OrderProductionItemStore.js index 36a527b..a7294d7 100644 --- a/ui/src/stores/OrderProductionItemStore.js +++ b/ui/src/stores/OrderProductionItemStore.js @@ -40,6 +40,8 @@ function nowMs () { return Date.now() } +const applyInFlightByOrder = new Map() + const YAS_NUMERIC_SIZES = new Set(['2', '4', '6', '8', '10', '12', '14']) function safeStr (value) { @@ -423,7 +425,7 @@ export const useOrderProductionItemStore = defineStore('orderproductionitems', { if (!code) return [] return this.productAttributeDraftsByCode[code] || [] }, - async validateUpdates (orderHeaderID, lines) { + async validateUpdates (orderHeaderID, lines, cdItems = []) { if (!orderHeaderID) return { missingCount: 0, missing: [] } this.saving = true @@ -434,7 +436,7 @@ export const useOrderProductionItemStore = defineStore('orderproductionitems', { console.info('[OrderProductionItemStore] validateUpdates start', { orderHeaderID, lineCount: lines?.length || 0 }) const res = await api.post( `/orders/production-items/${encodeURIComponent(orderHeaderID)}/validate`, - { lines } + { lines, cdItems } ) const data = res?.data || { missingCount: 0, missing: [] } const rid = res?.headers?.['x-debug-request-id'] || '' @@ -458,48 +460,69 @@ export const useOrderProductionItemStore = defineStore('orderproductionitems', { async applyUpdates (orderHeaderID, lines, insertMissing, cdItems = [], productAttributes = [], headerAverageDueDate = null) { if (!orderHeaderID) return { updated: 0, inserted: 0 } - this.saving = true - this.error = null + const orderKey = String(orderHeaderID).trim().toUpperCase() + if (applyInFlightByOrder.has(orderKey)) { + console.warn('[OrderProductionItemStore] applyUpdates deduped (in-flight)', { + orderHeaderID: orderKey, + lineCount: lines?.length || 0 + }) + return await applyInFlightByOrder.get(orderKey) + } + + const applyPromise = (async () => { + this.saving = true + this.error = null + + try { + const t0 = nowMs() + console.info('[OrderProductionItemStore] applyUpdates start', { + orderHeaderID, + lineCount: lines?.length || 0, + insertMissing: !!insertMissing, + cdItemCount: cdItems?.length || 0, + attributeCount: productAttributes?.length || 0, + headerAverageDueDate + }) + const res = await api.post( + `/orders/production-items/${encodeURIComponent(orderHeaderID)}/apply`, + { + lines, + insertMissing, + cdItems, + productAttributes, + HeaderAverageDueDate: headerAverageDueDate + } + ) + const data = res?.data || { updated: 0, inserted: 0 } + const rid = res?.headers?.['x-debug-request-id'] || '' + console.info('[OrderProductionItemStore] applyUpdates done', { + orderHeaderID, + updated: Number(data?.updated || 0), + inserted: Number(data?.inserted || 0), + barcodeInserted: Number(data?.barcodeInserted || 0), + attributeUpserted: Number(data?.attributeUpserted || 0), + headerUpdated: !!data?.headerUpdated, + requestId: rid, + durationMs: Math.round(nowMs() - t0) + }) + return data + } catch (err) { + logApiError('applyUpdates', err, { orderHeaderID, lineCount: lines?.length || 0, insertMissing }) + this.error = extractApiErrorMessage(err, 'Guncelleme basarisiz') + throw err + } finally { + this.saving = false + } + })() + + applyInFlightByOrder.set(orderKey, applyPromise) try { - const t0 = nowMs() - console.info('[OrderProductionItemStore] applyUpdates start', { - orderHeaderID, - lineCount: lines?.length || 0, - insertMissing: !!insertMissing, - cdItemCount: cdItems?.length || 0, - attributeCount: productAttributes?.length || 0, - headerAverageDueDate - }) - const res = await api.post( - `/orders/production-items/${encodeURIComponent(orderHeaderID)}/apply`, - { - lines, - insertMissing, - cdItems, - productAttributes, - HeaderAverageDueDate: headerAverageDueDate - } - ) - const data = res?.data || { updated: 0, inserted: 0 } - const rid = res?.headers?.['x-debug-request-id'] || '' - console.info('[OrderProductionItemStore] applyUpdates done', { - orderHeaderID, - updated: Number(data?.updated || 0), - inserted: Number(data?.inserted || 0), - barcodeInserted: Number(data?.barcodeInserted || 0), - attributeUpserted: Number(data?.attributeUpserted || 0), - headerUpdated: !!data?.headerUpdated, - requestId: rid, - durationMs: Math.round(nowMs() - t0) - }) - return data - } catch (err) { - logApiError('applyUpdates', err, { orderHeaderID, lineCount: lines?.length || 0, insertMissing }) - this.error = extractApiErrorMessage(err, 'Guncelleme basarisiz') - throw err + return await applyPromise } finally { - this.saving = false + if (applyInFlightByOrder.get(orderKey) === applyPromise) { + applyInFlightByOrder.delete(orderKey) + } } } }