Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-03-03 23:28:43 +03:00
parent da33f30dd0
commit 4dc0415546
8 changed files with 1558 additions and 0 deletions

View File

@@ -555,6 +555,24 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
wrapV3(http.HandlerFunc(routes.GetProductSecondColorsHandler)),
)
bindV3(r, pgDB,
"/api/product-stock-query", "GET",
"order", "view",
wrapV3(http.HandlerFunc(routes.GetProductStockQueryHandler)),
)
bindV3(r, pgDB,
"/api/product-images", "GET",
"order", "view",
wrapV3(routes.GetProductImagesHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/product-images/{id}/content", "GET",
"order", "view",
wrapV3(routes.GetProductImageContentHandler(pgDB)),
)
// ============================================================
// ROLE MANAGEMENT
// ============================================================

View File

@@ -0,0 +1,201 @@
package queries
// GetProductStockQuery:
// Ürün kodu bazlı, optimize stok + attribute sorgusu.
const GetProductStockQuery = `
DECLARE @ProductCode NVARCHAR(50) = @p1;
;WITH INV AS
(
SELECT
CompanyCode,
OfficeCode,
StoreTypeCode,
StoreCode,
WarehouseCode,
ItemTypeCode,
ItemCode,
ColorCode,
ItemDim1Code,
ItemDim2Code,
ItemDim3Code,
SUM(PickingQty1) AS PickingQty1,
SUM(ReserveQty1) AS ReserveQty1,
SUM(DispOrderQty1) AS DispOrderQty1,
SUM(InventoryQty1) AS InventoryQty1
FROM
(
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
Qty1 AS PickingQty1, 0 AS ReserveQty1, 0 AS DispOrderQty1, 0 AS InventoryQty1
FROM PickingStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND LEN(@ProductCode) = 13
UNION ALL
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
0, Qty1, 0, 0
FROM ReserveStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND LEN(@ProductCode) = 13
UNION ALL
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
0, 0, Qty1, 0
FROM DispOrderStates
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND LEN(@ProductCode) = 13
UNION ALL
SELECT
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code,
0, 0, 0, SUM(In_Qty1 - Out_Qty1)
FROM trStock WITH (NOLOCK)
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND LEN(@ProductCode) = 13
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
) X
GROUP BY
CompanyCode, OfficeCode, StoreTypeCode, StoreCode, WarehouseCode,
ItemTypeCode, ItemCode, ColorCode, ItemDim1Code, ItemDim2Code, ItemDim3Code
),
Attr AS
(
SELECT TOP 1
ProductCode,
ProductDescription,
ProductAtt01Desc,
ProductAtt02Desc,
ProductAtt10Desc,
ProductAtt11Desc,
ProductAtt21Desc,
ProductAtt22Desc,
ProductAtt23Desc,
ProductAtt24Desc,
ProductAtt25Desc,
ProductAtt26Desc,
ProductAtt27Desc,
ProductAtt28Desc,
ProductAtt29Desc,
ProductAtt30Desc,
ProductAtt31Desc,
ProductAtt32Desc,
ProductAtt33Desc,
ProductAtt34Desc,
ProductAtt35Desc,
ProductAtt36Desc,
ProductAtt37Desc,
ProductAtt38Desc,
ProductAtt39Desc,
ProductAtt40Desc,
ProductAtt41Desc,
ProductAtt42Desc,
ProductAtt43Desc,
ProductAtt44Desc,
ProductAtt45Desc,
ProductAtt46Desc
FROM ProductFilterWithDescription('TR')
WHERE ProductCode = @ProductCode
AND LEN(ProductCode) = 13
AND LEN(@ProductCode) = 13
),
Price AS
(
SELECT TOP 1
Price
FROM prItemBasePrice WITH (NOLOCK)
WHERE ItemTypeCode = 1
AND ItemCode = @ProductCode
AND LEN(ItemCode) = 13
AND LEN(@ProductCode) = 13
ORDER BY PriceDate DESC
)
SELECT
I.WarehouseCode AS Depo_Kodu,
W.WarehouseDescription AS Depo_Adi,
IT.ItemTypeDescription AS InventoryType,
I.ItemCode AS Urun_Kodu,
A.ProductDescription AS Madde_Aciklamasi,
I.ColorCode AS Renk_Kodu,
C.ColorDescription AS Renk_Aciklamasi,
I.ItemDim1Code AS Beden,
I.ItemDim2Code AS Yaka,
ROUND(I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1, U.RoundDigit) AS Kullanilabilir_Envanter,
A.ProductAtt01Desc AS URUN_ANA_GRUBU,
A.ProductAtt02Desc AS URUN_ALT_GRUBU,
A.ProductAtt10Desc AS MARKA,
A.ProductAtt11Desc AS DR,
A.ProductAtt21Desc AS KALIP,
A.ProductAtt22Desc AS IKINCI_PARCA_KALIP,
A.ProductAtt23Desc AS PACA_GENISLIGI,
A.ProductAtt24Desc AS UCUNCU_PARCA_KALIP,
A.ProductAtt25Desc AS UCUNCU_PARCA_MODEL,
A.ProductAtt26Desc AS BIRINCI_PARCA_KUMAS,
A.ProductAtt27Desc AS IKINCI_PARCA_KUMAS,
A.ProductAtt28Desc AS UCUNCU_PARCA_KUMAS,
A.ProductAtt29Desc AS BIRINCI_PARCA_KARISIM,
A.ProductAtt30Desc AS IKINCI_PARCA_KARISIM,
A.ProductAtt31Desc AS UCUNCU_PARCA_KARISIM,
A.ProductAtt32Desc AS YAKA_TIPI,
A.ProductAtt33Desc AS DUGME,
A.ProductAtt34Desc AS YIRTMAC,
A.ProductAtt35Desc AS SEZON_YILI,
A.ProductAtt36Desc AS MEVSIM,
A.ProductAtt37Desc AS TABAN,
A.ProductAtt38Desc AS BIRINCI_PARCA_FIT,
A.ProductAtt39Desc AS IKINCI_PARCA_FIT,
A.ProductAtt40Desc AS BOS2,
A.ProductAtt41Desc AS KISA_KAR,
A.ProductAtt42Desc AS SERI_FASON,
A.ProductAtt43Desc AS STOK_GIRIS_YONTEMI,
A.ProductAtt44Desc AS YETISKIN_GARSON,
A.ProductAtt45Desc AS ASKILI_YAN,
A.ProductAtt46Desc AS BOS3,
P.Price AS Fiyat
FROM INV I
JOIN cdItem CI WITH (NOLOCK)
ON CI.ItemTypeCode = I.ItemTypeCode
AND CI.ItemCode = I.ItemCode
LEFT JOIN cdUnitOfMeasure U WITH (NOLOCK)
ON U.UnitOfMeasureCode = CI.UnitOfMeasureCode1
LEFT JOIN cdWarehouseDesc W WITH (NOLOCK)
ON W.WarehouseCode = I.WarehouseCode
AND W.LangCode = 'TR'
LEFT JOIN bsItemTypeDesc IT WITH (NOLOCK)
ON IT.ItemTypeCode = I.ItemTypeCode
AND IT.LangCode = 'TR'
LEFT JOIN cdColorDesc C WITH (NOLOCK)
ON C.ColorCode = I.ColorCode
AND C.LangCode = 'TR'
CROSS JOIN Attr A
OUTER APPLY (SELECT TOP 1 Price FROM Price) P
WHERE
I.ItemTypeCode = 1
AND I.ItemCode = @ProductCode
AND LEN(I.ItemCode) = 13
AND LEN(@ProductCode) = 13
AND (I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1) > 0
AND CI.IsBlocked = 0
AND I.WarehouseCode IN
(
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
);
`

View File

@@ -0,0 +1,155 @@
package routes
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gorilla/mux"
)
type ProductImageItem struct {
ID int64 `json:"id"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
Storage string `json:"storage_path"`
ContentURL string `json:"content_url"`
}
// GET /api/product-images?code=...&color=...
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.URL.Query().Get("code"))
color := strings.TrimSpace(r.URL.Query().Get("color"))
if code == "" {
http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest)
return
}
query := `
SELECT
b.id,
b.file_name,
COALESCE(b.file_size, 0) AS file_size,
COALESCE(b.storage_path, '') AS storage_path
FROM dfblob b
JOIN mmitem i
ON i.id = b.src_id
WHERE b.typ = 'img'
AND b.src_table = 'mmitem'
AND i.code = $1
AND ($2 = '' OR b.file_name ILIKE '%' || '-' || $2 || '-%')
ORDER BY COALESCE(b.sort_order, 999999), b.zlins_dttm DESC, b.id DESC
`
rows, err := pg.Query(query, code, color)
if err != nil {
http.Error(w, "Görsel sorgu hatası: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
items := make([]ProductImageItem, 0, 16)
for rows.Next() {
var it ProductImageItem
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
continue
}
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
items = append(items, it)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(items)
}
}
// GET /api/product-images/{id}/content
func GetProductImageContentHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "Geçersiz görsel id", http.StatusBadRequest)
return
}
var (
fileName string
storagePath string
storedInDB bool
binData []byte
)
err = pg.QueryRow(`
SELECT
COALESCE(file_name, ''),
COALESCE(storage_path, ''),
COALESCE(stored_in_db, false),
bin
FROM dfblob
WHERE id = $1
AND typ = 'img'
`, id).Scan(&fileName, &storagePath, &storedInDB, &binData)
if err != nil {
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
http.Error(w, "Görsel okunamadı: "+err.Error(), http.StatusInternalServerError)
return
}
if storedInDB && len(binData) > 0 {
w.Header().Set("Content-Type", http.DetectContentType(binData))
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(binData)
return
}
resolved := resolveStoragePath(storagePath)
if resolved == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, resolved)
}
}
func resolveStoragePath(storagePath string) string {
raw := strings.TrimSpace(storagePath)
if raw == "" {
return ""
}
raw = strings.TrimPrefix(raw, "./")
candidates := []string{
filepath.Clean(storagePath),
filepath.Clean(raw),
filepath.Join(".", raw),
filepath.Join("..", raw),
}
if root := strings.TrimSpace(os.Getenv("BLOB_ROOT")); root != "" {
candidates = append(candidates, filepath.Join(root, raw))
}
for _, p := range candidates {
if p == "" {
continue
}
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}

View File

@@ -0,0 +1,85 @@
package routes
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
)
// GetProductStockQueryHandler
// GET /api/product-stock-query?code=...
func GetProductStockQueryHandler(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
http.Error(w, "Eksik parametre: code gerekli", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductStockQuery, code)
if err != nil {
log.Printf("❌ [PRODUCT-STOCK-QUERY] SQL hatası: %v", err)
http.Error(w, "SQL hatası: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
http.Error(w, "Kolon bilgisi alınamadı", http.StatusInternalServerError)
return
}
result := make([]map[string]interface{}, 0, 128)
for rows.Next() {
raw := make([]interface{}, len(columns))
dest := make([]interface{}, len(columns))
for i := range raw {
dest[i] = &raw[i]
}
if err := rows.Scan(dest...); err != nil {
log.Printf("⚠️ [PRODUCT-STOCK-QUERY] scan hatası: %v", err)
continue
}
rowMap := make(map[string]interface{}, len(columns))
for i, c := range columns {
rowMap[c] = normalizeSQLValue(raw[i])
}
result = append(result, rowMap)
}
if err := rows.Err(); err != nil {
http.Error(w, "Satır okuma hatası: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(result)
}
func normalizeSQLValue(v interface{}) interface{} {
switch val := v.(type) {
case nil:
return ""
case []byte:
s := strings.TrimSpace(string(val))
if s == "" {
return ""
}
if n, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64); err == nil {
return n
}
return s
default:
return val
}
}

View File

@@ -261,6 +261,19 @@ const menuItems = [
]
},
{
label: 'Ürün',
icon: 'inventory_2',
children: [
{
label: 'Ürün Kodundan Stok Sorgula',
to: '/app/product-stock-query',
permission: 'order:view'
}
]
},
{
label: 'Sistem',
icon: 'settings',

View File

@@ -0,0 +1,1078 @@
<template>
<q-page
v-if="canReadOrder"
class="order-page q-pa-md"
:style="{ '--grid-header-h': gridHeaderHeight }"
>
<div class="sticky-stack">
<div class="filter-bar row q-col-gutter-md q-mb-sm">
<div class="col-12 col-md-6">
<q-select
v-model="selectedProductCode"
:options="filteredProductOptions"
label="Ürün Kodu"
filled
dense
clearable
use-input
input-debounce="250"
emit-value
map-options
option-value="value"
option-label="label"
:loading="loadingProducts"
@filter="filterProducts"
@keyup.enter="fetchStockByCode"
/>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="search"
label="Sorgula"
:loading="loadingStock"
:disable="!selectedProductCode"
@click="fetchStockByCode"
/>
</div>
<div class="col-auto">
<q-btn
flat
color="grey-8"
icon="restart_alt"
label="Sıfırla"
@click="resetForm"
/>
</div>
<div class="col-auto">
<q-btn
flat
color="primary"
icon="unfold_more"
:label="allDetailsExpanded ? 'Tüm Depoları Kapat' : 'Tüm Depoları Göster'"
:disable="!level1Groups.length"
@click="toggleAllDetails"
/>
</div>
</div>
<div class="save-toolbar">
<div class="text-subtitle2 text-weight-bold">Ürün Kodundan Stok Sorgula</div>
</div>
<div
v-if="showGridHeader"
class="order-grid-header"
>
<div class="col-fixed model">MODEL</div>
<div class="col-fixed renk">RENK</div>
<div class="col-fixed ana">ÜRÜN ANA GRUBU</div>
<div class="col-fixed alt">ÜRÜN ALT GRUBU</div>
<div class="col-fixed aciklama-col">AÇIKLAMA</div>
<div class="beden-block">
<div class="grp-row">
<div class="grp-title">{{ activeSchema?.title || 'BEDEN' }}</div>
<div class="grp-body">
<div
v-for="v in sizeLabels"
:key="'hdr-' + activeGrpKey + '-' + v"
class="grp-cell hdr"
>
{{ v }}
</div>
</div>
</div>
</div>
<div class="total-row">
<div class="total-cell">ADET</div>
</div>
</div>
</div>
<q-banner
v-if="errorMessage"
class="bg-red-1 text-negative q-my-sm rounded-borders"
dense
>
{{ errorMessage }}
</q-banner>
<q-banner
v-else-if="!loadingStock && !level1Groups.length"
class="bg-blue-1 text-primary q-my-sm rounded-borders"
dense
>
Ürün kodu seçip sorgulayın.
</q-banner>
<div class="order-scroll-y">
<div v-if="level1Groups.length" class="order-grid-body">
<template v-for="grp1 in level1Groups" :key="grp1.key">
<div class="summary-group open">
<div class="order-sub-header level-1" @click="toggleOpen(grp1.key)">
<div class="sub-col level1-merged">
<div class="text-weight-bold">{{ grp1.productCode }}</div>
<div class="text-caption">{{ grp1.productDesc || '-' }}</div>
</div>
<div class="sub-center level1-center">
<div class="beden-row values-top">
<div v-for="sz in sizeLabels" :key="`v1-${grp1.key}-${sz}`" class="beden-cell">
{{ Number(grp1.sizeTotals[sz] || 0) > 0 ? formatNumber(grp1.sizeTotals[sz]) : '' }}
</div>
</div>
<div class="beden-row headers">
<div v-for="sz in sizeLabels" :key="`h1-${grp1.key}-${sz}`" class="beden-cell">
{{ sz }}
</div>
</div>
</div>
<div class="sub-right level1-right">
<div class="top-total">{{ formatNumber(grp1.totalQty) }}</div>
<div class="bottom-row">
<div class="bottom-label">ADET</div>
</div>
<div class="icon-row">
<q-icon :name="isOpen(grp1.key) ? 'expand_less' : 'expand_more'" size="18px" />
</div>
</div>
</div>
<template v-if="isOpen(grp1.key)">
<template v-for="grp2 in grp1.children" :key="grp2.key">
<div class="order-sub-header level-2" @click="onLevel2Click(grp1.productCode, grp2)">
<div class="sub-col model">{{ grp1.productCode || '-' }}</div>
<div class="sub-col renk">
<div class="renk-kodu">{{ grp2.colorCode || '-' }}{{ grp2.secondColor ? '-' + grp2.secondColor : '' }}</div>
<div class="renk-aciklama">{{ grp2.colorDesc || '-' }}</div>
</div>
<div class="sub-col ana">{{ grp2.urunAnaGrubu || '-' }}</div>
<div class="sub-col alt">{{ grp2.urunAltGrubu || '-' }}</div>
<div class="sub-col aciklama">{{ grp2.aciklama || '-' }}</div>
<div class="sub-center level2-center">
<div class="beden-row values-top">
<div v-for="sz in sizeLabels" :key="`top2-${grp2.key}-${sz}`" class="beden-cell">
{{ Number(grp2.sizeTotals[sz] || 0) > 0 ? formatNumber(grp2.sizeTotals[sz]) : '' }}
</div>
</div>
<div class="beden-row headers">
<div v-for="sz in sizeLabels" :key="`h2-${grp2.key}-${sz}`" class="beden-cell">
{{ sz }}
</div>
</div>
</div>
<div class="sub-right level2-right">
<div class="top-total">{{ formatNumber(grp2.totalQty) }}</div>
<div class="bottom-row">
<div class="bottom-label">ADET</div>
<q-icon :name="isOpen(grp2.key) ? 'expand_less' : 'expand_more'" size="18px" />
</div>
</div>
</div>
<template v-if="isOpen(grp2.key)">
<div class="detail-table-wrap">
<div
v-for="row in buildLevel2Rows(grp2)"
:key="row.rowKey"
class="summary-row"
>
<div class="cell depo-merged">{{ row.depoAdi || '-' }}</div>
<div class="grp-area">
<div class="grp-row">
<div
v-for="v in sizeLabels"
:key="row.rowKey + '-sz-' + v"
class="cell beden"
>
{{ resolveBedenValue(row.bedenMap, row.grpKey, v) }}
</div>
</div>
</div>
<div class="cell adet">{{ formatNumber(row.adet) }}</div>
</div>
</div>
</template>
</template>
</template>
</div>
</template>
</div>
</div>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">Bu modüle erişim yetkiniz yok.</div>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import api from 'src/services/api'
import { usePermission } from 'src/composables/usePermission'
import {
detectBedenGroup,
normalizeBedenLabel,
schemaByKey as storeSchemaByKey,
useOrderEntryStore
} from 'src/stores/orderentryStore'
const $q = useQuasar()
const { canRead } = usePermission()
const canReadOrder = canRead('order')
const orderStore = useOrderEntryStore()
const loadingProducts = ref(false)
const loadingStock = ref(false)
const errorMessage = ref('')
const selectedProductCode = ref('')
const productOptions = ref([])
const filteredProductOptions = ref([])
const rawRows = ref([])
const activeSchema = ref(storeSchemaByKey.tak)
const activeGrpKey = ref('tak')
const openState = ref({})
const sizeLabels = computed(() => activeSchema.value?.values || [])
const showGridHeader = computed(() =>
!loadingStock.value && level1Groups.value.length > 0
)
const allDetailsExpanded = computed(() => {
const groups = level1Groups.value || []
if (!groups.length) return false
const detailKeys = []
for (const g1 of groups) {
for (const g2 of g1.children || []) {
detailKeys.push(g2.key)
for (const g3 of g2.children || []) detailKeys.push(g3.key)
}
}
if (!detailKeys.length) return false
return detailKeys.every((k) => openState.value[k] === true)
})
const gridHeaderHeight = computed(() =>
showGridHeader.value ? '56px' : '0px'
)
function emptySizeTotals() {
const map = {}
for (const s of sizeLabels.value) map[s] = 0
return map
}
function parseNumber(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
const text = String(value ?? '').trim()
if (!text) return 0
const normalized = text.replace(/\./g, '').replace(',', '.')
const n = Number.parseFloat(normalized)
return Number.isFinite(n) ? n : 0
}
function formatNumber(v) {
return parseNumber(v).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
function normalizeSize(v) {
return normalizeBedenLabel(v)
}
function resolveBedenValue(bedenMap, grpKey, bedenLabel) {
const map = bedenMap?.[grpKey]
if (!map || typeof map !== 'object') return 0
return Number(map[bedenLabel] || 0)
}
function isOpen(key) {
return openState.value[key] !== false
}
function toggleOpen(key) {
openState.value[key] = !isOpen(key)
}
function initOpenState(groups, expandDetails = false) {
const next = {}
for (const g1 of groups) {
next[g1.key] = true
for (const g2 of g1.children) {
next[g2.key] = expandDetails
for (const g3 of g2.children) {
next[g3.key] = expandDetails
}
}
}
openState.value = next
}
function expandAllDetails() {
initOpenState(level1Groups.value || [], true)
}
function collapseAllDetails() {
initOpenState(level1Groups.value || [], false)
}
function toggleAllDetails() {
if (allDetailsExpanded.value) {
collapseAllDetails()
return
}
expandAllDetails()
}
function buildLevel3Rows(grp3) {
const byKey = new Map()
const gk = activeGrpKey.value || 'tak'
for (const item of grp3.items || []) {
const model = String(item.Urun_Kodu || '').trim()
const renk = String(item.Renk_Kodu || '').trim()
const renk2 = String(item.Yaka || '').trim()
const urunAnaGrubu = String(item.URUN_ANA_GRUBU || '').trim()
const urunAltGrubu = String(item.URUN_ALT_GRUBU || '').trim()
const aciklama = String(item.Madde_Aciklamasi || '').trim()
const beden = normalizeSize(item.Beden || '')
const qty = parseNumber(item.Kullanilabilir_Envanter)
const rowKey = [model, renk, renk2, grp3.depoKodu || '', grp3.depoAdi || ''].join('|')
if (!byKey.has(rowKey)) {
byKey.set(rowKey, {
rowKey,
model,
renk,
renk2,
urunAnaGrubu,
urunAltGrubu,
aciklama,
grpKey: gk,
bedenMap: { [gk]: {} },
adet: 0,
depoAdi: grp3.depoAdi || '-'
})
}
const row = byKey.get(rowKey)
row.bedenMap[gk][beden] = Number(row.bedenMap[gk][beden] || 0) + qty
row.adet += qty
}
return Array.from(byKey.values())
}
function buildLevel2Rows(grp2) {
const merged = []
for (const grp3 of grp2.children || []) {
merged.push(...buildLevel3Rows(grp3))
}
return merged
}
const level1Groups = computed(() => {
const l1Map = new Map()
for (const item of rawRows.value) {
const productCode = String(item.Urun_Kodu || '').trim()
const productDesc = String(item.Madde_Aciklamasi || '').trim()
const colorCode = String(item.Renk_Kodu || '').trim()
const colorDesc = String(item.Renk_Aciklamasi || '').trim()
const secondColor = String(item.Yaka || '').trim()
const depoKodu = String(item.Depo_Kodu || '').trim()
const depoAdi = String(item.Depo_Adi || '').trim()
const urunAnaGrubu = String(item.URUN_ANA_GRUBU || '').trim()
const urunAltGrubu = String(item.URUN_ALT_GRUBU || '').trim()
const aciklama = String(item.Madde_Aciklamasi || '').trim()
const beden = normalizeSize(item.Beden || '')
const qty = parseNumber(item.Kullanilabilir_Envanter)
if (!l1Map.has(productCode)) {
l1Map.set(productCode, {
key: `L1|${productCode}`,
productCode,
productDesc,
sizeTotals: emptySizeTotals(),
totalQty: 0,
childrenMap: new Map()
})
}
const l1 = l1Map.get(productCode)
if (Object.prototype.hasOwnProperty.call(l1.sizeTotals, beden)) {
l1.sizeTotals[beden] += qty
}
l1.totalQty += qty
const l2Key = `${colorCode}|${secondColor}`
if (!l1.childrenMap.has(l2Key)) {
l1.childrenMap.set(l2Key, {
key: `L2|${productCode}|${l2Key}`,
colorCode,
colorDesc,
secondColor,
urunAnaGrubu,
urunAltGrubu,
aciklama,
sizeTotals: emptySizeTotals(),
totalQty: 0,
childrenMap: new Map()
})
}
const l2 = l1.childrenMap.get(l2Key)
if (Object.prototype.hasOwnProperty.call(l2.sizeTotals, beden)) {
l2.sizeTotals[beden] += qty
}
l2.totalQty += qty
const l3Key = `${depoKodu}|${depoAdi}`
if (!l2.childrenMap.has(l3Key)) {
l2.childrenMap.set(l3Key, {
key: `L3|${productCode}|${l2Key}|${l3Key}`,
depoKodu,
depoAdi,
sizeTotals: emptySizeTotals(),
totalQty: 0,
items: []
})
}
const l3 = l2.childrenMap.get(l3Key)
if (Object.prototype.hasOwnProperty.call(l3.sizeTotals, beden)) {
l3.sizeTotals[beden] += qty
}
l3.totalQty += qty
l3.items.push({
...item,
_rowKey: `${productCode}|${colorCode}|${secondColor}|${depoKodu}|${beden}`
})
}
return Array.from(l1Map.values()).map((l1) => ({
...l1,
children: Array.from(l1.childrenMap.values()).map((l2) => ({
...l2,
children: Array.from(l2.childrenMap.values())
}))
}))
})
function filterProducts(val, update) {
if (!val) {
update(() => {
filteredProductOptions.value = [...productOptions.value]
})
return
}
const needle = String(val || '').toLocaleLowerCase('tr-TR')
update(() => {
filteredProductOptions.value = productOptions.value.filter(opt =>
String(opt.label || '').toLocaleLowerCase('tr-TR').includes(needle)
)
})
}
async function loadProductOptions() {
loadingProducts.value = true
try {
const res = await api.get('/products')
const arr = Array.isArray(res?.data) ? res.data : []
productOptions.value = arr
.map((x) => String(x?.ProductCode || '').trim())
.filter((code) => code.length === 13)
.sort((a, b) => a.localeCompare(b, 'tr'))
.map((code) => ({ label: code, value: code }))
filteredProductOptions.value = [...productOptions.value]
} catch (err) {
errorMessage.value = 'Ürün kodları alınamadı.'
console.error('loadProductOptions error:', err)
} finally {
loadingProducts.value = false
}
}
async function fetchStockByCode() {
const code = String(selectedProductCode.value || '').trim()
if (!code) return
loadingStock.value = true
errorMessage.value = ''
try {
if (!orderStore.schemaMap || !Object.keys(orderStore.schemaMap).length) {
orderStore.initSchemaMap()
}
const res = await api.get('/product-stock-query', { params: { code } })
const list = Array.isArray(res?.data) ? res.data : []
if (!list.length) {
rawRows.value = []
openState.value = {}
return
}
const first = list[0] || {}
const grpKey = detectBedenGroup(
list.map((x) => x?.Beden || ''),
first?.URUN_ANA_GRUBU || '',
first?.YETISKIN_GARSON || ''
)
const schemaMap = Object.keys(orderStore.schemaMap || {}).length
? orderStore.schemaMap
: storeSchemaByKey
activeGrpKey.value = grpKey || 'tak'
activeSchema.value = schemaMap?.[grpKey] || storeSchemaByKey.tak
rawRows.value = list
initOpenState(level1Groups.value)
} catch (err) {
console.error('fetchStockByCode error:', err)
rawRows.value = []
openState.value = {}
errorMessage.value = 'Stok sorgulama sırasında hata oluştu.'
$q.notify({
type: 'negative',
position: 'top-right',
message: 'Stok sorgusu başarısız.'
})
} finally {
loadingStock.value = false
}
}
function onLevel2Click(_productCode, grp2) {
toggleOpen(grp2.key)
}
function resetForm() {
selectedProductCode.value = ''
rawRows.value = []
errorMessage.value = ''
openState.value = {}
activeSchema.value = storeSchemaByKey.tak
}
onMounted(() => {
loadProductOptions()
})
</script>
<style scoped>
.order-page {
--psq-sticky-offset: 12px;
--grp-title-w: 44px;
--psq-header-h: 56px;
--psq-col-adet: calc(var(--col-adet) + var(--beden-w));
--psq-l1-lift: 42px;
}
.order-grid-header {
top: calc(var(--header-h) + var(--filter-h) + var(--save-h) + var(--psq-sticky-offset)) !important;
grid-template-columns:
var(--col-model)
var(--col-renk)
var(--col-ana)
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
var(--psq-col-adet) !important;
}
.order-grid-header .col-fixed,
.order-grid-header .total-cell {
writing-mode: horizontal-tb !important;
transform: none !important;
height: var(--psq-header-h) !important;
font-size: 10px !important;
line-height: 1 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px !important;
}
.order-grid-header .beden-block {
height: var(--psq-header-h) !important;
}
.order-grid-header .grp-row {
height: var(--psq-header-h) !important;
align-items: center;
}
.order-grid-header .grp-title {
width: var(--grp-title-w) !important;
text-align: center !important;
padding-right: 0 !important;
font-size: 10px !important;
}
.order-grid-header .grp-cell.hdr {
height: var(--psq-header-h) !important;
font-size: 10px !important;
}
.order-grid-header .total-row {
grid-column: 7 / -1 !important;
}
.order-grid-header .total-cell {
width: var(--psq-col-adet) !important;
}
.order-grid-header .total-cell:last-child {
width: var(--psq-col-adet) !important;
}
.order-sub-header {
grid-template-columns:
var(--col-model)
var(--col-renk)
var(--col-ana)
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
var(--psq-col-adet) !important;
top: calc(
var(--header-h)
+ var(--filter-h)
+ var(--save-h)
+ var(--grid-header-h)
+ var(--psq-sticky-offset)
) !important;
}
.order-sub-header.level-2 {
min-height: 82px !important;
height: 82px !important;
background: #fff9c4 !important;
border-top: 1px solid #d4c79f !important;
border-bottom: 1px solid #d4c79f !important;
}
.order-sub-header.level-1 {
min-height: 84px !important;
height: 84px !important;
top: calc(
var(--header-h)
+ var(--filter-h)
+ var(--save-h)
+ var(--grid-header-h)
+ var(--psq-sticky-offset)
- var(--psq-l1-lift)
) !important;
background: var(--q-primary, #1976d2) !important;
border-top: 1px solid #145ea8 !important;
border-bottom: 1px solid #145ea8 !important;
color: #fff !important;
}
.order-sub-header.level-1 .sub-col.level1-merged {
grid-column: 1 / 6;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 0 10px;
border-right: 1px solid rgba(255, 255, 255, 0.45);
min-width: 0;
}
.order-sub-header.level-1 .sub-col.level1-merged .text-caption {
color: #fff !important;
opacity: 0.95;
}
.order-sub-header.level-1 .sub-center.level1-center {
grid-column: 6;
display: grid;
grid-template-rows: 42px 42px;
align-items: stretch;
height: 100%;
overflow: hidden;
padding-left: calc(var(--grp-title-w) + var(--grp-title-gap));
margin-left: 0;
}
.order-sub-header.level-1 .beden-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--beden-w);
height: 42px;
min-height: 42px;
}
.order-sub-header.level-1 .beden-row .beden-cell {
display: flex;
align-items: center;
justify-content: center;
height: 42px;
min-height: 42px;
background: var(--q-primary, #1976d2) !important;
color: #fff !important;
border-right: 1px solid rgba(255, 255, 255, 0.45);
border-bottom: 1px solid rgba(255, 255, 255, 0.45);
border-top: none;
border-left: none;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
.order-sub-header.level-1 .beden-row.values-top .beden-cell {
background: #fff9c4 !important;
color: var(--q-primary, #1976d2) !important;
font-weight: 700;
}
.order-sub-header.level-1 .beden-row.headers .beden-cell {
font-weight: 500;
color: #fff !important;
}
.order-sub-header.level-1 .sub-right.level1-right {
grid-column: 7 / 8;
display: grid;
grid-template-columns: var(--psq-col-adet);
grid-template-rows: 30px 30px 24px;
align-items: stretch;
justify-items: stretch;
height: 100%;
overflow: hidden;
padding: 0 8px 0 6px;
border-left: 1px solid rgba(255, 255, 255, 0.45);
color: #fff;
}
.order-sub-header.level-1 .sub-right .top-total {
grid-column: 1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
background: #fff9c4 !important;
color: var(--q-primary, #1976d2) !important;
padding: 0 6px;
border-radius: 4px;
}
.order-sub-header.level-1 .sub-right .bottom-label {
font-size: 12px;
font-weight: 700;
}
.order-sub-header.level-1 .sub-right .bottom-row {
grid-column: 1;
grid-row: 2;
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
white-space: nowrap;
overflow: hidden;
}
.order-sub-header.level-1 .sub-right .icon-row {
grid-column: 1;
grid-row: 3;
display: flex;
align-items: center;
justify-content: flex-end;
}
.order-sub-header.level-2 .sub-col {
display: flex;
align-items: center;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
color: #111;
min-width: 0;
border-right: 1px solid #d4c79f;
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.2;
word-break: break-word;
overflow-wrap: anywhere;
}
.order-sub-header.level-2 .sub-col.model { grid-column: 1; }
.order-sub-header.level-2 .sub-col.renk { grid-column: 2; }
.order-sub-header.level-2 .sub-col.ana { grid-column: 3; }
.order-sub-header.level-2 .sub-col.alt { grid-column: 4; }
.order-sub-header.level-2 .sub-col.aciklama { grid-column: 5; }
.order-sub-header.level-2 .sub-col.model,
.order-sub-header.level-2 .sub-col.renk,
.order-sub-header.level-2 .sub-col.ana,
.order-sub-header.level-2 .sub-col.alt {
justify-content: center;
text-align: center;
}
.order-sub-header.level-2 .sub-col.renk {
flex-direction: column;
gap: 2px;
line-height: 1.1;
}
.order-sub-header.level-2 .sub-col.renk .renk-kodu {
font-weight: 700;
}
.order-sub-header.level-2 .sub-col.renk .renk-aciklama {
font-size: 11px;
opacity: 0.9;
}
.order-sub-header.level-2 .sub-col.aciklama {
justify-content: flex-start;
text-align: left;
}
.order-sub-header.level-2 .sub-center.level2-center {
grid-column: 6;
display: grid;
grid-template-rows: 1fr 1fr;
align-items: stretch;
height: 100%;
padding-left: var(--grp-title-w);
margin-left: var(--grp-title-gap);
}
.order-sub-header.level-2 .beden-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--beden-w);
}
.order-sub-header.level-2 .beden-row.values-top .beden-cell {
border-right: 1px solid #d4c79f;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #1f1f1f;
line-height: 1;
}
.order-sub-header.level-2 .beden-row.headers .beden-cell {
border-top: 1px solid #d4c79f;
border-right: 1px solid #d4c79f;
border-bottom: none;
background: var(--q-primary, #1976d2);
font-size: 12px;
font-weight: 700;
color: #fff;
line-height: 1;
}
.order-sub-header.level-2 .sub-right.level2-right {
grid-column: 7 / 8;
display: grid;
grid-template-columns: var(--psq-col-adet);
grid-template-rows: 1fr 1fr;
align-items: center;
justify-items: start;
padding-left: 8px;
padding-right: 0;
transform: none !important;
border-left: 1px solid #d4c79f;
}
.order-sub-header.level-2 .sub-right .top-total {
grid-column: 1 / 2;
grid-row: 1;
font-size: 14px;
font-weight: 700;
line-height: 1;
justify-self: start;
text-align: left;
}
.order-sub-header.level-2 .sub-right .bottom-label {
font-size: 12px;
font-weight: 700;
}
.order-sub-header.level-2 .sub-right .bottom-row {
grid-column: 1;
grid-row: 2;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.order-grid-body .summary-row {
display: grid !important;
grid-template-columns:
var(--col-model)
var(--col-renk)
var(--col-ana)
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
var(--psq-col-adet) !important;
min-height: 56px;
height: 56px;
background: #fff;
border-top: 1px solid #d4c79f;
border-bottom: 1px solid #d4c79f;
align-items: stretch;
}
.order-grid-body .summary-row .cell {
min-height: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #d4c79f;
font-size: 12px;
overflow: hidden !important;
}
.order-grid-body .summary-row .cell:last-child {
border-right: none;
}
.order-grid-body .summary-row .grp-area {
display: grid !important;
grid-template-columns: var(--grp-title-w) var(--grp-title-gap) 1fr;
width: 100% !important;
height: 56px;
padding-left: 0 !important;
transform: none !important;
border-right: 1px solid #d4c79f;
}
.order-grid-body .summary-row .grp-row {
grid-column: 3;
width: 100%;
height: 56px;
margin-left: 0 !important;
justify-content: start !important;
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--beden-w);
}
.order-grid-body .summary-row .grp-row .cell.beden {
width: var(--beden-w);
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
margin: 0 !important;
border-right: 1px solid #d4c79f;
border-left: none !important;
border-top: none !important;
border-bottom: none !important;
min-height: 56px;
height: 56px;
box-sizing: border-box;
}
.order-grid-body .summary-row .grp-row .cell.beden:first-child {
border-left: 1px solid #d4c79f !important;
}
.order-grid-body .summary-row .cell.model,
.order-grid-body .summary-row .cell.renk,
.order-grid-body .summary-row .cell.ana,
.order-grid-body .summary-row .cell.alt {
justify-content: center;
text-align: center;
}
.order-grid-body .summary-row .cell.aciklama {
grid-column: 5 / 6 !important;
position: static !important;
width: var(--col-aciklama) !important;
margin-right: 0 !important;
min-height: 56px !important;
z-index: auto !important;
background: #fff !important;
box-sizing: border-box !important;
border-right: 1px solid #d4c79f !important;
justify-content: flex-start !important;
text-align: left !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
line-height: 1 !important;
padding-left: 6px !important;
padding-right: 6px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
}
.order-grid-body .summary-row .cell.depo-merged {
grid-column: 1 / 6 !important;
justify-content: center !important;
text-align: center !important;
font-weight: 600;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.order-grid-body .summary-row .cell.adet,
.order-grid-body .summary-row .cell.termin {
justify-content: flex-end;
text-align: right;
padding-right: 10px !important;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.order-grid-body {
margin-top: 0 !important;
padding-top: 0 !important;
}
.order-grid-body > .summary-group,
.order-grid-body > .summary-group:first-child {
margin-top: 0 !important;
padding-top: 0 !important;
}
.detail-table-wrap {
padding: 8px 0 12px 0;
background: #fff;
}
.detail-table :deep(th),
.detail-table :deep(td) {
white-space: nowrap;
}
</style>

View File

@@ -290,6 +290,14 @@ const routes = [
meta: { permission: 'order:export' }
},
/* ================= PRODUCTS ================= */
{
path: 'product-stock-query',
name: 'product-stock-query',
component: () => import('pages/ProductStockQuery.vue'),
meta: { permission: 'order:view' }
},
/* ================= PASSWORD ================= */