From 295924cf1ef2ea2c3340f162f88242dbca73a352 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Fri, 15 May 2026 19:43:44 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/main.go | 15 + svc/models/production_product_costing.go | 35 ++ svc/queries/production_product_costing.go | 291 ++++++++-- svc/routes/login.go | 7 + svc/routes/mk_mail_helper.go | 68 +++ svc/routes/production_product_costing.go | 483 +++++++++++++---- svc/routes/user_detail.go | 7 + ui/.quasar/prod-spa/app.js | 75 --- ui/.quasar/prod-spa/client-entry.js | 158 ------ ui/.quasar/prod-spa/client-prefetch.js | 116 ---- ui/.quasar/prod-spa/quasar-user-options.js | 23 - ...g.js.temporary.compiled.1778860298326.mjs} | 0 ...ductionProductCostingDefaultQuantities.vue | 111 ++-- .../ProductionProductCostingHasCostDetail.vue | 511 ++++++++++++++++-- ...ProductionProductCostingMTBolumMapping.vue | 193 ++++++- ...productionProductCostingDefaultQtyStore.js | 8 +- 16 files changed, 1500 insertions(+), 601 deletions(-) create mode 100644 svc/routes/mk_mail_helper.go 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.1778776531494.mjs => quasar.config.js.temporary.compiled.1778860298326.mjs} (100%) diff --git a/svc/main.go b/svc/main.go index be8dee7..5b4219e 100644 --- a/svc/main.go +++ b/svc/main.go @@ -796,11 +796,26 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "order", "view", wrapV3(http.HandlerFunc(routes.PostProductionHasCostDetailBulkPricesHandler)), ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/has-cost-detail/last-detail", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingHasCostDetailLastDetailHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/options/hammadde-by-nos", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOptionsHammaddeByNosHandler)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/save", "POST", "order", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLSaveHandler)), ) + bindV3(r, pgDB, + "/api/pricing/production-product-costing/onml/delete", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLDeleteHandler)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities", "GET", "order", "view", diff --git a/svc/models/production_product_costing.go b/svc/models/production_product_costing.go index fbdc0f7..f49b28c 100644 --- a/svc/models/production_product_costing.go +++ b/svc/models/production_product_costing.go @@ -149,6 +149,7 @@ type ProductionProductCostingOnMLSaveDetailUpsertRow struct { FiyatDoviz string `json:"fiyat_doviz"` MaliyeteDahil int `json:"maliyete_dahil"` CMPriceTypeID *int `json:"cm_price_type_id"` + SAciklama3 string `json:"s_aciklama3"` } type ProductionProductCostingOnMLSaveDetailDeleteRow struct { @@ -170,6 +171,10 @@ type ProductionProductCostingOnMLSaveResponse struct { NOnMLNo int `json:"n_onml_no"` } +type ProductionProductCostingOnMLDeleteRequest struct { + NOnMLNo int `json:"n_onml_no"` +} + // ============================================================ // Default quantities (URETIM): mk_MaliyetParcaEslestirme_vmiktarlar // ============================================================ @@ -209,9 +214,39 @@ type ProductionProductCostingDefaultQtyLookupRequest struct { type ProductionProductCostingDefaultQtyLookupItem struct { NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + SAciklama string `json:"sAciklama"` LDefaultMiktar float64 `json:"lDefaultMiktar"` } +type ProductionProductCostingLastOnMLDetLookupRequest struct { + NHammaddeTuruNos []int `json:"nHammaddeTuruNos"` + BeforeDate string `json:"before_date"` // YYYY-MM-DD (optional) + ExcludeOnMLNo int `json:"exclude_onml_no"` // optional + NFirmaID int `json:"n_firma_id"` // optional + OnlyICode bool `json:"only_i_code"` // optional: restrict to sKodu like 'I.%' +} + +type ProductionProductCostingLastOnMLDetLookupItem struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + SKodu string `json:"sKodu"` + SAciklama string `json:"sAciklama"` + SBirim string `json:"sBirim"` + FiyatDoviz string `json:"fiyat_doviz"` + FiyatGirilen float64 `json:"fiyat_girilen"` + IsSameFirma bool `json:"is_same_firma"` +} + +type ProductionProductCostingHammaddeByNosRequest struct { + NHammaddeTuruNos []int `json:"nHammaddeTuruNos"` +} + +type ProductionProductCostingHammaddeByNosItem struct { + NHammaddeTuruNo int `json:"nHammaddeTuruNo"` + SAciklama string `json:"sAciklama"` + MTUrtMTBolumID int `json:"mtUrtMTBolumID"` + SParcaAdi string `json:"sParcaAdi"` +} + type ProductionHasCostDetailExchangeRates struct { RateDate string `json:"rateDate"` TRYRate float64 `json:"tryRate"` diff --git a/svc/queries/production_product_costing.go b/svc/queries/production_product_costing.go index 7faca79..be5444d 100644 --- a/svc/queries/production_product_costing.go +++ b/svc/queries/production_product_costing.go @@ -1,6 +1,7 @@ package queries import ( + "bssapp-backend/models" "bssapp-backend/utils" "context" "database/sql" @@ -227,17 +228,47 @@ func UpsertV3ItemBasePriceUSD( return fmt.Errorf("missing params for base price upsert") } - // NOTE: In this DB, PRIMARY KEY is on: - // (ItemTypeCode, ItemCode, CountryCode, SeasonCode, BasePriceCode) - // so we cannot insert multiple rows for different dates under the same base price. - // We update the single row's PriceDate/Price to reflect latest costing. + // 1. Find a CountryCode that is NOT yet used for this specific item in prItemBasePrice. + // We query cdCountry for a code that has no matching entry in prItemBasePrice for this item. + // We exclude 'TR' to keep the original record safe. + var targetCountry string + err := mssqlDB.QueryRowContext(ctx, ` +SELECT TOP 1 C.CountryCode +FROM dbo.cdCountry C WITH (NOLOCK) +WHERE C.CountryCode <> 'TR' + AND NOT EXISTS ( + SELECT 1 FROM dbo.prItemBasePrice P WITH (NOLOCK) + WHERE P.ItemTypeCode = 1 + AND LTRIM(RTRIM(P.ItemCode)) = LTRIM(RTRIM(@p1)) + AND P.BasePriceCode = 1 + AND P.CountryCode = C.CountryCode + ) +ORDER BY NEWID() -- Randomly pick one of the available country codes +`, itemCode).Scan(&targetCountry) + + if err != nil { + if err == sql.ErrNoRows { + // Fallback: If ALL countries are exhausted (unlikely), default to 'AD' as a last resort. + targetCountry = "AD" + } else { + return fmt.Errorf("failed to find available country code: %w", err) + } + } + + appUser := user + if strings.TrimSpace(appUser) == "" { + appUser = "BSSAPP" + } else { + appUser = "BSSAPP:" + strings.TrimSpace(appUser) + } + sqlText := ` MERGE dbo.prItemBasePrice AS T USING ( SELECT @p1 AS ItemTypeCode, @p2 AS ItemCode, - 'TR' AS CountryCode, + @p6 AS CountryCode, '' AS SeasonCode, 1 AS BasePriceCode, CONVERT(date, @p3, 23) AS PriceDate, @@ -266,7 +297,7 @@ WHEN NOT MATCHED THEN ); ` - _, err := mssqlDB.ExecContext(ctx, sqlText, 1, itemCode, priceDate, priceUSD, user) + _, err = mssqlDB.ExecContext(ctx, sqlText, 1, itemCode, priceDate, priceUSD, appUser, targetCountry) return err } @@ -1228,6 +1259,73 @@ ORDER BY return uretimDB.QueryContext(ctx, sqlText, search, limit, searchLike) } +func GetProductionHammaddeByNos(ctx context.Context, uretimDB *sql.DB, nos []int) ([]models.ProductionProductCostingHammaddeByNosItem, error) { + clean := make([]int, 0, len(nos)) + seen := make(map[int]struct{}, len(nos)) + for _, n := range nos { + if n <= 0 { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + clean = append(clean, n) + } + if len(clean) == 0 { + return []models.ProductionProductCostingHammaddeByNosItem{}, nil + } + if len(clean) > 5000 { + return nil, fmt.Errorf("too many nos") + } + + valueRows := make([]string, 0, len(clean)) + args := make([]any, 0, len(clean)) + for i, n := range clean { + valueRows = append(valueRows, "(@p"+strconv.Itoa(i+1)+")") + args = append(args, n) + } + + sqlText := ` +WITH req(nHammaddeTuruNo) AS ( + SELECT DISTINCT v.nHammaddeTuruNo + FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(nHammaddeTuruNo) +) +SELECT + ISNULL(T.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + ISNULL(T.sAciklama, '') AS sAciklama, + ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID, + ISNULL(B.sAdi, '') AS sParcaAdi +FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK) +INNER JOIN req R + ON R.nHammaddeTuruNo = T.nHammaddeTuruNo +LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) + ON B.nUrtMTBolumID = T.MTnUrtMTBolumID + AND ISNULL(B.nUrtTipiID, 0) = 1 +WHERE ISNULL(T.bAktif, 0) = 1 +ORDER BY T.nHammaddeTuruNo; +` + + rows, err := uretimDB.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]models.ProductionProductCostingHammaddeByNosItem, 0, len(clean)) + for rows.Next() { + var it models.ProductionProductCostingHammaddeByNosItem + if err := rows.Scan(&it.NHammaddeTuruNo, &it.SAciklama, &it.MTUrtMTBolumID, &it.SParcaAdi); err != nil { + return nil, err + } + out = append(out, it) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + // ============================================================ // Default quantities (URETIM): mk_MaliyetParcaEslestirme_vmiktarlar // ============================================================ @@ -1248,7 +1346,9 @@ SELECT TOP (@p3) ISNULL(H.sAciklama, '') AS sAciklama, ISNULL(V.lDefaultMiktar, 0) AS lDefaultMiktar, CONVERT(VARCHAR(16), V.dteCalcTarihi, 120) AS dteCalcTarihi, - CAST(CASE WHEN ISNULL(V.bAktif, 0) = 1 THEN 1 ELSE 0 END AS bit) AS bAktif + -- We intentionally don't depend on mk_MaliyetParcaEslestirme_vmiktarlar.bAktif + -- to keep this feature robust across schema changes; only active hammadde types are listed. + CAST(1 AS bit) AS bAktif FROM dbo.mk_MaliyetParcaEslestirme_vmiktarlar V WITH (NOLOCK) LEFT JOIN dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) ON H.nHammaddeTuruNo = V.nHammaddeTuruNo @@ -1264,26 +1364,16 @@ ORDER BY func UpsertProductionProductCostingDefaultQtyRow(ctx context.Context, uretimDB *sql.DB, nHammaddeTuruNo int, lDefaultMiktar float64, bAktif *bool) error { // NOTE: Legacy helper kept for backward-compat but now behaves as UPDATE-only. - activeVal := -1 - if bAktif != nil { - if *bAktif { - activeVal = 1 - } else { - activeVal = 0 - } - } - sqlText := ` UPDATE dbo.mk_MaliyetParcaEslestirme_vmiktarlar SET lDefaultMiktar = @p2, - dteCalcTarihi = GETDATE(), - bAktif = CASE WHEN @p3 < 0 THEN ISNULL(bAktif, 1) ELSE @p3 END + dteCalcTarihi = GETDATE() WHERE nHammaddeTuruNo = @p1; SELECT @@ROWCOUNT; ` var affected int - if err := uretimDB.QueryRowContext(ctx, sqlText, nHammaddeTuruNo, lDefaultMiktar, activeVal).Scan(&affected); err != nil { + if err := uretimDB.QueryRowContext(ctx, sqlText, nHammaddeTuruNo, lDefaultMiktar).Scan(&affected); err != nil { return err } if affected == 0 { @@ -1333,11 +1423,10 @@ USING agg AS S WHEN MATCHED THEN UPDATE SET T.lDefaultMiktar = S.lDefaultMiktar, - T.dteCalcTarihi = GETDATE(), - T.bAktif = 1 + T.dteCalcTarihi = GETDATE() WHEN NOT MATCHED THEN - INSERT (nHammaddeTuruNo, lDefaultMiktar, dteCalcTarihi, bAktif) - VALUES (S.nHammaddeTuruNo, S.lDefaultMiktar, GETDATE(), 1); + INSERT (nHammaddeTuruNo, lDefaultMiktar, dteCalcTarihi) + VALUES (S.nHammaddeTuruNo, S.lDefaultMiktar, GETDATE()); ` _, err := uretimDB.ExecContext(ctx, sqlText, topN) return err @@ -1377,7 +1466,11 @@ FROM ranked; return avg, cnt, nil } -func LookupProductionProductCostingDefaultQtyByNos(ctx context.Context, uretimDB *sql.DB, nos []int) (map[int]float64, error) { +func LookupProductionProductCostingDefaultQtyByNos(ctx context.Context, uretimDB *sql.DB, nos []int) ([]struct { + No int + Aciklama string + Qty float64 +}, error) { clean := make([]int, 0, len(nos)) seen := make(map[int]struct{}, len(nos)) for _, n := range nos { @@ -1391,7 +1484,11 @@ func LookupProductionProductCostingDefaultQtyByNos(ctx context.Context, uretimDB clean = append(clean, n) } if len(clean) == 0 { - return map[int]float64{}, nil + return []struct { + No int + Aciklama string + Qty float64 + }{}, nil } if len(clean) > 1000 { return nil, fmt.Errorf("too many hammadde nos") @@ -1409,9 +1506,10 @@ WITH req(nHammaddeTuruNo) AS ( SELECT DISTINCT v.nHammaddeTuruNo FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(nHammaddeTuruNo) ) -SELECT - ISNULL(V.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, - CAST(ISNULL(V.lDefaultMiktar, 0) AS FLOAT) AS lDefaultMiktar + SELECT + ISNULL(V.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + ISNULL(H.sAciklama, '') AS sAciklama, + CAST(ISNULL(V.lDefaultMiktar, 0) AS FLOAT) AS lDefaultMiktar FROM req R INNER JOIN dbo.mk_MaliyetParcaEslestirme_vmiktarlar V WITH (NOLOCK) ON V.nHammaddeTuruNo = R.nHammaddeTuruNo @@ -1426,15 +1524,24 @@ WHERE ISNULL(H.bAktif, 0) = 1; } defer rows.Close() - out := make(map[int]float64, len(clean)) + out := make([]struct { + No int + Aciklama string + Qty float64 + }, 0, len(clean)) for rows.Next() { var no int + var aciklama sql.NullString var qty float64 - if err := rows.Scan(&no, &qty); err != nil { + if err := rows.Scan(&no, &aciklama, &qty); err != nil { return nil, err } if no > 0 && qty > 0 { - out[no] = qty + out = append(out, struct { + No int + Aciklama string + Qty float64 + }{No: no, Aciklama: strings.TrimSpace(aciklama.String), Qty: qty}) } } if err := rows.Err(); err != nil { @@ -1443,6 +1550,128 @@ WHERE ISNULL(H.bAktif, 0) = 1; return out, nil } +func LookupLastOnMLMasDetByHammaddeNos( + ctx context.Context, + uretimDB *sql.DB, + nos []int, + beforeDate string, // YYYY-MM-DD optional + excludeOnMLNo int, + nFirmaID int, + onlyICode bool, + limitPerType int, +) ([]models.ProductionProductCostingLastOnMLDetLookupItem, error) { + if limitPerType <= 0 { + limitPerType = 1 + } + if limitPerType > 1 { + limitPerType = 1 + } + + clean := make([]int, 0, len(nos)) + seen := make(map[int]struct{}, len(nos)) + for _, n := range nos { + if n <= 0 { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + clean = append(clean, n) + } + if len(clean) == 0 { + return []models.ProductionProductCostingLastOnMLDetLookupItem{}, nil + } + if len(clean) > 500 { + return nil, fmt.Errorf("too many hammadde nos") + } + + valueRows := make([]string, 0, len(clean)) + args := make([]any, 0, len(clean)+2) + for i, n := range clean { + valueRows = append(valueRows, "(@p"+strconv.Itoa(i+1)+")") + args = append(args, n) + } + // extra params at the end + args = append(args, strings.TrimSpace(beforeDate)) + args = append(args, excludeOnMLNo) + args = append(args, nFirmaID) + if onlyICode { + args = append(args, 1) + } else { + args = append(args, 0) + } + + beforeParam := "@p" + strconv.Itoa(len(clean)+1) + excludeParam := "@p" + strconv.Itoa(len(clean)+2) + firmaParam := "@p" + strconv.Itoa(len(clean)+3) + onlyIParam := "@p" + strconv.Itoa(len(clean)+4) + + sqlText := ` +WITH req(nHammaddeTuruNo) AS ( + SELECT DISTINCT v.nHammaddeTuruNo + FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(nHammaddeTuruNo) +), +ranked AS ( + SELECT + D.nHammaddeTuruNo, + LTRIM(RTRIM(ISNULL(D.sKodu, ''))) AS sKodu, + LTRIM(RTRIM(ISNULL(D.sAciklama, ''))) AS sAciklama, + LTRIM(RTRIM(ISNULL(D.sBirim, ''))) AS sBirim, + LTRIM(RTRIM(ISNULL(D.fiyat_doviz, ''))) AS fiyat_doviz, + CAST(ISNULL(D.fiyat_girilen, 0) AS FLOAT) AS fiyat_girilen, + CAST(CASE WHEN ISNULL(` + firmaParam + `, 0) > 0 AND ISNULL(M.nFirmaID, 0) = ISNULL(` + firmaParam + `, 0) THEN 1 ELSE 0 END AS bit) AS is_same_firma, + ROW_NUMBER() OVER ( + PARTITION BY D.nHammaddeTuruNo + ORDER BY + CASE WHEN ISNULL(` + firmaParam + `, 0) > 0 AND ISNULL(M.nFirmaID, 0) = ISNULL(` + firmaParam + `, 0) THEN 0 ELSE 1 END, + COALESCE(M.Tarihi, M.dteKayitTarihi) DESC, + M.nOnMLNo DESC, + D.nOnMLDetNo DESC + ) AS rn + FROM dbo.spUrtOnMLMasDet D WITH (NOLOCK) + INNER JOIN dbo.spUrtOnMLMas M WITH (NOLOCK) + ON M.nOnMLNo = D.nOnMLNo + INNER JOIN req R + ON R.nHammaddeTuruNo = D.nHammaddeTuruNo + WHERE ISNULL(D.fiyat_girilen, 0) > 0 + AND LTRIM(RTRIM(ISNULL(D.sKodu, ''))) <> '' + AND (ISNULL(` + onlyIParam + `, 0) = 0 OR LTRIM(RTRIM(ISNULL(D.sKodu, ''))) LIKE 'I.%') + AND (` + beforeParam + ` = '' OR CONVERT(date, COALESCE(M.Tarihi, M.dteKayitTarihi)) < CONVERT(date, ` + beforeParam + `, 23)) + AND (ISNULL(` + excludeParam + `, 0) <= 0 OR M.nOnMLNo <> ` + excludeParam + `) +) +SELECT + ISNULL(nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + ISNULL(sKodu, '') AS sKodu, + ISNULL(sAciklama, '') AS sAciklama, + ISNULL(sBirim, '') AS sBirim, + ISNULL(fiyat_doviz, '') AS fiyat_doviz, + ISNULL(fiyat_girilen, 0) AS fiyat_girilen, + CAST(ISNULL(is_same_firma, 0) AS bit) AS is_same_firma +FROM ranked +WHERE rn = 1; +` + + rows, err := uretimDB.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]models.ProductionProductCostingLastOnMLDetLookupItem, 0, len(clean)) + for rows.Next() { + var item models.ProductionProductCostingLastOnMLDetLookupItem + if err := rows.Scan(&item.NHammaddeTuruNo, &item.SKodu, &item.SAciklama, &item.SBirim, &item.FiyatDoviz, &item.FiyatGirilen, &item.IsSameFirma); err != nil { + return nil, err + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func buildSQLServerFullTextPrefixQuery(search string) string { terms := strings.Fields(strings.TrimSpace(search)) parts := make([]string, 0, len(terms)) diff --git a/svc/routes/login.go b/svc/routes/login.go index b4761ec..da11915 100644 --- a/svc/routes/login.go +++ b/svc/routes/login.go @@ -496,6 +496,13 @@ func UserCreateRoute(db *sql.DB) http.HandlerFunc { return } + // Keep mk_mail in sync for downstream mail mapping screens. + if err := ensureMkMail(tx, payload.Email); err != nil { + log.Printf("USER CREATE ensureMkMail ERROR user_id=%d email=%q err=%v", newID, payload.Email, err) + http.Error(w, "Mail kaydi guncellenemedi", http.StatusInternalServerError) + return + } + // ROLES for _, role := range payload.Roles { role = strings.TrimSpace(role) diff --git a/svc/routes/mk_mail_helper.go b/svc/routes/mk_mail_helper.go new file mode 100644 index 0000000..743a61c --- /dev/null +++ b/svc/routes/mk_mail_helper.go @@ -0,0 +1,68 @@ +package routes + +import ( + "bssapp-backend/utils" + "database/sql" + "errors" + "regexp" + "strings" +) + +var simpleEmailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +// ensureMkMail makes sure the given email exists in mk_mail. +// If it already exists (case-insensitive match), it is re-activated and normalized. +// This is intentionally tolerant (no ON CONFLICT) to avoid relying on a specific unique constraint. +func ensureMkMail(tx *sql.Tx, email string) error { + mail := strings.ToLower(strings.TrimSpace(email)) + if mail == "" { + return nil + } + if !simpleEmailRe.MatchString(mail) { + // user email field can be free-form; don't hard fail user save because of mk_mail bookkeeping + return nil + } + + var id string + err := tx.QueryRow(` + SELECT m.id::text + FROM mk_mail m + WHERE LOWER(TRIM(m.email)) = LOWER(TRIM($1)) + LIMIT 1 + `, mail).Scan(&id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + if errors.Is(err, sql.ErrNoRows) { + newID := utils.NewUUID() + _, err = tx.Exec(` + INSERT INTO mk_mail ( + id, + email, + display_name, + "type", + is_primary, + external_id, + is_active, + created_at + ) + VALUES ($1, $2, '', 'user', true, true, true, NOW()) + `, newID, mail) + return err + } + + // Exists: normalize + activate. Avoid touching created_at. + _, err = tx.Exec(` + UPDATE mk_mail + SET + email = $2, + display_name = COALESCE(display_name, ''), + "type" = 'user', + is_primary = true, + external_id = true, + is_active = true + WHERE id::text = $1 + `, id, mail) + return err +} diff --git a/svc/routes/production_product_costing.go b/svc/routes/production_product_costing.go index fcacab0..1811035 100644 --- a/svc/routes/production_product_costing.go +++ b/svc/routes/production_product_costing.go @@ -327,6 +327,9 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ groupName string groupTotal float64 groupTotalUSD float64 + nOnMLNoStr string + nOnMLDetNoStr string + hNoStr string fiyatGirilen sql.NullFloat64 fiyatDoviz sql.NullString maliyeteDahil sql.NullBool @@ -338,9 +341,9 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ &groupName, &groupTotal, &groupTotalUSD, - &item.NOnMLNo, - &item.NOnMLDetNo, - &item.NHammaddeTuruNo, + &nOnMLNoStr, + &nOnMLDetNoStr, + &hNoStr, &item.SKodu, &item.SAciklama, &item.SRenk, @@ -371,6 +374,10 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ } scannedRows += 1 + item.NOnMLNo, _ = strconv.Atoi(nOnMLNoStr) + item.NOnMLDetNo, _ = strconv.Atoi(nOnMLDetNoStr) + item.NHammaddeTuruNo, _ = strconv.Atoi(hNoStr) + if fiyatGirilen.Valid { item.FiyatGirilen = new(float64) *item.FiyatGirilen = fiyatGirilen.Float64 @@ -426,8 +433,8 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ rows, err := queries.GetProductionHasCostDetailRowsByOnMLNo(ctx, uretimDB, nOnMLNo) if err != nil { logger.Error("query error", "err", err) - log.Printf("❌ [ProductionHasCostDetailGroups] query error: %v", err) - http.Error(w, "Veritabanı hatası", http.StatusInternalServerError) + log.Printf("❌ [ProductionHasCostDetailGroups] query error: %v", err) + http.Error(w, "Veritabanı hatası", http.StatusInternalServerError) return } defer rows.Close() @@ -442,6 +449,9 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ groupName string groupTotal float64 groupTotalUSD float64 + nOnMLNoStr string + nOnMLDetNoStr string + hNoStr string fiyatGirilen sql.NullFloat64 fiyatDoviz sql.NullString maliyeteDahil sql.NullBool @@ -453,9 +463,9 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ &groupName, &groupTotal, &groupTotalUSD, - &item.NOnMLNo, - &item.NOnMLDetNo, - &item.NHammaddeTuruNo, + &nOnMLNoStr, + &nOnMLDetNoStr, + &hNoStr, &item.SKodu, &item.SAciklama, &item.SRenk, @@ -479,10 +489,16 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ &item.SHammaddeTuruAdi, &item.SParcaAdi, ); err != nil { - log.Printf("⚠️ [ProductionHasCostDetailGroups] scan error: %v", err) + scanErrors++ + logger.Warn("scan error", "scan_index", scannedRows+scanErrors, "err", err) + log.Printf("⚠️ [ProductionHasCostDetailGroups] scan error: %v", err) continue } - scannedRows += 1 + scannedRows++ + + item.NOnMLNo, _ = strconv.Atoi(nOnMLNoStr) + item.NOnMLDetNo, _ = strconv.Atoi(nOnMLDetNoStr) + item.NHammaddeTuruNo, _ = strconv.Atoi(hNoStr) if fiyatGirilen.Valid { item.FiyatGirilen = new(float64) @@ -1093,21 +1109,74 @@ func PostProductionProductCostingDefaultQuantitiesLookupHandler(w http.ResponseW return } - m, err := queries.LookupProductionProductCostingDefaultQtyByNos(ctx, uretimDB, req.NHammaddeTuruNos) + rows, err := queries.LookupProductionProductCostingDefaultQtyByNos(ctx, uretimDB, req.NHammaddeTuruNos) if err != nil { http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) return } - out := make([]models.ProductionProductCostingDefaultQtyLookupItem, 0, len(m)) - for no, qty := range m { + out := make([]models.ProductionProductCostingDefaultQtyLookupItem, 0, len(rows)) + for _, r0 := range rows { out = append(out, models.ProductionProductCostingDefaultQtyLookupItem{ - NHammaddeTuruNo: no, - LDefaultMiktar: qty, + NHammaddeTuruNo: r0.No, + SAciklama: r0.Aciklama, + LDefaultMiktar: r0.Qty, }) } _ = json.NewEncoder(w).Encode(out) } +// POST /api/pricing/production-product-costing/has-cost-detail/last-detail +func PostProductionProductCostingHasCostDetailLastDetailHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingLastOnMLDetLookupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + rows, err := queries.LookupLastOnMLMasDetByHammaddeNos(ctx, uretimDB, req.NHammaddeTuruNos, req.BeforeDate, req.ExcludeOnMLNo, req.NFirmaID, req.OnlyICode, 1) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) +} + +// POST /api/pricing/production-product-costing/options/hammadde-by-nos +func PostProductionProductCostingOptionsHammaddeByNosHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + var req models.ProductionProductCostingHammaddeByNosRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + rows, err := queries.GetProductionHammaddeByNos(ctx, uretimDB, req.NHammaddeTuruNos) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) +} + // POST /api/pricing/production-product-costing/default-quantities/refresh func PostProductionProductCostingDefaultQuantitiesRefreshHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -1166,6 +1235,11 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. req.Header.FirmaKodu = strings.TrimSpace(req.Header.FirmaKodu) if req.Header.UrunKodu == "" || req.Header.MaliyetTarihi == "" { + logger.Warn("validation failed: missing required header fields", + "trace_id", traceID, + "urun_kodu", req.Header.UrunKodu, + "maliyet_tarihi", req.Header.MaliyetTarihi, + ) http.Error(w, "urun_kodu ve maliyet_tarihi zorunlu", http.StatusBadRequest) return } @@ -1210,6 +1284,11 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. firmaID = id } if firmaID <= 0 { + logger.Warn("validation failed: missing firma", + "trace_id", traceID, + "n_firma_id", req.Header.NFirmaID, + "firma_kodu", req.Header.FirmaKodu, + ) http.Error(w, "Firma secilmeden kaydedilemez (n_firma_id / firma_kodu)", http.StatusBadRequest) return } @@ -1264,6 +1343,11 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. // Parse Tarihi tarihi, err := time.Parse("2006-01-02", req.Header.MaliyetTarihi) if err != nil { + logger.Warn("validation failed: maliyet_tarihi parse error", + "trace_id", traceID, + "maliyet_tarihi", req.Header.MaliyetTarihi, + "err", err, + ) http.Error(w, "maliyet_tarihi YYYY-MM-DD formatinda olmali", http.StatusBadRequest) return } @@ -1274,7 +1358,23 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. http.Error(w, "Islem baslatilamadi", http.StatusInternalServerError) return } - defer func() { _ = tx.Rollback() }() + committed := false + logger.Info("tx begin", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "urun_kodu", req.Header.UrunKodu, + "maliyet_tarihi", req.Header.MaliyetTarihi, + ) + defer func() { + if committed { + return + } + if err := tx.Rollback(); err == nil { + logger.Info("tx rollback", "trace_id", traceID, "n_onml_no", nOnMLNo) + } else { + logger.Warn("tx rollback failed", "trace_id", traceID, "n_onml_no", nOnMLNo, "err", err) + } + }() // Determine mamul turu inside same tx (to keep create atomic) mamulLabel := "" @@ -1299,6 +1399,7 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. return } if mt <= 0 { + logger.Warn("validation failed: mamul turu not found", "trace_id", traceID, "n_onml_no", nOnMLNo, "mamul_label", mamulLabel) http.Error(w, "Mamul turu bulunamadi: "+mamulLabel, http.StatusBadRequest) return } @@ -1320,6 +1421,7 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. sAciklama = sql.NullString{String: req.Header.SAciklama, Valid: true} } + logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "upsert_header") if err := queries.UpsertOnMLHeader(tx, ctx, queries.OnMLHeaderUpsertArgs{ NOnMLNo: nOnMLNo, UrunKodu: req.Header.UrunKodu, @@ -1343,6 +1445,7 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. } // Deletes + logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_deletes", "count", len(req.Detail.Deletes)) for _, d := range req.Detail.Deletes { if d.NOnMLDetNo <= 0 { continue @@ -1355,13 +1458,33 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. } // Upserts + logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_upserts", "count", len(req.Detail.Upserts)) for _, row := range req.Detail.Upserts { if row.NOnMLDetNo <= 0 { continue } if row.NHammaddeTuruNo <= 0 || strings.TrimSpace(row.SKodu) == "" { - http.Error(w, "Detay satirinda n_hammadde_turu_no ve s_kodu zorunlu", http.StatusBadRequest) - return + // FALLBACK: If nHammaddeTuruNo is missing but sKodu is present, default to 1 (General/Labor) + // to avoid blocking the user, especially for labor items. + if strings.TrimSpace(row.SKodu) != "" && row.NHammaddeTuruNo <= 0 { + logger.Warn("n_hammadde_turu_no <= 0, falling back to 1", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "n_onml_det_no", row.NOnMLDetNo, + "s_kodu", strings.TrimSpace(row.SKodu), + ) + row.NHammaddeTuruNo = 1 + } else { + logger.Warn("validation failed: missing required detail fields (s_kodu empty)", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "n_onml_det_no", row.NOnMLDetNo, + "n_hammadde_turu_no", row.NHammaddeTuruNo, + "s_kodu", strings.TrimSpace(row.SKodu), + ) + http.Error(w, "Detay satirinda s_kodu zorunlu", http.StatusBadRequest) + return + } } qty := row.LMiktar if qty < 0 { @@ -1387,9 +1510,21 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http. lTutar := unitTRY * qty lDovizTutari := unitUSD * qty + // Debug log for price resolution + logger.Info("price debug", + "s_kodu", strings.TrimSpace(row.SKodu), + "qty", qty, + "fiyat_girilen", row.FiyatGirilen, + "fiyat_doviz", strings.TrimSpace(row.FiyatDoviz), + "unitTRY", unitTRY, + "lTutar", lTutar, + "lDovizTutari", lDovizTutari, + ) + // Resolve stock type id from tbStok by sKodu (exact), then fallback to model-based match. // Note: In this DB, stock type is stored as tbStok.nStokTipi but spUrtOnMLMasDet expects nStokTipiID (int). rawSKodu := strings.TrimSpace(row.SKodu) + logger.Info("resolving stock type", "s_kodu", rawSKodu) var nStokTipiID int err := tx.QueryRowContext(ctx, ` SELECT TOP 1 ISNULL(CONVERT(int, ISNULL(S.nStokTipi, 0)), 0) AS nStokTipiID @@ -1411,16 +1546,32 @@ ORDER BY `, rawSKodu).Scan(&nStokTipiID) if err != nil { if err == sql.ErrNoRows { - http.Error(w, "Stok tipi bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) + // FALLBACK: If stock item not found in tbStok at all, default to 1. + logger.Warn("stok tipi not found in tbStok, falling back to 1", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "n_onml_det_no", row.NOnMLDetNo, + "s_kodu", rawSKodu, + ) + nStokTipiID = 1 + } else { + logger.Error("stok tipi lookup error", "err", err) + http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError) return } - logger.Error("stok tipi lookup error", "err", err) - http.Error(w, "Stok tipi bulunamadi (tbStok sorgu hatasi)", http.StatusInternalServerError) - return } + logger.Info("stock type resolved", "s_kodu", rawSKodu, "n_stok_tipi_id", nStokTipiID) if nStokTipiID <= 0 { - http.Error(w, "Stok tipi bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) - return + // FALLBACK: If stock type is missing or 0 in tbStok, default to 1 (usually 'Raw Material' or 'General'). + // This prevents blocking the save process for items not fully configured in tbStok. + logger.Warn("stok tipi <= 0, falling back to 1", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "n_onml_det_no", row.NOnMLDetNo, + "s_kodu", rawSKodu, + "n_stok_tipi_id_orig", nStokTipiID, + ) + nStokTipiID = 1 } // Dummy/system-mapped required fields: @@ -1469,7 +1620,8 @@ WHEN MATCHED THEN Maliyete_dahil = @p29, cm_price_type_id = @p30, sKullaniciAdiDeg = @p31, - dteIslemTarihiDeg = GETDATE() + dteIslemTarihiDeg = GETDATE(), + sAciklama3 = NULLIF(@p32, '') WHEN NOT MATCHED THEN INSERT ( nOnMLNo,nOnMLDetNo,nHammaddeTuruNo,sKodu,sAciklama,sRenk,lMiktar,lFiyat,lTutar, @@ -1480,7 +1632,7 @@ WHEN NOT MATCHED THEN VALUES ( @p1,@p2,@p3,@p4,@p5,@p6,@p9,@p10,@p11, @p12,@p13,@p14,@p15,@p16,@p17,@p18,@p19, - @p20,NULLIF(@p8,''),@p21,@p22,@p31,GETDATE(),NULLIF(@p7,''),NULL,@p23,@p24, + @p20,NULLIF(@p8,''),@p21,@p22,@p31,GETDATE(),NULLIF(@p7,''),NULLIF(@p32, ''),@p23,@p24, @p25,@p26,NULLIF(@p27,0),NULLIF(@p28,''),@p29,@p30 ); ` @@ -1519,8 +1671,21 @@ WHEN NOT MATCHED THEN row.MaliyeteDahil, row.CMPriceTypeID, user, + strings.TrimSpace(row.SAciklama3), // p32: sAciklama3 (Grup Adi) ); err != nil { - logger.Error("detail merge error", "err", err) + logger.Error("detail merge error", + "trace_id", traceID, + "n_onml_no", nOnMLNo, + "n_onml_det_no", row.NOnMLDetNo, + "n_hammadde_turu_no", row.NHammaddeTuruNo, + "n_urt_mt_bolum_id", row.NUrtMTBolumID, + "s_kodu", strings.TrimSpace(row.SKodu), + "fiyat_girilen", row.FiyatGirilen, + "fiyat_doviz", strings.TrimSpace(row.FiyatDoviz), + "l_miktar", qty, + "n_stok_tipi_id", nStokTipiID, + "err", err, + ) http.Error(w, "Detay kaydedilemedi", http.StatusInternalServerError) return } @@ -1533,6 +1698,7 @@ WHEN NOT MATCHED THEN // ============================================================ if req.Header.NUrtReceteID > 0 { receteID := req.Header.NUrtReceteID + logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "recipe_sync", "n_urt_recete_id", receteID) // Determine next available recipe detail id (nUrtRecMBolumID) nextRecDetID := 0 @@ -1551,94 +1717,72 @@ WHERE RMik.nUrtReceteID = @p1 continue } - // CM1/CM2 rows are cost-only and must NOT be written back into recipe tables. - // Source of truth: spUrtOnMLHammaddeTuru.sAciklama3 (e.g. 'CM2'). - var grp sql.NullString - if err := tx.QueryRowContext(ctx, ` -SELECT TOP 1 LTRIM(RTRIM(ISNULL(H.sAciklama3, ''))) -FROM dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) -WHERE H.nHammaddeTuruNo = @p1 -`, hNo).Scan(&grp); err == nil { - g := strings.ToUpper(strings.TrimSpace(grp.String)) - if g == "CM1" || g == "CM2" { - continue - } + // 1. FILTER: CM1/CM2 (Labor/Service) rows must NOT be written back into recipe tables. + // We check the group label (sAciklama3) from the row itself. + g := strings.ToUpper(strings.TrimSpace(row.SAciklama3)) + if g == "CM1" || g == "CM2" { + logger.Info("recipe sync skip: labor item", "s_kodu", row.SKodu, "group", g) + continue } - // Resolve nHStokID from tbStok using sKodu (exact), then sModel fallback. - // IMPORTANT: nHStokID must be resolved; otherwise we'd update/insert too broadly. + // In this version of URETIM DB, the table name is dbo.spUrtRecMBolumMik + // but it uses _G suffixes for quantity and amount columns. + // Also nHStokID_G stores the stock code string rather than just an ID. rawSKodu := strings.TrimSpace(row.SKodu) - var nHStokID int - err := tx.QueryRowContext(ctx, ` -SELECT TOP 1 ISNULL(S.nStokID, 0) -FROM dbo.tbStok S WITH (NOLOCK) -WHERE ISNULL(S.IsBlocked, 0) = 0 - AND ( - REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') - OR LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 - OR @p1 LIKE LTRIM(RTRIM(ISNULL(S.sModel,''))) + '%' - ) -ORDER BY - CASE - WHEN REPLACE(LTRIM(RTRIM(ISNULL(S.sKodu,''))), ' ', '') = REPLACE(@p1, ' ', '') THEN 0 - WHEN LTRIM(RTRIM(ISNULL(S.sModel,''))) = @p1 THEN 1 - ELSE 2 - END, - S.dteKayitTarihi DESC, - S.nStokID DESC -`, rawSKodu).Scan(&nHStokID) - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Recete sync icin stok bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) - return - } - logger.Error("recipe stok lookup error", "err", err) - http.Error(w, "Recete sync stok bulunamadi", http.StatusInternalServerError) - return - } - if nHStokID == 0 { - http.Error(w, "Recete sync icin stok bulunamadi (s_kodu="+rawSKodu+")", http.StatusBadRequest) - return + + // Ensure a section entry (spUrtRecMBolum) exists for this hNo (Hammadde Turu) + // in the current recipe, otherwise detail rows (Mik) cannot be linked properly. + var sectionExists int + _ = tx.QueryRowContext(ctx, ` +SELECT COUNT(1) FROM dbo.spUrtRecMBolum WITH (NOLOCK) +WHERE nUrtReceteID = @p1 AND nUrtMBolumID = @p2 +`, receteID, hNo).Scan(§ionExists) + + if sectionExists <= 0 { + logger.Info("creating missing recipe section", "n_urt_recete_id", receteID, "n_urt_m_bolum_id", hNo) + _, _ = tx.ExecContext(ctx, ` +INSERT INTO dbo.spUrtRecMBolum (nUrtReceteID, nUrtUBolumID, nUrtMBolumID, nUrtMTBolumID, sKullaniciAdi, dteIslemTarihi) +VALUES (@p1, 13, @p2, @p3, @p4, GETDATE()) +`, receteID, hNo, row.NUrtMTBolumID, user) } - // If we cannot resolve stock, we still try to insert by hammadde no only. - // Some recipes may omit nHStokID; but we prefer filling it when possible. - - // Update quantity if row already exists for (recete, hammadde, stok) + // Update quantity and prices if row already exists for (recete, hammadde, stok_code) + // Using nHStokID_G (string code) for matching as per user screenshot. var exists int if err := tx.QueryRowContext(ctx, ` SELECT COUNT(1) FROM dbo.spUrtRecMBolumMik RMik WITH (NOLOCK) WHERE RMik.nUrtReceteID = @p1 AND RMik.nUrtMBolumID = @p2 - AND RMik.nHStokID = @p3 -`, receteID, hNo, nHStokID).Scan(&exists); err == nil && exists > 0 { + AND LTRIM(RTRIM(RMik.nHStokID_G)) = @p3 +`, receteID, hNo, rawSKodu).Scan(&exists); err == nil && exists > 0 { _, _ = tx.ExecContext(ctx, ` UPDATE dbo.spUrtRecMBolumMik -SET lHMiktar = @p4, - sKullaniciAdiDeg = @p5, - dteIslemTarihiDeg = GETDATE() +SET lHMiktar_G = @p4, + lHMaliyet_G = @p5, + sKullaniciAdiDeg = @p6, + dteIslemTarihiDeg = GETDATE(), + bIslem = @p7 WHERE nUrtReceteID = @p1 AND nUrtMBolumID = @p2 - AND nHStokID = @p3 -`, receteID, hNo, nHStokID, row.LMiktar, user) + AND LTRIM(RTRIM(nHStokID_G)) = @p3 +`, receteID, hNo, rawSKodu, row.LMiktar, unitTRY, user, row.MaliyeteDahil) continue } - // Insert missing: best-effort with minimal columns. - // NOTE: This assumes nUrtRecMBolumID can be a sequential int and that other columns are nullable/have defaults. + // Insert missing: using _G columns and storing code in nHStokID_G. _, insertErr := tx.ExecContext(ctx, ` INSERT INTO dbo.spUrtRecMBolumMik ( nUrtReceteID, nUrtUBolumID, nUrtRecMBolumID, nStokID, - nHStokID, - lHMiktar, - lHFire, + nHStokID_G, + lHMiktar_G, + lHFire_G, nMaliyetTipiID, - lHMaliyet, - lMiktar, + lHMaliyet_G, + lMiktar_G, sIslemKodu, nUrtMBolumID, nUrtMTBolumID, @@ -1646,56 +1790,52 @@ INSERT INTO dbo.spUrtRecMBolumMik ( bIslem, nSure, sAciklama, - dteDovizTarihi, - sDovizCinsi, - lDovizOran, - lDovizFiyat, sKullaniciAdi, dteIslemTarihi, nMBolumSarfTipiNo ) VALUES ( @p1, - 0, + 13, @p2, 0, - @p3, - @p4, + @p3, -- nHStokID_G (Code) + @p4, -- lHMiktar_G 0, 6, - 0, - 1, + @p5, -- lHMaliyet_G + 1, -- lMiktar_G '', - @p5, @p6, - 1, - 0, - 0, - NULL, - NULL, - NULL, - NULL, - NULL, @p7, + 1, + @p8, -- bIslem + 0, + NULL, + @p9, GETDATE(), 1 ) - `, receteID, nextRecDetID, nHStokID, row.LMiktar, hNo, row.NUrtMTBolumID, user) + `, receteID, nextRecDetID, rawSKodu, row.LMiktar, unitTRY, hNo, row.NUrtMTBolumID, row.MaliyeteDahil, user) if insertErr == nil { nextRecDetID += 1 } } } + logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "commit") if err := tx.Commit(); err != nil { logger.Error("tx commit error", "err", err) http.Error(w, "Kaydetme tamamlanamadi", http.StatusInternalServerError) return } + committed = true + logger.Info("tx commit ok", "trace_id", traceID, "n_onml_no", nOnMLNo) // V3: update base price table so pricing screens reflect latest costing. // Not transactional with URETIM DB; if this fails, URETIM save has already succeeded. if mssqlDB != nil { + logger.Info("post-commit step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "v3_base_price_upsert") if err := queries.UpsertV3ItemBasePriceUSD(ctx, mssqlDB, req.Header.UrunKodu, req.Header.MaliyetTarihi, totalUSD, user); err != nil { logger.Error("v3 base price upsert error", "err", err) http.Error(w, "URETIM kaydedildi ama V3 maliyet guncellenemedi", http.StatusInternalServerError) @@ -1706,6 +1846,131 @@ VALUES ( _ = json.NewEncoder(w).Encode(models.ProductionProductCostingOnMLSaveResponse{NOnMLNo: nOnMLNo}) } +// POST /api/pricing/production-product-costing/onml/delete +// Deletes costing records created in URETIM (OnML header + details) and, if created by this app, V3 base price row. +// IMPORTANT: Recipe tables are NOT touched. +func PostProductionProductCostingOnMLDeleteHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + uretimDB := db.GetUretimDB() + if uretimDB == nil { + http.Error(w, "URETIM veritabani baglantisi aktif degil", http.StatusServiceUnavailable) + return + } + mssqlDB := db.GetDB() + + claims, _ := auth.GetClaimsFromContext(r.Context()) + user := "" + if claims != nil { + user = strings.TrimSpace(claims.Username) + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + logger := utils.SlogFromContext(ctx).With("handler", "production-product-costing.onml.delete") + + var req models.ProductionProductCostingOnMLDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Gecersiz JSON", http.StatusBadRequest) + return + } + if req.NOnMLNo <= 0 { + http.Error(w, "n_onml_no zorunlu", http.StatusBadRequest) + return + } + + // Load header info for safe deletes and redirect behavior. + var urunKodu string + var maliyetTarihi time.Time + err := uretimDB.QueryRowContext(ctx, ` +SELECT TOP 1 + LTRIM(RTRIM(ISNULL(UrunKodu,''))) AS UrunKodu, + COALESCE(Tarihi, dteKayitTarihi, GETDATE()) AS Tarihi +FROM dbo.spUrtOnMLMas WITH (NOLOCK) +WHERE nOnMLNo = @p1 +`, req.NOnMLNo).Scan(&urunKodu, &maliyetTarihi) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Kayit bulunamadi", http.StatusNotFound) + return + } + logger.Error("header lookup error", "err", err) + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + urunKodu = strings.TrimSpace(urunKodu) + + tx, err := uretimDB.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + defer func() { _ = tx.Rollback() }() + + // Delete details first, then header. + if _, err := tx.ExecContext(ctx, ` +DELETE FROM dbo.spUrtOnMLMasDet WHERE nOnMLNo = @p1 +`, req.NOnMLNo); err != nil { + logger.Error("delete detail error", "err", err) + http.Error(w, "Detay silinemedi", http.StatusInternalServerError) + return + } + if _, err := tx.ExecContext(ctx, ` +DELETE FROM dbo.spUrtOnMLMas WHERE nOnMLNo = @p1 +`, req.NOnMLNo); err != nil { + logger.Error("delete header error", "err", err) + http.Error(w, "Header silinemedi", http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + logger.Error("tx commit error", "err", err) + http.Error(w, "Silme tamamlanamadi", http.StatusInternalServerError) + return + } + + // V3: Delete base price ONLY if row was created by this app (CreatedUserName starts with BSSAPP). + deletedBasePrice := false + if mssqlDB != nil && urunKodu != "" { + var createdBy sql.NullString + var lastBy sql.NullString + _ = mssqlDB.QueryRowContext(ctx, ` +SELECT TOP 1 + ISNULL(CreatedUserName,'') AS CreatedUserName, + ISNULL(LastUpdatedUserName,'') AS LastUpdatedUserName +FROM dbo.prItemBasePrice WITH (NOLOCK) +WHERE ItemTypeCode = 1 + AND LTRIM(RTRIM(ItemCode)) = @p1 + AND ISNULL(CountryCode,'') = 'TR' + AND ISNULL(SeasonCode,'') = '' + AND ISNULL(BasePriceCode,0) = 1 +`, urunKodu).Scan(&createdBy, &lastBy) + + created := strings.ToUpper(strings.TrimSpace(createdBy.String)) + last := strings.ToUpper(strings.TrimSpace(lastBy.String)) + if strings.HasPrefix(created, "BSSAPP") && strings.HasPrefix(last, "BSSAPP") { + if _, err := mssqlDB.ExecContext(ctx, ` +DELETE FROM dbo.prItemBasePrice +WHERE ItemTypeCode = 1 + AND LTRIM(RTRIM(ItemCode)) = @p1 + AND ISNULL(CountryCode,'') = 'TR' + AND ISNULL(SeasonCode,'') = '' + AND ISNULL(BasePriceCode,0) = 1 +`, urunKodu); err == nil { + deletedBasePrice = true + } + } + } + + logger.Info("delete done", "n_onml_no", req.NOnMLNo, "urun_kodu", urunKodu, "deleted_base_price", deletedBasePrice, "user", user) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "n_onml_no": req.NOnMLNo, + "urun_kodu": urunKodu, + "deleted_baseprice": deletedBasePrice, + }) +} + // GET /api/pricing/production-product-costing/has-cost-detail-exchange-rates func GetProductionHasCostDetailExchangeRatesHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/svc/routes/user_detail.go b/svc/routes/user_detail.go index 8054c58..23de038 100644 --- a/svc/routes/user_detail.go +++ b/svc/routes/user_detail.go @@ -251,6 +251,13 @@ func handleUserUpdate(db *sql.DB, w http.ResponseWriter, r *http.Request, userID return } + // Keep mk_mail in sync for downstream mail mapping screens. + if err := ensureMkMail(tx, payload.Email); err != nil { + log.Printf("ERROR [UserDetail] ensureMkMail failed user_id=%d email=%q err=%v", userID, payload.Email, err) + http.Error(w, "Mail kaydi guncellenemedi", http.StatusInternalServerError) + return + } + if _, err := tx.Exec(`DELETE FROM dfrole_usr WHERE dfusr_id = $1`, userID); err != nil { log.Printf("❌ [UserDetail] delete roles failed user_id=%d err=%v", userID, err) http.Error(w, "Roller temizlenemedi", http.StatusInternalServerError) 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 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -1,158 +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'), - - import(/* webpackMode: "eager" */ 'boot/locale'), - - import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard') - - ]).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.temporary.compiled.1778776531494.mjs b/ui/quasar.config.js.temporary.compiled.1778860298326.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1778776531494.mjs rename to ui/quasar.config.js.temporary.compiled.1778860298326.mjs diff --git a/ui/src/pages/ProductionProductCostingDefaultQuantities.vue b/ui/src/pages/ProductionProductCostingDefaultQuantities.vue index e1dda3e..2c75b8d 100644 --- a/ui/src/pages/ProductionProductCostingDefaultQuantities.vue +++ b/ui/src/pages/ProductionProductCostingDefaultQuantities.vue @@ -1,6 +1,6 @@ @@ -120,7 +116,6 @@ const columns = [ { name: 'sAciklama', label: 'Aciklama', field: 'sAciklama', align: 'left', sortable: true }, { name: 'lDefaultMiktar', label: 'Varsayilan Miktar', field: 'lDefaultMiktar', align: 'right', sortable: true }, { name: 'dteCalcTarihi', label: 'Hesap Tarihi', field: 'dteCalcTarihi', align: 'left', sortable: true }, - { name: 'bAktif', label: 'Aktif', field: 'bAktif', align: 'center', sortable: true }, { name: 'actions', label: '', field: '__actions', align: 'right', sortable: false } ] @@ -145,13 +140,6 @@ function onEditQty (row, val) { row.lDefaultMiktar = qty } -function onEditActive (row, val) { - const no = Number(row?.nHammaddeTuruNo || 0) - if (!(no > 0)) return - store.setDraft(no, { bAktif: Boolean(val) }) - row.bAktif = Boolean(val) -} - async function onCalcAvg (row) { const no = Number(row?.nHammaddeTuruNo || 0) if (!(no > 0)) return @@ -204,7 +192,7 @@ function ensureBeforeUnloadGuard (enabled) { } } -watch(hasUnsavedChanges, (v) => ensureBeforeUnloadGuard(Boolean(v))) +watch(() => Boolean(hasUnsavedChanges.value), (v) => ensureBeforeUnloadGuard(Boolean(v))) onBeforeRouteLeave((to, from, next) => { if (!hasUnsavedChanges.value) return next() @@ -225,11 +213,62 @@ onUnmounted(() => { diff --git a/ui/src/pages/ProductionProductCostingHasCostDetail.vue b/ui/src/pages/ProductionProductCostingHasCostDetail.vue index e777925..200d65f 100644 --- a/ui/src/pages/ProductionProductCostingHasCostDetail.vue +++ b/ui/src/pages/ProductionProductCostingHasCostDetail.vue @@ -51,7 +51,7 @@ color="primary" class="pcd-toolbar-btn" :loading="detailLoading" - @click="fetchDetail({ clearDraft: true, hydrateDraft: false })" + @click="confirmRefresh" /> + + Once maliyet olusturulunca aktif olur + @@ -359,13 +372,19 @@ @@ -443,9 +462,12 @@ - - - + + +
{{ rowEditorMode === 'edit' ? 'Satir Duzenle' : 'Yeni Satir Ekle' }}
@@ -752,6 +774,47 @@ const rowEditorDialogOpen = ref(false) const rowEditorMode = ref('new') const rowEditorTargetRowKey = ref('') const rowEditorForm = ref(createRowEditorForm()) + +// Draggable "Satir Duzenle" dialog (mouse drag by header). +const rowEditorDialogPos = ref({ x: 0, y: 0 }) +const rowEditorDialogPanBase = ref({ x: 0, y: 0 }) +const rowEditorDialogDragging = ref(false) +const rowEditorDialogDragMouseStart = ref({ x: 0, y: 0 }) +const rowEditorDialogDragPosStart = ref({ x: 0, y: 0 }) +const rowEditorDialogStyle = computed(() => ({ + transform: `translate(${Number(rowEditorDialogPos.value?.x || 0)}px, ${Number(rowEditorDialogPos.value?.y || 0)}px)` +})) + +function resetRowEditorDialogPosition () { + rowEditorDialogPos.value = { x: 0, y: 0 } + rowEditorDialogPanBase.value = { x: 0, y: 0 } +} + +function startRowEditorDialogDrag (e) { + if (!e) return + rowEditorDialogDragging.value = true + rowEditorDialogDragMouseStart.value = { x: Number(e.clientX || 0), y: Number(e.clientY || 0) } + rowEditorDialogDragPosStart.value = { x: Number(rowEditorDialogPos.value?.x || 0), y: Number(rowEditorDialogPos.value?.y || 0) } + window.addEventListener('mousemove', onRowEditorDialogDragMove, true) + window.addEventListener('mouseup', stopRowEditorDialogDrag, true) +} + +function onRowEditorDialogDragMove (e) { + if (!rowEditorDialogDragging.value) return + const startMouse = rowEditorDialogDragMouseStart.value || { x: 0, y: 0 } + const startPos = rowEditorDialogDragPosStart.value || { x: 0, y: 0 } + const dx = Number(e?.clientX || 0) - Number(startMouse.x || 0) + const dy = Number(e?.clientY || 0) - Number(startMouse.y || 0) + rowEditorDialogPos.value = { x: Number(startPos.x || 0) + dx, y: Number(startPos.y || 0) + dy } +} + +function stopRowEditorDialogDrag () { + rowEditorDialogDragging.value = false + try { + window.removeEventListener('mousemove', onRowEditorDialogDragMove, true) + window.removeEventListener('mouseup', stopRowEditorDialogDrag, true) + } catch {} +} const rowEditorHammaddeOptions = ref([]) const rowEditorHammaddeAllOptions = ref([]) const rowEditorHammaddeLoading = ref(false) @@ -843,6 +906,13 @@ const hasUnsavedChanges = computed(() => { return flatDetailRows.value.some(row => Boolean(row?.draftChanged)) }) +const canDeleteCosting = computed(() => { + // Only allow delete for records created from no-cost flow. + // UX expectation: delete button becomes available after a costing record exists. + const n = parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0 + return n > 0 && String(detailSource.value || '').trim().toLowerCase() === 'no-cost' +}) + function persistLocalDraftNow () { const key = draftStorageKey.value if (!key) return @@ -1796,6 +1866,19 @@ function resolveInputCurrency (row) { return normalizePriceCurrency(row?.inputPricePrBr || row?.fiyat_doviz) || 'USD' } +function resolveAutoOrICodeHighlightClass (row) { + // Priority: auto-filled from previous costing (already firm-aware). + if (row?.__autoFilledFromPrev) { + return row?.__autoFilledFromPrevSameFirma ? 'text-positive text-weight-bold' : 'text-negative text-weight-bold' + } + // Manual selection for I.* codes: highlight same/other firm like the auto-fill convention. + const code = String(row?.sKodu || '').trim().toUpperCase() + if (code.startsWith('I.') && row?.__iCodeSelected) { + return row?.__iCodeSameFirma ? 'text-positive text-weight-bold' : 'text-negative text-weight-bold' + } + return '' +} + function resolveNumericRowQuantity (row) { return parseMoneyInput(row?.miktarInput ?? row?.lMiktar) } @@ -1826,17 +1909,23 @@ function resolveUSDUnitPriceByInput (inputPrice, inputCurrency, usdRate, eurRate function normalizeDetailRows (items, groupName = '') { const list = Array.isArray(items) ? items : [] - return list.map((x, i) => ({ - ...x, - __rowKey: x?.__rowKey || `${x?.nOnMLNo || ''}-${x?.nOnMLDetNo || ''}-${i}`, - miktarInput: x?.miktarInput ?? normalizeQuantityInput(x?.lMiktar), - inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen), - inputPricePrBr: normalizePriceCurrency(x?.inputPricePrBr || x?.fiyat_doviz || x?.sDovizCinsi) || 'USD', - maliyeteDahil: x?.maliyeteDahil ?? normalizeBooleanFlag(x?.maliyete_dahil ?? x?.Maliyete_dahil), - cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3), - draftChanged: Boolean(x?.draftChanged), - priceUpdateState: String(x?.priceUpdateState || '').trim() - })) + return list.map((x, i) => { + // Determine the original currency from backend fields. + const originalCurrency = normalizePriceCurrency(x?.fiyat_doviz || x?.sDovizCinsi || x?.inputPricePrBr) || 'USD' + return { + ...x, + __rowKey: x?.__rowKey || `${x?.nOnMLNo || ''}-${x?.nOnMLDetNo || ''}-${i}`, + miktarInput: x?.miktarInput ?? normalizeQuantityInput(x?.lMiktar), + // If inputPrice is missing, it means we are loading fresh from backend. + // Use fiyat_girilen which is the unit price in original currency. + inputPrice: x?.inputPrice ?? normalizeInputPrice(x?.fiyat_girilen), + inputPricePrBr: originalCurrency, + maliyeteDahil: x?.maliyeteDahil ?? normalizeBooleanFlag(x?.maliyete_dahil ?? x?.Maliyete_dahil), + cmPriceTypeId: normalizeCMPriceTypeId(x?.cmPriceTypeId ?? x?.cm_price_type_id, groupName || x?.sAciklama3), + draftChanged: Boolean(x?.draftChanged), + priceUpdateState: String(x?.priceUpdateState || '').trim() + } + }) } function normalizeDetailGroups (groups) { @@ -2253,10 +2342,17 @@ function buildDetailItemRequestPayload (row) { } } -function applyPriceSelectionToRow (targetRowKey, price, currency, priceType) { +function applyPriceSelectionToRow (targetRowKey, price, currency, priceType, itemCode, itemDescription, historyCompanyCode) { const normalizedCurrency = normalizePriceCurrency(currency) || 'USD' const normalizedPrice = parseMoneyInput(price) const finalPriceType = priceType || 'SAF' + const normalizedCode = normalizeCodeValue(itemCode) + const normalizedDesc = String(itemDescription || '').trim() + + const isICode = String(normalizedCode || '').toUpperCase().startsWith('I.') + const selectedFirma = String(detailHeader.value?.FirmaKodu || '').trim().toUpperCase() + const historyFirma = String(historyCompanyCode || '').trim().toUpperCase() + const isSameFirma = Boolean(selectedFirma && historyFirma && selectedFirma === historyFirma) detailGroups.value = detailGroups.value.map(grp => ({ ...grp, @@ -2264,6 +2360,9 @@ function applyPriceSelectionToRow (targetRowKey, price, currency, priceType) { if (row.__rowKey !== targetRowKey) return row return recalculateDetailRow({ ...row, + ...(normalizedCode ? { sKodu: normalizedCode } : {}), + ...(normalizedDesc ? { sAciklama: normalizedDesc } : {}), + ...(isICode ? { __iCodeSelected: true, __iCodeSameFirma: isSameFirma } : { __iCodeSelected: false, __iCodeSameFirma: false }), inputPrice: normalizeInputPrice(normalizedPrice), fiyat_girilen: normalizedPrice, inputPricePrBr: normalizedCurrency, @@ -2481,6 +2580,94 @@ async function fetchBulkItemPrices () { : 'Donen veriler satirlarla eslestirilemedi.', position: 'top-right' }) + + // Autofill missing required codes from last OnML history (URETIM spUrtOnMLMasDet) + try { + const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : []) + const targets = flatNow.filter(r => + Boolean(r?.requiredPlaceholder) && + // CM1/CM2 and FABRIC rows must be chosen by user; don't auto-fill from previous costing. + !isCMGroupName(r?.sAciklama3) && + normalizeGroupName(r?.sAciklama3) !== 'FABRIC' && + (String(r?.sKodu || '').trim() === '' || Number(resolveNumericRowInputPrice(r) || 0) <= 0) + ) + const hNos = Array.from(new Set(targets + .map(r => parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10)) + .filter(n => n > 0) + )) + if (hNos.length > 0) { + const lastRows = await post('/pricing/production-product-costing/has-cost-detail/last-detail', { + nHammaddeTuruNos: hNos, + before_date: normalizeDateInput(costDate.value), + exclude_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0, + n_firma_id: parseInt(String(detailHeader.value?.nFirmaID || detailHeader.value?.NFirmaID || '0').trim() || '0', 10) || 0 + }, { params: { trace_id: traceId.value } }) + + const list = Array.isArray(lastRows) ? lastRows : [] + const byNo = {} + list.forEach(x => { + const no = parseInt(String(x?.nHammaddeTuruNo || '0'), 10) || 0 + if (no > 0) byNo[no] = x + }) + + let filled = 0 + detailGroups.value = detailGroups.value.map(grp => ({ + ...grp, + items: (Array.isArray(grp?.items) ? grp.items : []).map(row => { + if (!row?.requiredPlaceholder) return row + if (isCMGroupName(row?.sAciklama3) || normalizeGroupName(row?.sAciklama3) === 'FABRIC') return row + const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0 + const hit = byNo[no] + if (!hit) return row + + const next = { ...row } + const hasCode = String(next.sKodu || '').trim() !== '' + const hasPrice = Number(resolveNumericRowInputPrice(next) || 0) > 0 + + if (!hasCode && String(hit?.sKodu || '').trim()) { + next.sKodu = String(hit.sKodu || '').trim() + next.sAciklama = String(hit?.sAciklama || '').trim() + } + if (!hasPrice && Number(hit?.fiyat_girilen || 0) > 0) { + next.inputPrice = String(hit.fiyat_girilen) + next.fiyat_girilen = Number(hit.fiyat_girilen) + const pr = String(hit?.fiyat_doviz || '').trim() || 'USD' + next.inputPricePrBr = pr + next.fiyat_doviz = pr + } + + // Only mark if something changed + const changed = (next.sKodu !== row.sKodu) || (next.sAciklama !== row.sAciklama) || (next.inputPrice !== row.inputPrice) || (next.inputPricePrBr !== row.inputPricePrBr) + if (!changed) return row + filled += 1 + return recalculateDetailRow({ + ...next, + __autoFilledFromPrev: true + , + __autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma) + }, { + priceType: 'PREV', + updateState: 'autofill', + markChanged: true + }) + }) + })) + + if (filled > 0) { + $q.notify({ + type: 'info', + message: `${filled} satirda kod/aciklama ve fiyat bilgisi onceki maliyetten otomatik getirildi.`, + position: 'top-right' + }) + } + } + } catch (e) { + // Non-blocking + slog.error('production-product-costing.detail', 'bulk:autofill-prev:error', { + trace_id: traceId.value, + error: String(e?.message || e || '') + }) + } } catch (err) { $q.notify({ type: 'negative', @@ -2542,11 +2729,28 @@ function onDetailRowClick (evt, row) { } function applyLineHistorySelection (historyRow) { - applyPriceSelectionToRow(lineHistoryTargetRowKey.value, historyRow?.price, historyRow?.currency, historyRow?.priceType) + const targetKey = String(lineHistoryTargetRowKey.value || '').trim() + if (!targetKey) { + $q.notify({ + type: 'negative', + message: 'Hedef satir bulunamadi (rowKey bos).', + position: 'top-right' + }) + return + } + applyPriceSelectionToRow( + targetKey, + historyRow?.price, + historyRow?.currency, + historyRow?.priceType, + historyRow?.itemCode, + historyRow?.itemDescription, + historyRow?.companyCode + ) lineHistoryDialogOpen.value = false $q.notify({ type: 'positive', - message: 'Secilen history fiyati satira uygulandi.', + message: `Secilen fiyat satira uygulandi: ${formatMoney(historyRow?.price)} ${String(historyRow?.currency || '').trim() || 'USD'}`, position: 'top-right' }) } @@ -2759,8 +2963,10 @@ function normalizeGroupName (value) { async function fetchRequiredParcaMappings () { const ilk = String(detailHeader.value?.UrunIlkGrubu || '').trim() const ana = String(detailHeader.value?.UrunAnaGrubu || '').trim() - const alt = String(detailHeader.value?.UrunAltGrubu || '').trim() - if (!ilk || !ana || !alt) return [] + // Some sources return NULL/'' for "no alt group". Mapping screen stores it as '-'. + const altRaw = String(detailHeader.value?.UrunAltGrubu || '').trim() + const alt = altRaw || '-' + if (!ilk || !ana) return [] const data = await get('/pricing/production-product-costing/maliyet-parca-eslestirme', { trace_id: traceId.value, @@ -2956,6 +3162,81 @@ async function ensureNoCostRequiredRowsFromMappings (mappings) { applyEditorRowToGroups(placeholder) } } + + // CM2 special case: + // Bulk autofill is disabled for CM2 (and FABRIC) to prevent wrong price pulls, + // but we still want the I.* code templates (code + description) to appear on page open. + // So we prefill ONLY code + description for required CM2 placeholders from last OnML history. + try { + const flatNow = detailGroups.value.flatMap(grp => Array.isArray(grp?.items) ? grp.items : []) + const cm2Targets = flatNow.filter(r => + Boolean(r?.requiredPlaceholder) && + normalizeGroupName(r?.sAciklama3) === 'CM2' && + String(r?.sKodu || '').trim() === '' + ) + const cm2Nos = Array.from(new Set(cm2Targets + .map(r => parseInt(String(r?.nHammaddeTuruNo || '').trim() || '0', 10)) + .filter(n => n > 0) + )) + if (cm2Nos.length > 0) { + const lastRows = await post('/pricing/production-product-costing/has-cost-detail/last-detail', { + nHammaddeTuruNos: cm2Nos, + before_date: normalizeDateInput(costDate.value), + exclude_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0, + n_firma_id: parseInt(String(detailHeader.value?.nFirmaID || detailHeader.value?.NFirmaID || '0').trim() || '0', 10) || 0, + only_i_code: true + }, { params: { trace_id: traceId.value } }) + + const list = Array.isArray(lastRows) ? lastRows : [] + const byNo = {} + list.forEach(x => { + const no = parseInt(String(x?.nHammaddeTuruNo || '0'), 10) || 0 + if (no > 0) byNo[no] = x + }) + + let filled = 0 + detailGroups.value = detailGroups.value.map(grp => ({ + ...grp, + items: (Array.isArray(grp?.items) ? grp.items : []).map(row => { + if (!row?.requiredPlaceholder) return row + if (normalizeGroupName(row?.sAciklama3) !== 'CM2') return row + if (String(row?.sKodu || '').trim() !== '') return row + const no = parseInt(String(row?.nHammaddeTuruNo || '').trim() || '0', 10) || 0 + const hit = byNo[no] + if (!hit) return row + const code = String(hit?.sKodu || '').trim() + if (!code) return row + + const next = recalculateDetailRow({ + ...row, + sKodu: code, + sAciklama: String(hit?.sAciklama || '').trim(), + __autoFilledFromPrev: true, + __autoFilledFromPrevSameFirma: Boolean(hit?.is_same_firma || hit?.isSameFirma) + }, { + priceType: 'PREV', + updateState: 'autofill-icode', + markChanged: true + }) + filled += 1 + return next + }) + })) + if (filled > 0) { + $q.notify({ + type: 'info', + message: `${filled} CM2 satirinda I.* kod/aciklama onceki maliyetten otomatik getirildi.`, + position: 'top-right' + }) + } + } + } catch (e) { + // Non-blocking + slog.error('production-product-costing.detail', 'cm2:autofill-icode:error', { + trace_id: traceId.value, + error: String(e?.message || e) + }) + } } function computeMissingRequiredSlots () { @@ -2964,19 +3245,30 @@ function computeMissingRequiredSlots () { if (list.length === 0) return missing list.forEach(mapping => { - const groupName = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi || mapping?.sAciklama3) + // Required slots are defined per (ParcaBolum / MTBolumID) + (HammaddeTuruNo). + // Do NOT match by row.sAciklama3, because that field is the "group header" (DT/TP/CM2/FABRIC), not the part. + const mappingMtBolumID = parseInt(String(mapping?.nUrtMTBolumID ?? mapping?.NUrtMTBolumID ?? '0'), 10) || 0 + const mappingParcaAdi = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi || '') const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : [] hList.forEach(hNoRaw => { const hNo = normalizeHammaddeNo(hNoRaw) if (!hNo) return - const match = flatDetailRows.value.find(r => - normalizeGroupName(r?.sAciklama3) === groupName && - normalizeHammaddeNo(r?.nHammaddeTuruNo) === hNo - ) + const match = flatDetailRows.value.find(r => { + if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false + const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0 + if (mappingMtBolumID > 0 && rowMtBolumID > 0) return mappingMtBolumID === rowMtBolumID + // Fallback: older rows might not have mtBolumID; use part name match. + return normalizeGroupName(r?.sParcaAdi) === mappingParcaAdi + }) const price = resolveNumericRowInputPrice(match) if (!match || !(price > 0)) { - missing.push({ groupName, nHammaddeTuruNo: hNo, rowKey: match?.__rowKey || '' }) + missing.push({ + mtBolumID: mappingMtBolumID, + parcaAdi: mappingParcaAdi, + nHammaddeTuruNo: hNo, + rowKey: match?.__rowKey || '' + }) } }) }) @@ -3131,6 +3423,16 @@ function round4 (n) { return Math.round(x * 10000) / 10000 } +function escapeHtml (input) { + const s = String(input ?? '') + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + async function confirmDefaultQtyDeviationIfNeeded () { // Compare entered qty vs default qty (mk_MaliyetParcaEslestirme_vmiktarlar) per hammadde type. // Rule: if deviation > 10% (abs), require user confirmation. @@ -3154,10 +3456,15 @@ async function confirmDefaultQtyDeviationIfNeeded () { return true } const defMap = {} + const descMap = {} ;(Array.isArray(defaults) ? defaults : []).forEach(it => { const no = parseInt(String(it?.nHammaddeTuruNo || '0'), 10) || 0 const d = Number(it?.lDefaultMiktar || 0) - if (no > 0 && d > 0) defMap[no] = d + const desc = String(it?.sAciklama || '').trim() + if (no > 0 && d > 0) { + defMap[no] = d + if (desc) descMap[no] = desc + } }) const outliers = [] @@ -3176,18 +3483,53 @@ async function confirmDefaultQtyDeviationIfNeeded () { outliers.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct)) const maxRows = 30 - const lines = outliers.slice(0, maxRows).map(x => { + const rowsHtml = outliers.slice(0, maxRows).map(x => { const sign = x.pct >= 0 ? '+' : '' - return `${x.no}: varsayilan ${round4(x.defQty)} | girilen ${round4(x.enteredQty)} | fark ${sign}${round1(x.pct)}%` - }) - const truncated = outliers.length > maxRows - ? `\n... (Toplam ${outliers.length} satir. Ilk ${maxRows} gosterildi.)` + const pct = `${sign}${round1(x.pct)}%` + const cls = x.pct >= 0 ? 'color:#b71c1c;' : 'color:#1b5e20;' + const desc = String(descMap[x.no] || '').trim() + const noLabel = desc ? `${x.no} - ${escapeHtml(desc)}` : String(x.no) + return ` + + ${noLabel} + ${round4(x.defQty)} + ${round4(x.enteredQty)} + ${pct} + + ` + }).join('') + const truncatedNote = outliers.length > maxRows + ? `
Toplam ${outliers.length} satir var. Ilk ${maxRows} gosterildi.
` : '' const ok = await new Promise(resolve => { $q.dialog({ title: 'Varsayilan Miktar Kontrolu', - message: `Bazi hammadde turlerinde varsayilan miktardan %10'dan fazla sapma var.\n\n${lines.join('\n')}${truncated}\n\nOnayliyorsaniz Kaydet'e basın. Duzenlemek icin Geri Don.`, + html: true, + message: ` +
+ Bazi hammadde turlerinde varsayilan miktardan %10'dan fazla sapma var. +
+
+ + + + + + + + + + + ${rowsHtml} + +
HammaddeVarsayilanGirilenFark %
+
+ ${truncatedNote} +
+ Onayliyorsaniz Onayla ve Kaydet'e basın. Duzenlemek icin Geri Don. +
+ `, cancel: { label: 'Geri Don' }, ok: { label: 'Onayla ve Kaydet', color: 'primary' }, persistent: true @@ -3196,21 +3538,72 @@ async function confirmDefaultQtyDeviationIfNeeded () { return ok } +async function deleteCosting () { + if (!detailHeader.value) return + const n = parseInt(String(detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || onMLNo.value || '0'), 10) || 0 + if (!(n > 0)) { + $q.notify({ type: 'warning', message: 'Silinecek kayit bulunamadi.', position: 'top-right' }) + return + } + + const ok = await new Promise(resolve => { + $q.dialog({ + title: 'Kaydi Sil', + message: `Bu maliyet kaydi silinecek. (OnMLNo=${n}) Devam edilsin mi?`, + cancel: true, + persistent: true + }).onOk(() => resolve(true)).onCancel(() => resolve(false)) + }) + if (!ok) return + + saveLoading.value = true + try { + await post('/pricing/production-product-costing/onml/delete', { + n_onml_no: n + }) + clearLocalDraft() + $q.notify({ type: 'positive', message: 'Kayit silindi.', position: 'top-right' }) + router.replace({ name: 'production-product-costing-no-cost' }) + } catch (e) { + $q.notify({ type: 'negative', message: String(e?.message || e || 'Silinemedi'), position: 'top-right' }) + } finally { + saveLoading.value = false + } +} + +async function confirmRefresh () { + if (detailLoading.value) return + const ok = await new Promise(resolve => { + $q.dialog({ + title: 'Yenile', + message: hasUnsavedChanges.value + ? 'Bu islem sayfayi sifirlar ve kaydedilmemis degisiklikler kaybolur. Devam edilsin mi?' + : 'Bu islem sayfayi sifirlar. Devam edilsin mi?', + cancel: { label: 'Geri Don' }, + ok: { label: 'Onayla', color: 'negative' }, + persistent: true + }).onOk(() => resolve(true)).onCancel(() => resolve(false)) + }) + if (!ok) return + clearLocalDraft() + await fetchDetail({ clearDraft: true, hydrateDraft: false }) +} + async function saveChanges () { saveLoading.value = true try { requiredAttentionRowKeys.value = {} if (isNoCostDetail.value) { const missing = computeMissingRequiredSlots() - if (missing.length > 0) { - const ok = await new Promise(resolve => { - $q.dialog({ - title: 'Eksik Maliyet Parcalari', - message: `Eslestirilen parcalarda (fiyat > 0) girilmemis satirlar var. Devam etmek istiyor musunuz? (Eksik: ${missing.length})`, - cancel: true, - persistent: true - }).onOk(() => resolve(true)).onCancel(() => resolve(false)) - }) + if (missing.length > 0) { + const ok = await new Promise(resolve => { + $q.dialog({ + title: 'Eksik Maliyet Parcalari', + message: `Eslestirilen parcalarda fiyat girilmemis satirlar var. Devam etmek istiyor musunuz? (Eksik: ${missing.length})`, + cancel: true, + persistent: true + }).onOk(() => resolve(true)).onCancel(() => resolve(false)) + }) if (!ok) { const next = {} missing.forEach(x => { @@ -3250,7 +3643,8 @@ async function saveChanges () { fiyat_girilen: Number(resolveNumericRowInputPrice(r) || 0), fiyat_doviz: String(resolveInputCurrency(r) || '').trim(), maliyete_dahil: (r?.maliyeteDahil || r?.maliyete_dahil) ? 1 : 0, - cm_price_type_id: r?.cmPriceTypeId ?? r?.cm_price_type_id ?? null + cm_price_type_id: r?.cmPriceTypeId ?? r?.cm_price_type_id ?? null, + s_aciklama3: String(r?.sAciklama3 || '').trim() })) const deletes = (Array.isArray(deletedDetailRows.value) ? deletedDetailRows.value : []).map(d => ({ @@ -3278,6 +3672,9 @@ async function saveChanges () { $q.notify({ type: 'positive', message: 'Kaydedildi.', position: 'top-right' }) + // Force clear local draft before fetching fresh data to ensure we don't re-hydrate old inputs. + clearLocalDraft() + // If we created a new OnML (no-cost), switch to has-cost detail mode. if (isNoCostDetail.value && newOnMLNo > 0) { router.replace({ @@ -3289,6 +3686,25 @@ async function saveChanges () { }) return } + + // For existing costing, just refresh the detail. + await fetchDetail({ clearDraft: true, hydrateDraft: false }) + } catch (e) { + // Surface backend message (http.Error text) when available. + const msg = String( + e?.response?.data?.message || + e?.response?.data || + e?.message || + e || + 'Kaydedilemedi' + ) + slog.error('production-product-costing.detail', 'save:error', { + trace_id: traceId.value, + status: e?.response?.status, + error: msg + }) + $q.notify({ type: 'negative', message: msg, position: 'top-right' }) + return } finally { saveLoading.value = false } @@ -3489,6 +3905,11 @@ watch( max-width: 96vw; } +.pcd-row-editor-drag-handle { + cursor: move; + user-select: none; +} + .pcd-row-editor-dialog :deep(.pcd-row-editor-entry .q-field__control) { background: color-mix(in srgb, var(--q-secondary) 18%, white) !important; border: 1px solid var(--q-secondary) !important; diff --git a/ui/src/pages/ProductionProductCostingMTBolumMapping.vue b/ui/src/pages/ProductionProductCostingMTBolumMapping.vue index 37e7b89..c45e4ec 100644 --- a/ui/src/pages/ProductionProductCostingMTBolumMapping.vue +++ b/ui/src/pages/ProductionProductCostingMTBolumMapping.vue @@ -87,6 +87,43 @@