diff --git a/svc/.env.local b/svc/.env.local index 08055f7..8c10980 100644 --- a/svc/.env.local +++ b/svc/.env.local @@ -21,7 +21,8 @@ UI_DIR=/opt/bssapp/ui/dist # =============================== # DATABASES # =============================== -POSTGRES_CONN=host=46.224.33.150 port=5432 user=postgres password=tayitkan dbname=baggib2b sslmode=disable +# Local dev: connect via SSH tunnel to the server (Windows -> 15432 -> server 127.0.0.1:5432). +POSTGRES_CONN=host=127.0.0.1 port=15432 user=postgres password=tayitkan dbname=baggib2b sslmode=disable MSSQL_CONN=sqlserver://sa:Gil_0150@10.0.0.9:1433?database=BAGGI_V3&encrypt=disable URETIM_MSSQL_CONN=sqlserver://sa:Gil_0150@10.0.0.9:1433?database=URETIM&encrypt=disable diff --git a/svc/main.go b/svc/main.go index e0193e2..03a7b28 100644 --- a/svc/main.go +++ b/svc/main.go @@ -16,6 +16,7 @@ import ( "os" "path" "path/filepath" + "runtime" "runtime/debug" "strings" @@ -906,6 +907,12 @@ func main() { log.Println("⚠️ .env / mail.env bulunamadı") } + // Local dev convenience: on Windows we generally want to override .env with .env.local + // (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432). + if runtime.GOOS == "windows" { + _ = godotenv.Overload(".env.local") + } + jwtSecret := os.Getenv("JWT_SECRET") if len(jwtSecret) < 10 { log.Fatal("❌ JWT_SECRET tanımlı değil veya çok kısa (min 10 karakter)") diff --git a/svc/models/production_product_costing.go b/svc/models/production_product_costing.go index 06f2ce3..ef7d5fc 100644 --- a/svc/models/production_product_costing.go +++ b/svc/models/production_product_costing.go @@ -98,6 +98,7 @@ type ProductionHasCostDetailHeader struct { NOnMLNo string `json:"nOnMLNo"` UrunKodu string `json:"UrunKodu"` UrunAdi string `json:"UrunAdi"` + UrunIlkGrubu string `json:"UrunIlkGrubu"` UrunAnaGrubu string `json:"UrunAnaGrubu"` UrunAltGrubu string `json:"UrunAltGrubu"` UretimSekliID string `json:"UretimSekliID"` @@ -131,6 +132,7 @@ type ProductionHasCostDetailEditorOption struct { NHammaddeTuruNo string `json:"nHammaddeTuruNo"` SHammaddeTuruAdi string `json:"sHammaddeTuruAdi"` SAciklama3 string `json:"sAciklama3"` + MTUrtMTBolumID int `json:"mtUrtMTBolumID"` SKodu string `json:"sKodu"` SAciklama string `json:"sAciklama"` SModel string `json:"sModel"` diff --git a/svc/queries/production_product_costing.go b/svc/queries/production_product_costing.go index 704b40a..0f2422c 100644 --- a/svc/queries/production_product_costing.go +++ b/svc/queries/production_product_costing.go @@ -37,6 +37,34 @@ ORDER BY ProductCode; return urunAnaGrubu, urunAltGrubu, nil } +func GetProductIlkAnaAltGrupByUrunKodu(ctx context.Context, mssqlDB *sql.DB, urunKodu string) (urunIlkGrubu string, urunAnaGrubu string, urunAltGrubu string, err error) { + urunKodu = strings.TrimSpace(urunKodu) + if mssqlDB == nil || urunKodu == "" { + return "", "", "", nil + } + + // Nebim V3: ProductFilterWithDescription exposes ProductAtt42Desc (ilk grup), ProductAtt01Desc (ana), ProductAtt02Desc (alt). + sqlText := ` +SELECT TOP 1 + ISNULL(ProductAtt42Desc, '') AS UrunIlkGrubu, + ISNULL(ProductAtt01Desc, '') AS UrunAnaGrubu, + ISNULL(ProductAtt02Desc, '') AS UrunAltGrubu +FROM ProductFilterWithDescription('TR') +WHERE IsBlocked = 0 + AND LTRIM(RTRIM(ProductCode)) = @p1 +ORDER BY ProductCode; +` + + row := mssqlDB.QueryRowContext(ctx, sqlText, urunKodu) + if err := row.Scan(&urunIlkGrubu, &urunAnaGrubu, &urunAltGrubu); err != nil { + if err == sql.ErrNoRows { + return "", "", "", nil + } + return "", "", "", err + } + return urunIlkGrubu, urunAnaGrubu, urunAltGrubu, nil +} + func GetProductionProductCostingAnaGrupOptions(ctx context.Context, mssqlDB *sql.DB, search string, limit int) (*sql.Rows, error) { search = strings.TrimSpace(search) if limit <= 0 { @@ -131,6 +159,7 @@ SELECT TOP (@p2) ISNULL(B.sAdi, '') AS sAdi FROM dbo.spUrtMTBolum B WITH (NOLOCK) WHERE ISNULL(B.bAktif, 0) = 1 + AND ISNULL(B.nUrtTipiID, 0) = 1 AND (@p1 = '' OR ISNULL(B.sAdi, '') LIKE @p3 OR CONVERT(VARCHAR(32), ISNULL(B.nUrtMTBolumID, 0)) LIKE @p3) ORDER BY B.nUrtMTBolumID ` @@ -160,6 +189,7 @@ SELECT FROM dbo.mk_MaliyetParcaEslestirme M WITH (NOLOCK) LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) ON B.nUrtMTBolumID = M.nUrtMTBolumID + AND ISNULL(B.nUrtTipiID, 0) = 1 OUTER APPLY ( SELECT STUFF(( @@ -268,6 +298,97 @@ DECLARE @id INT; return mappingID, nil } +type ProductionProductCostingInvalidHammaddeForPart struct { + NHammaddeTuruNo int + Aciklama string + MTBolumID int +} + +// FilterHammaddeTurleriForPart enforces spUrtOnMLHammaddeTuru.MTnUrtMTBolumID rules: +// - If MTnUrtMTBolumID > 0, the hammadde type is allowed only for that part (nUrtMTBolumID). +// - If MTnUrtMTBolumID is 0/NULL, it is considered global/unknown and allowed. +func FilterHammaddeTurleriForPart( + ctx context.Context, + uretimDB *sql.DB, + nUrtMTBolumID int, + nHammaddeTurleri []int, +) (allowed []int, invalid []ProductionProductCostingInvalidHammaddeForPart, err error) { + seen := make(map[int]bool, len(nHammaddeTurleri)) + unique := make([]int, 0, len(nHammaddeTurleri)) + for _, n := range nHammaddeTurleri { + if n <= 0 || seen[n] { + continue + } + seen[n] = true + unique = append(unique, n) + } + if len(unique) == 0 { + return []int{}, []ProductionProductCostingInvalidHammaddeForPart{}, nil + } + + // Build IN list safely. + ph := make([]string, 0, len(unique)) + args := make([]any, 0, len(unique)) + for i, n := range unique { + ph = append(ph, fmt.Sprintf("@p%d", i+1)) + args = append(args, n) + } + + sqlText := fmt.Sprintf(` +SELECT + ISNULL(H.nHammaddeTuruNo, 0) AS nHammaddeTuruNo, + ISNULL(H.sAciklama, '') AS sAciklama, + ISNULL(H.MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID +FROM dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK) +WHERE H.nHammaddeTuruNo IN (%s) +`, strings.Join(ph, ",")) + + rows, qerr := uretimDB.QueryContext(ctx, sqlText, args...) + if qerr != nil { + return nil, nil, qerr + } + defer rows.Close() + + type meta struct { + aciklama string + mtBolumID int + } + metaByNo := map[int]meta{} + for rows.Next() { + var no int + var aciklama string + var mtBolum int + if scanErr := rows.Scan(&no, &aciklama, &mtBolum); scanErr != nil { + return nil, nil, scanErr + } + metaByNo[no] = meta{aciklama: strings.TrimSpace(aciklama), mtBolumID: mtBolum} + } + if rowsErr := rows.Err(); rowsErr != nil { + return nil, nil, rowsErr + } + + allowed = make([]int, 0, len(unique)) + invalid = make([]ProductionProductCostingInvalidHammaddeForPart, 0) + for _, n := range unique { + m, ok := metaByNo[n] + if !ok { + // Unknown in lookup; allow (we can't validate). + allowed = append(allowed, n) + continue + } + if m.mtBolumID > 0 && nUrtMTBolumID > 0 && m.mtBolumID != nUrtMTBolumID { + invalid = append(invalid, ProductionProductCostingInvalidHammaddeForPart{ + NHammaddeTuruNo: n, + Aciklama: m.aciklama, + MTBolumID: m.mtBolumID, + }) + continue + } + allowed = append(allowed, n) + } + return allowed, invalid, nil +} + func SetProductionProductCostingParcaMappingActive(ctx context.Context, uretimDB *sql.DB, id int, bAktif bool, user string) error { user = strings.TrimSpace(user) activeVal := 0 @@ -699,7 +820,8 @@ WITH RecipeMatch AS ( ), HammaddeTekil AS ( SELECT - ISNULL(NULLIF(LTRIM(RTRIM(HT.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(HT.sAciklama)), ''), N'TANIMSIZ')) AS sAciklama3, + -- Group label: DT/TP/CM2/FABRIC... Prefer sAciklama3, then sAciklama2. Never fall back to sAciklama (name). + COALESCE(NULLIF(LTRIM(RTRIM(HT.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(HT.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3, ISNULL(HT.nHammaddeTuruNo, 0) AS nHammaddeTuruNoSort, RTRIM(CONVERT(VARCHAR(32), ISNULL(HT.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, -- Match URETIM's sp_pUrtOnMaliyetRecetedenKop behavior: use model code + color code instead of variant stock code. @@ -709,6 +831,8 @@ HammaddeTekil AS ( ISNULL(HT.sAciklama, '') AS sHammaddeTuruAdi, ISNULL(S.sBirimCinsi1, '') AS sBirim, ISNULL(RMik.lHMiktar, 0) AS lMiktar, + ISNULL(HT.MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID, + ISNULL(B.sAdi, '') AS sParcaAdi, ROW_NUMBER() OVER ( PARTITION BY HT.nHammaddeTuruNo ORDER BY ISNULL(S.sModel, ISNULL(S.sKodu, '')) @@ -725,13 +849,20 @@ HammaddeTekil AS ( SELECT TOP 1 H.nHammaddeTuruNo, H.sAciklama, - H.sAciklama3 + H.sAciklama2, + H.sAciklama3, + H.MTnUrtMTBolumID FROM dbo.spUrtOnMLHammaddeTuru H - WHERE H.nUrtMBolumID = RMik.nUrtMBolumID + -- In our data, RMik.nUrtMBolumID carries the required hammadde type number (e.g. 900/901/3300), + -- not a "bolum id". So match by hammadde type number. + WHERE H.nHammaddeTuruNo = RMik.nUrtMBolumID ORDER BY - CASE WHEN H.nUrtMTBolumID = RMik.nUrtMTBolumID THEN 0 ELSE 1 END, + CASE WHEN H.MTnUrtMTBolumID = RMik.nUrtMTBolumID THEN 0 ELSE 1 END, H.nHammaddeTuruNo ) HT + LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) + ON B.nUrtMTBolumID = HT.MTnUrtMTBolumID + AND ISNULL(B.nUrtTipiID, 0) = 1 WHERE HT.nHammaddeTuruNo IS NOT NULL ) SELECT @@ -762,7 +893,7 @@ SELECT 0.0 AS gbpTutar, HT.sBirim, HT.sHammaddeTuruAdi, - HT.sHammaddeTuruAdi AS sParcaAdi + HT.sParcaAdi AS sParcaAdi FROM HammaddeTekil HT WHERE HT.rn = 1 ORDER BY @@ -800,15 +931,22 @@ func GetProductionHasCostDetailHammaddeTypeOptions( SELECT TOP (@p2) RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo, ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi, - ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') AS sAciklama3 - FROM dbo.spUrtOnMLHammaddeTuru T - WHERE - ISNULL(T.bAktif, 0) = 1 - AND ( + COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3, + ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID, + ISNULL(B.sAdi, '') AS sParcaAdi +FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK) +LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK) + ON B.nUrtMTBolumID = T.MTnUrtMTBolumID + AND ISNULL(B.nUrtTipiID, 0) = 1 +WHERE + ISNULL(T.bAktif, 0) = 1 + AND ( @p1 = '' OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3 OR ISNULL(T.sAciklama, '') LIKE @p3 + OR ISNULL(T.sAciklama2, '') LIKE @p3 OR ISNULL(T.sAciklama3, '') LIKE @p3 + OR ISNULL(B.sAdi, '') LIKE @p3 ) ORDER BY CASE diff --git a/svc/routes/production_product_costing.go b/svc/routes/production_product_costing.go index e0caa5f..8e7b068 100644 --- a/svc/routes/production_product_costing.go +++ b/svc/routes/production_product_costing.go @@ -596,10 +596,11 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ logger.Info("request done", "n_urt_recete_id", item.NUrtReceteID, "urun_kodu", item.UrunKodu) if mssqlDB != nil { - ana, alt, err := queries.GetProductAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu) + ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu) if err != nil { logger.Warn("product group query error", "err", err) } else { + item.UrunIlkGrubu = ilk item.UrunAnaGrubu = ana item.UrunAltGrubu = alt } @@ -659,10 +660,11 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ logger.Info("request done", "n_onml_no", item.NOnMLNo, "urun_kodu", item.UrunKodu, "n_urt_recete_id", item.NUrtReceteID) if mssqlDB != nil { - ana, alt, err := queries.GetProductAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu) + ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu) if err != nil { logger.Warn("product group query error", "err", err) } else { + item.UrunIlkGrubu = ilk item.UrunAnaGrubu = ana item.UrunAltGrubu = alt } @@ -761,7 +763,7 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht list := make([]models.ProductionHasCostDetailEditorOption, 0, limit) for rows.Next() { var item models.ProductionHasCostDetailEditorOption - if err := rows.Scan(&item.NHammaddeTuruNo, &item.SHammaddeTuruAdi, &item.SAciklama3); err != nil { + if err := rows.Scan(&item.NHammaddeTuruNo, &item.SHammaddeTuruAdi, &item.SAciklama3, &item.MTUrtMTBolumID, &item.SParcaAdi); err != nil { logger.Warn("hammadde scan error", "err", err) log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde scan error: %v", err) continue @@ -769,7 +771,6 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht item.Kind = "hammadde" item.Value = item.NHammaddeTuruNo item.Label = strings.TrimSpace(item.NHammaddeTuruNo + " - " + item.SHammaddeTuruAdi) - item.SParcaAdi = item.SAciklama3 list = append(list, item) } if err := rows.Err(); err != nil { @@ -1958,7 +1959,18 @@ func PostProductionProductCostingParcaMappingUpsertHandler(w http.ResponseWriter return } - id, err := queries.UpsertProductionProductCostingParcaMapping(ctx, uretimDB, req.UrunIlkGrubu, req.UrunAnaGrubu, req.UrunAltGrubu, req.NUrtMTBolumID, req.NHammaddeTurleri, req.BAktif, user) + allowed, invalid, ferr := queries.FilterHammaddeTurleriForPart(ctx, uretimDB, req.NUrtMTBolumID, req.NHammaddeTurleri) + if ferr != nil { + logger.Error("hammadde validation error", "err", ferr) + http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) + return + } + // Soft-enforce: drop invalid types instead of hard failing. This prevents a "multi-part save" from failing + // when the UI reuses the same hammadde list across multiple MT bolum selections. + // We still return the ignored list so UI/logs can surface it. + ignored := invalid + + id, err := queries.UpsertProductionProductCostingParcaMapping(ctx, uretimDB, req.UrunIlkGrubu, req.UrunAnaGrubu, req.UrunAltGrubu, req.NUrtMTBolumID, allowed, req.BAktif, user) if err != nil { logger.Error("exec error", "err", err) http.Error(w, "Veritabani hatasi", http.StatusInternalServerError) @@ -1966,7 +1978,10 @@ func PostProductionProductCostingParcaMappingUpsertHandler(w http.ResponseWriter } logger.Info("request done", "id", id, "user", user, "bAktif", req.BAktif) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": id}) + if len(ignored) > 0 { + logger.Warn("hammadde types ignored due to MT bolum mismatch", "nUrtMTBolumID", req.NUrtMTBolumID, "ignored_count", len(ignored)) + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": id, "ignored": ignored}) } type productionProductCostingSetActiveRequest struct { diff --git a/ui/quasar.config.js.temporary.compiled.1778713117975.mjs b/ui/quasar.config.js.temporary.compiled.1778713117975.mjs new file mode 100644 index 0000000..16944ba --- /dev/null +++ b/ui/quasar.config.js.temporary.compiled.1778713117975.mjs @@ -0,0 +1,135 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * 1. DO NOT edit this file directly as it won't do anything. + * 2. EDIT the original quasar.config file INSTEAD. + * 3. DO NOT git commit this file. It should be ignored. + * + * This file is still here because there was an error in + * the original quasar.config file and this allows you to + * investigate the Node.js stack error. + * + * After you fix the original file, this file will be + * deleted automatically. + **/ + + +// quasar.config.js +import { defineConfig } from "@quasar/app-webpack/wrappers"; +var quasar_config_default = defineConfig(() => { + const apiBaseUrl = (process.env.VITE_API_BASE_URL || "/api").trim(); + return { + /* ===================================================== + APP INFO + ===================================================== */ + productName: "Baggi BSS", + productDescription: "Baggi Tekstil Business Support System", + /* ===================================================== + BOOT FILES + ===================================================== */ + boot: ["dayjs", "locale", "resizeObserverGuard"], + /* ===================================================== + GLOBAL CSS + ===================================================== */ + css: ["app.css"], + /* ===================================================== + ICONS / FONTS + ===================================================== */ + extras: [ + "roboto-font", + "material-icons" + ], + /* ===================================================== + BUILD (PRODUCTION) + ===================================================== */ + build: { + vueRouterMode: "hash", + env: { + VITE_API_BASE_URL: apiBaseUrl + }, + esbuildTarget: { + browser: ["es2022", "firefox115", "chrome115", "safari14"], + node: "node20" + }, + // Cache & performance + gzip: true, + preloadChunks: true + }, + /* ===================================================== + DEV SERVER (LOCAL) + ===================================================== */ + devServer: { + server: { type: "http" }, + port: 9e3, + open: true, + client: { + overlay: { + errors: true, + warnings: false, + runtimeErrors: false + } + }, + // DEV proxy (CORS'suz) + proxy: [ + { + context: ["/api"], + target: "http://localhost:8080", + changeOrigin: true, + secure: false, + ws: true, + timeout: 0, + proxyTimeout: 0 + } + ] + }, + /* ===================================================== + QUASAR FRAMEWORK + ===================================================== */ + framework: { + config: { + notify: { + position: "top", + timeout: 2500 + } + }, + lang: "tr", + plugins: [ + "Loading", + "Dialog", + "Notify" + ] + }, + animations: [], + /* ===================================================== + SSR / PWA (DISABLED) + ===================================================== */ + ssr: { + prodPort: 3e3, + middlewares: ["render"], + pwa: false + }, + pwa: { + workboxMode: "GenerateSW" + }, + /* ===================================================== + MOBILE / DESKTOP + ===================================================== */ + capacitor: { + hideSplashscreen: true + }, + electron: { + preloadScripts: ["electron-preload"], + inspectPort: 5858, + bundler: "packager", + builder: { + appId: "baggisowtfaresystem" + } + }, + bex: { + extraScripts: [] + } + }; +}); +export { + quasar_config_default as default +}; diff --git a/ui/src/pages/ProductionProductCostingHasCostDetail.vue b/ui/src/pages/ProductionProductCostingHasCostDetail.vue index 3f734b0..1bf090c 100644 --- a/ui/src/pages/ProductionProductCostingHasCostDetail.vue +++ b/ui/src/pages/ProductionProductCostingHasCostDetail.vue @@ -44,7 +44,15 @@ @click="toggleHeaderInfo" /> - + - - - -
-
-
- -
-
- -
-
- -
-
@@ -160,6 +144,9 @@
+
+ +
@@ -180,9 +167,9 @@
-
+
-
Parça Bazlı Maliyet Özetleri
+
Parça Bazlı Maliyet Özellikleri
@@ -359,7 +346,7 @@ @@ -826,14 +813,18 @@ const flatDetailRows = computed(() => detailGroups.value.flatMap(grp => Array.is // no-cost: required parca slots (from Maliyet Parca Eslestirme) const requiredParcaMappings = ref([]) const requiredAttentionRowKeys = ref({}) +const requiredHammaddeMetaCache = ref({}) // hammaddeNo -> { groupName, parcaAdi } + +// Bump this when draft payload semantics change, to avoid hydrating stale rows after backend logic updates. +const DRAFT_STORAGE_VERSION = 'v2' const draftStorageKey = computed(() => { if (isNoCostDetail.value) { if (!recipeCode.value) return '' - return `pcd-costing:no-cost:${String(productCode.value || '').trim()}:${String(recipeCode.value || '').trim()}` + return `pcd-costing:${DRAFT_STORAGE_VERSION}:no-cost:${String(productCode.value || '').trim()}:${String(recipeCode.value || '').trim()}` } if (!onMLNo.value) return '' - return `pcd-costing:has-cost:${String(onMLNo.value).trim()}` + return `pcd-costing:${DRAFT_STORAGE_VERSION}:has-cost:${String(onMLNo.value).trim()}` }) const currentHeaderSnapshot = computed(() => JSON.stringify({ @@ -1051,6 +1042,27 @@ function isCMGroupName (value) { return normalizedValue.includes('CM1') || normalizedValue.includes('CM2') } +function isKnownGroupName (value) { + const v = String(value || '').trim().toUpperCase() + if (!v) return false + return v === 'DT' || v === 'TP' || v === 'FABRIC' || v === 'CM1' || v === 'CM2' || + v.includes(' DT') || v.includes(' TP') || v.includes('FABRIC') || v.includes('CM1') || v.includes('CM2') +} + +function normalizeLegacyParcaAndGroup (seed = {}) { + const parca = String(seed?.sParcaAdi || '').trim() + const group = String(seed?.sAciklama3 || '').trim() + if (!parca && !group) return seed + + // Some legacy rows were saved with parca/group swapped. Fix it at the UI layer: + // - Parca: CEKET/PANTOLON/YELEK... + // - Group: DT/TP/CM1/CM2/FABRIC... + if (isKnownGroupName(parca) && !isKnownGroupName(group)) { + return { ...seed, sParcaAdi: group, sAciklama3: parca } + } + return seed +} + function createEmptyExchangeRates () { return { rateDate: '', @@ -1062,6 +1074,7 @@ function createEmptyExchangeRates () { } function createRowEditorForm (seed = {}) { + seed = normalizeLegacyParcaAndGroup(seed) const defaultCurrency = normalizePriceCurrency(seed?.inputPricePrBr || seed?.fiyat_doviz || detailHeader.value?.sDovizCinsi) || 'USD' const cmPriceTypeId = normalizeCMPriceTypeId(seed?.cmPriceTypeId ?? seed?.cm_price_type_id, seed?.sAciklama3 ?? seed?.sParcaAdi) return { @@ -1070,10 +1083,10 @@ function createRowEditorForm (seed = {}) { nStokID: String(seed?.nStokID || '').trim(), sModel: String(seed?.sModel || '').trim(), nOnMLDetNo: String(seed?.nOnMLDetNo || '').trim(), - sParcaAdi: String(seed?.sParcaAdi || seed?.sAciklama3 || '').trim(), + sParcaAdi: String(seed?.sParcaAdi || '').trim(), nHammaddeTuruNo: String(seed?.nHammaddeTuruNo || '').trim(), sHammaddeTuruAdi: String(seed?.sHammaddeTuruAdi || '').trim(), - sAciklama3: String(seed?.sAciklama3 || seed?.sParcaAdi || '').trim(), + sAciklama3: String(seed?.sAciklama3 || '').trim(), sKodu: String(seed?.sKodu || '').trim(), sAciklama: String(seed?.sAciklama || '').trim(), sRenk: String(seed?.sRenk || seed?.ColorCode || '').trim(), @@ -1828,7 +1841,7 @@ function normalizeDetailRows (items, groupName = '') { function normalizeDetailGroups (groups) { const list = Array.isArray(groups) ? groups : [] - return list.map(grp => { + const out = list.map(grp => { const groupName = String(grp?.sAciklama3 || '').trim() const items = normalizeDetailRows(grp?.items, groupName).map(row => ({ ...row, @@ -1836,16 +1849,52 @@ function normalizeDetailGroups (groups) { cmPriceTypeId: normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, groupName || row?.sAciklama3) })) // USD TUTAR (DESC) sıralama - items.sort((a, b) => { - const valA = resolveRowUSDTutar(a) - const valB = resolveRowUSDTutar(b) - return valB - valA - }) + if (isNoCostDetail.value) { + items.sort((a, b) => { + const ha = parseInt(String(a?.nHammaddeTuruNo || '0'), 10) || 0 + const hb = parseInt(String(b?.nHammaddeTuruNo || '0'), 10) || 0 + if (ha !== hb) return ha - hb + const ka = String(a?.sKodu || '').trim() + const kb = String(b?.sKodu || '').trim() + return ka.localeCompare(kb, 'tr') + }) + } else { + items.sort((a, b) => { + const valA = resolveRowUSDTutar(a) + const valB = resolveRowUSDTutar(b) + return valB - valA + }) + } return { ...grp, items } }) + return sortDetailGroups(out) +} + +function groupOrderIndex (name) { + const v = String(name || '').trim().toUpperCase() + if (!v) return 999 + if (v.includes('CM2')) return 0 + if (v.includes('FABRIC')) return 1 + if (v === 'TP' || v.includes(' TP')) return 2 + if (v === 'DT' || v.includes(' DT')) return 3 + if (v.includes('CM1')) return 4 + return 999 +} + +function sortDetailGroups (groups) { + const list = Array.isArray(groups) ? [...groups] : [] + list.sort((a, b) => { + const ga = String(a?.sAciklama3 || '').trim() + const gb = String(b?.sAciklama3 || '').trim() + const ia = groupOrderIndex(ga) + const ib = groupOrderIndex(gb) + if (ia !== ib) return ia - ib + return ga.localeCompare(gb, 'tr') + }) + return list } function recalculateDetailRow (row, options = {}) { @@ -1977,11 +2026,7 @@ function resolveElHeight (refVal) { function updateStickyTop () { const stackH = resolveElHeight(stickyStackRef.value) - // Quasar default header height is usually around 50px - // If we are in a sub-layout or context where top header is not 50px, this might need adjustment - const layoutHeader = document.querySelector('.q-header') - const layoutHeaderH = layoutHeader ? layoutHeader.offsetHeight : 50 - subHeaderTop.value = (stackH || 0) + layoutHeaderH + subHeaderTop.value = (stackH || 0) + 50 } function toggleHeaderInfo () { @@ -2052,7 +2097,9 @@ async function fetchExchangeRatesForCostDate (targetDate = costDate.value) { } } -async function fetchDetail () { +async function fetchDetail (options = {}) { + const hydrateDraft = options.hydrateDraft !== false + const clearDraft = options.clearDraft === true if (isNoCostDetail.value && !recipeCode.value) { detailError.value = 'Recete kodu bulunamadi' detailGroups.value = [] @@ -2102,6 +2149,9 @@ async function fetchDetail () { lineHistoryLastRecipeMatchStage.value = '' try { + if (clearDraft) { + clearLocalDraft() + } const detailParams = buildDetailFetchParams() slog.info('production-product-costing.detail', 'fetch-detail:start', { trace_id: traceId.value, @@ -2123,11 +2173,13 @@ async function fetchDetail () { detailGroups.value = normalizeDetailGroups(groupsData) initialHeaderSnapshot.value = currentHeaderSnapshot.value // Optional: hydrate local draft after base data load. - tryHydrateFromLocalDraft() + if (hydrateDraft) { + tryHydrateFromLocalDraft() + } // ensure required placeholder rows exist (based on mapping screen) try { const mappings = await fetchRequiredParcaMappings() - ensureNoCostRequiredRowsFromMappings(mappings) + await ensureNoCostRequiredRowsFromMappings(mappings) } catch (err) { slog.error('production-product-costing.detail', 'required-mapping:error', { trace_id: traceId.value, @@ -2526,7 +2578,8 @@ async function bootstrapRowEditorOptions () { if (selectedHammadde) { rowEditorForm.value.sHammaddeTuruAdi = String(selectedHammadde.sHammaddeTuruAdi || rowEditorForm.value.sHammaddeTuruAdi || '').trim() rowEditorForm.value.sAciklama3 = String(selectedHammadde.sAciklama3 || rowEditorForm.value.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ' - rowEditorForm.value.sParcaAdi = String(selectedHammadde.sParcaAdi || selectedHammadde.sAciklama3 || rowEditorForm.value.sParcaAdi || '').trim() + // Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...). + rowEditorForm.value.sParcaAdi = String(selectedHammadde.sParcaAdi || rowEditorForm.value.sParcaAdi || '').trim() } const selectedItem = itemRows.find(opt => String(opt?.value || '') === itemSearch) if (selectedItem) { @@ -2558,7 +2611,8 @@ function onRowEditorHammaddeChange (value) { if (!selected) return rowEditorForm.value.sHammaddeTuruAdi = String(selected.sHammaddeTuruAdi || '').trim() rowEditorForm.value.sAciklama3 = String(selected.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ' - rowEditorForm.value.sParcaAdi = String(selected.sParcaAdi || selected.sAciklama3 || '').trim() + // Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...). + rowEditorForm.value.sParcaAdi = String(selected.sParcaAdi || '').trim() if (!isCMGroupName(rowEditorForm.value.sAciklama3)) { rowEditorForm.value.cmPriceTypeChecked = false } @@ -2603,7 +2657,7 @@ function onRowEditorColorChange (value) { } function buildRowFromEditorForm () { - const form = rowEditorForm.value + const form = normalizeLegacyParcaAndGroup(rowEditorForm.value) const existingRow = flatDetailRows.value.find(row => row.__rowKey === rowEditorTargetRowKey.value) const cmPriceTypeId = normalizeCMPriceTypeId(form.cmPriceTypeChecked ? 2 : 1, form.sAciklama3) if (!existingRow) { @@ -2618,8 +2672,9 @@ function buildRowFromEditorForm () { nStokID: String(form.nStokID || '').trim(), sModel: String(form.sModel || '').trim(), nOnMLDetNo: String(form.nOnMLDetNo || '').trim(), - sParcaAdi: String(form.sParcaAdi || form.sAciklama3 || '').trim(), - sAciklama3: String(form.sAciklama3 || form.sParcaAdi || 'TANIMSIZ').trim() || 'TANIMSIZ', + // Keep Parca Adi and Parca Grubu distinct. sAciklama3 is the group key (DT/TP/CM2/FABRIC). + sParcaAdi: String(form.sParcaAdi || '').trim(), + sAciklama3: String(form.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ', nHammaddeTuruNo: String(form.nHammaddeTuruNo || '').trim(), sHammaddeTuruAdi: String(form.sHammaddeTuruAdi || '').trim(), sKodu: String(form.sKodu || '').trim(), @@ -2662,7 +2717,14 @@ function applyEditorRowToGroups (nextRow) { nextGroups[targetIndex] = { ...nextGroups[targetIndex], items: [...(Array.isArray(nextGroups[targetIndex].items) ? nextGroups[targetIndex].items : []), nextRow] - .sort((left, right) => parseInt(String(left?.nOnMLDetNo || '0'), 10) - parseInt(String(right?.nOnMLDetNo || '0'), 10)) + .sort((left, right) => { + if (isNoCostDetail.value) { + const ha = parseInt(String(left?.nHammaddeTuruNo || '0'), 10) || 0 + const hb = parseInt(String(right?.nHammaddeTuruNo || '0'), 10) || 0 + if (ha !== hb) return ha - hb + } + return parseInt(String(left?.nOnMLDetNo || '0'), 10) - parseInt(String(right?.nOnMLDetNo || '0'), 10) + }) } } else { nextGroups.push({ @@ -2673,7 +2735,7 @@ function applyEditorRowToGroups (nextRow) { }) } - detailGroups.value = nextGroups + detailGroups.value = sortDetailGroups(nextGroups) syncAllGroupsOpen() schedulePersistLocalDraft() } @@ -2695,49 +2757,143 @@ 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 (!ana || !alt) return [] + if (!ilk || !ana || !alt) return [] const data = await get('/pricing/production-product-costing/maliyet-parca-eslestirme', { trace_id: traceId.value, only_active: 1, + urun_ilk_grubu: ilk, urun_ana_grubu: ana, urun_alt_grubu: alt }) return Array.isArray(data) ? data : [] } -function ensureNoCostRequiredRowsFromMappings (mappings) { +async function resolveHammaddeMetaForRequired (hNo) { + const key = String(hNo || '').trim() + if (!key) return null + const cached = requiredHammaddeMetaCache.value?.[key] + if (cached) return cached + + try { + const rows = await get('/pricing/production-product-costing/detail-editor-options', { + kind: 'hammadde', + search: key, + limit: 25, + trace_id: traceId.value + }) + const list = Array.isArray(rows) ? rows : [] + const hit = list.find(x => String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim() === key) + const meta = hit + ? { + // In URETIM lookups, DT/TP/CM2/FABRIC is often stored in sAciklama2 (and sometimes in sAciklama3). + groupName: String(hit?.sAciklama3 || hit?.sAciklama2 || '').trim(), + // Hammadde type description is usually sAciklama (e.g. "CKT KUMAS"). + // NOTE: detail-editor-options returns `sHammaddeTuruAdi` already without the number. Do not fall back to `label`, + // otherwise we end up rendering "3300 - 3300 - CKT ASKI". + hammaddeAdi: String(hit?.sHammaddeTuruAdi || hit?.sAciklama || '').trim(), + // Part info: spUrtOnMLHammaddeTuru.nUrtMTBolumID -> spUrtMTBolum.sAdi + mtBolumID: parseInt(String(hit?.mtUrtMTBolumID ?? hit?.MTUrtMTBolumID ?? hit?.nUrtMTBolumID ?? hit?.NUrtMTBolumID ?? '0'), 10) || 0, + parcaAdi: String(hit?.sParcaAdi || '').trim() + } + : null + requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: meta || { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } } + return meta + } catch { + requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } } + return null + } +} + +async function ensureNoCostRequiredRowsFromMappings (mappings) { const list = Array.isArray(mappings) ? mappings : [] requiredParcaMappings.value = list if (list.length === 0) return - // Add missing placeholder rows (qty=1, price=0) to remind user - list.forEach(mapping => { - const groupName = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi || mapping?.sAciklama3) - const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : [] - hList.forEach(hNoRaw => { - const hNo = normalizeHammaddeNo(hNoRaw) - if (!hNo) return + // Defensive: The mapping payload may contain duplicate hammadde numbers across rows (or even inside a single row). + // IMPORTANT: Placeholders are keyed by (nUrtMTBolumID + nHammaddeTuruNo). The same hammadde type can be required + // for multiple parts (Ceket/Pantolon/Yelek...), so de-duping only by hNo is incorrect. + const processedRequiredKeys = new Set() - const exists = flatDetailRows.value.some(r => - normalizeGroupName(r?.sAciklama3) === groupName && - normalizeHammaddeNo(r?.nHammaddeTuruNo) === hNo - ) - if (exists) return + // Add missing placeholder rows (qty=1, price=0) to remind user + for (const mapping of list) { + // Parca adi (CEKET/PANTOLON/YELEK...) comes from the MT bolum description (joined in backend as parcaBolumAdi). + // sAciklama3 is reserved for the group header (DT/TP/CM2/FABRIC). + const mappingParcaAdi = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi) + const mappingMtBolumID = parseInt(String(mapping?.nUrtMTBolumID ?? mapping?.NUrtMTBolumID ?? '0'), 10) || 0 + const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : [] + for (const hNoRaw of hList) { + const hNo = normalizeHammaddeNo(hNoRaw) + if (!hNo) continue + const reqKey = `${mappingMtBolumID}:${hNo}` + if (processedRequiredKeys.has(reqKey)) continue + processedRequiredKeys.add(reqKey) + + // Prefer the hammadde lookup's groupName (DT/TP/CM2/FABRIC) + // so placeholder rows land under the correct group headers. + const meta = await resolveHammaddeMetaForRequired(hNo) + const groupName = normalizeGroupName(meta?.groupName || '') + const hammaddeAdi = String(meta?.hammaddeAdi || '').trim() + const effectiveGroupName = groupName || 'TANIMSIZ' + const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0 + const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim()) + // Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it. + const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi) + + const anyMatch = flatDetailRows.value.find(r => { + if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false + const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0 + if (rowMtBolumID > 0 && mtBolumID > 0) return rowMtBolumID === mtBolumID + // If we don't have row's nUrtMTBolumID (legacy rows / older placeholders), fall back to matching by part name. + // Also accept legacy placeholders where sParcaAdi accidentally contains the group label (DT/TP/...), so we can move/fix them. + const rowParca = normalizeGroupName(r?.sParcaAdi) + return rowParca === desiredParcaAdi || isKnownGroupName(rowParca) + }) + if (anyMatch) { + // If we previously created a placeholder under a wrong group name, move it instead of duplicating. + if (anyMatch?.requiredPlaceholder) { + const currentGroup = normalizeGroupName(anyMatch?.sAciklama3) + const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '') + const currentHammaddeAdi = String(anyMatch?.sHammaddeTuruAdi || '').trim() + if (currentGroup !== effectiveGroupName || currentParcaAdi !== desiredParcaAdi || (hammaddeAdi && currentHammaddeAdi !== hammaddeAdi)) { + const moved = recalculateDetailRow({ + ...anyMatch, + sAciklama3: effectiveGroupName, + sParcaAdi: desiredParcaAdi, + nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0, + sHammaddeTuruAdi: hammaddeAdi || String(anyMatch?.sHammaddeTuruAdi || '').trim(), + requiredPlaceholder: true, + draftChanged: true + }, { + preserveInputs: true, + priceType: 'REQ', + updateState: 'required', + markChanged: true + }) + applyEditorRowToGroups(moved) + } + } + continue + } newRowSequence.value += 1 const rowKey = `req-auto-row-${newRowSequence.value}` const placeholder = recalculateDetailRow({ __rowKey: rowKey, isNew: true, + nUrtMTBolumID: mtBolumID, nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '', nOnMLDetNo: '', - sParcaAdi: groupName, - sAciklama3: groupName, + // Group header key + sAciklama3: effectiveGroupName, + // Parca adi column value (CEKET/PANTOLON/YELEK...) + sParcaAdi: desiredParcaAdi, nHammaddeTuruNo: hNo, - sHammaddeTuruAdi: '', + // Make "Hammadde Turu" render like existing rows: "no - aciklama" + sHammaddeTuruAdi: hammaddeAdi, sKodu: '', sAciklama: '', sRenk: '', @@ -2765,8 +2921,8 @@ function ensureNoCostRequiredRowsFromMappings (mappings) { }) applyEditorRowToGroups(placeholder) - }) - }) + } + } } function computeMissingRequiredSlots () { @@ -3061,6 +3217,9 @@ watch( } .pcd-sticky-stack { + position: sticky; + top: 50px; + z-index: 1000; background: #fff; margin-bottom: 0; border-bottom: 1px solid #ddd; @@ -3253,6 +3412,9 @@ watch( } .pcd-sub-header { + position: sticky !important; + top: var(--pcd-subheader-top) !important; + z-index: 990 !important; display: flex !important; align-items: center; justify-content: space-between; @@ -3296,6 +3458,9 @@ watch( } .pcd-detail-table :deep(.q-table thead tr:first-child th) { + position: sticky !important; + top: calc(var(--pcd-subheader-top) + 42px) !important; + z-index: 980 !important; background: #f8f9fa !important; opacity: 1 !important; } diff --git a/ui/src/pages/ProductionProductCostingMTBolumMapping.vue b/ui/src/pages/ProductionProductCostingMTBolumMapping.vue index da93e70..37e7b89 100644 --- a/ui/src/pages/ProductionProductCostingMTBolumMapping.vue +++ b/ui/src/pages/ProductionProductCostingMTBolumMapping.vue @@ -1,6 +1,6 @@