ui: add B2B olmayan stok (orphans) page

This commit is contained in:
M_Kececi
2026-06-25 14:14:09 +03:00
parent 52b39725ec
commit dfad548963
19 changed files with 594 additions and 71 deletions

View File

@@ -395,6 +395,10 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"language", "update",
wrapV3(routes.GetTranslationRowsHandler(pgDB)),
)
r.Handle(
"/api/language/translations/runtime",
wrapAuthOnly(http.HandlerFunc(routes.GetRuntimeTranslationsHandler(pgDB))),
).Methods("GET", "OPTIONS")
bindV3(r, pgDB,
"/api/language/translations/{id}", "PUT",
"language", "update",

View File

@@ -695,13 +695,13 @@ LIMIT 1
row.MappingReady = false
switch {
case row.MmitemID <= 0:
row.MappingWarning = "B2B'de urun yok (mmitem)"
row.MappingWarning = "B2B'de urun yok"
case row.Dim1ID <= 0:
row.MappingWarning = "B2B'de renk bu urunde yok (mmitem_dim/dfgrp.code)"
row.MappingWarning = "B2B'de bu urun icin renk yok"
case row.Dim3Code != "" && row.Dim3ID <= 0:
row.MappingWarning = "B2B'de dim3 token eslesmesi yok (mk_dim_token_map: dimval3)"
row.MappingWarning = "B2B'de ikinci renk eslesmesi yok"
case !comboOK:
row.MappingWarning = "B2B'de varyant kombosu yok (mmitem_dim)"
row.MappingWarning = "B2B'de bu urun/renk/ikinci renk kombinasyonu yok"
default:
// Not an orphan; skip.
if baseReady && comboOK {
@@ -861,7 +861,7 @@ func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc {
}
mmitemID, err := resolveMmitemIDTx(ctx, tx, code)
if err != nil || mmitemID <= 0 {
http.Error(w, "PG urun bulunamadi: "+code, http.StatusBadRequest)
http.Error(w, "B2B urun bulunamadi: "+code, http.StatusBadRequest)
return
}
// Authoritative dim1 resolver: only allow saving against a color that exists for this product in mmitem_dim.

View File

@@ -134,6 +134,12 @@ type TranslateSelectedPayload struct {
Limit int `json:"limit"`
}
type RuntimeTranslationsResponse struct {
Lang string `json:"lang"`
ByKey map[string]string `json:"by_key"`
ByText map[string]string `json:"by_text"`
}
type BulkUpdateItem struct {
ID int64 `json:"id"`
SourceTextTR *string `json:"source_text_tr"`
@@ -296,6 +302,82 @@ ORDER BY t_key, lang_code
}
}
func GetRuntimeTranslationsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
lang := normalizeRuntimeTranslationLang(firstNonEmpty(
r.URL.Query().Get("lang"),
r.Header.Get("Accept-Language"),
))
if lang == "" {
lang = "tr"
}
resp := RuntimeTranslationsResponse{
Lang: lang,
ByKey: map[string]string{},
ByText: map[string]string{},
}
rows, err := db.Query(`
WITH base AS (
SELECT DISTINCT ON (t_key)
t_key,
COALESCE(NULLIF(source_text_tr, ''), translated_text, '') AS source_text_tr
FROM mk_translator
WHERE COALESCE(NULLIF(source_text_tr, ''), translated_text, '') <> ''
ORDER BY t_key, CASE WHEN lang_code='tr' THEN 0 ELSE 1 END, updated_at DESC
),
target AS (
SELECT DISTINCT ON (t_key)
t_key,
COALESCE(translated_text, '') AS translated_text
FROM mk_translator
WHERE lang_code=$1
ORDER BY t_key, is_manual DESC, updated_at DESC
)
SELECT
base.t_key,
base.source_text_tr,
CASE
WHEN $1 = 'tr' THEN base.source_text_tr
ELSE COALESCE(NULLIF(target.translated_text, ''), base.source_text_tr)
END AS display_text
FROM base
LEFT JOIN target ON target.t_key = base.t_key
`, lang)
if err != nil {
http.Error(w, "runtime translations query error", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var key, sourceText, displayText string
if err := rows.Scan(&key, &sourceText, &displayText); err != nil {
http.Error(w, "runtime translations scan error", http.StatusInternalServerError)
return
}
key = strings.TrimSpace(key)
sourceText = normalizeRuntimeTranslationText(sourceText)
displayText = normalizeRuntimeTranslationText(displayText)
if key != "" && displayText != "" {
resp.ByKey[key] = displayText
}
if sourceText != "" && displayText != "" {
resp.ByText[sourceText] = displayText
}
}
if err := rows.Err(); err != nil {
http.Error(w, "runtime translations rows error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(resp)
}
}
func UpdateTranslationRowHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -1667,6 +1749,30 @@ func normalizeTranslationLang(v string) string {
return ""
}
func normalizeRuntimeTranslationLang(v string) string {
raw := strings.ToLower(strings.TrimSpace(v))
if raw == "" {
return ""
}
if strings.Contains(raw, ",") {
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
}
if strings.Contains(raw, ";") {
raw = strings.TrimSpace(strings.Split(raw, ";")[0])
}
if strings.Contains(raw, "-") {
raw = strings.TrimSpace(strings.Split(raw, "-")[0])
}
if strings.Contains(raw, "_") {
raw = strings.TrimSpace(strings.Split(raw, "_")[0])
}
return normalizeTranslationLang(raw)
}
func normalizeRuntimeTranslationText(v string) string {
return strings.Join(strings.Fields(strings.TrimSpace(v)), " ")
}
func normalizeTranslationStatus(v string) string {
status := strings.ToLower(strings.TrimSpace(v))
if _, ok := translationStatusSet[status]; ok {

View File

@@ -1,7 +1,10 @@
import { boot } from 'quasar/wrappers'
import { useLocaleStore } from 'src/stores/localeStore'
import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore'
export default boot(() => {
export default boot(async () => {
const localeStore = useLocaleStore()
localeStore.setLocale(localeStore.locale)
const runtimeTranslations = useRuntimeTranslationStore()
await runtimeTranslations.loadLocale(localeStore.locale)
})

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { messages } from 'src/i18n/messages'
import { DEFAULT_LOCALE } from 'src/i18n/languages'
import { useLocaleStore } from 'src/stores/localeStore'
import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore'
function lookup(obj, path) {
return String(path || '')
@@ -13,6 +14,7 @@ function lookup(obj, path) {
export function useI18n() {
const localeStore = useLocaleStore()
const runtimeTranslations = useRuntimeTranslationStore()
const currentLocale = computed(() => localeStore.locale)
@@ -24,6 +26,11 @@ export function useI18n() {
}
function t(key) {
const runtimeValue = runtimeTranslations.translateKey(key)
if (runtimeValue && runtimeValue !== key) {
return runtimeValue
}
for (const locale of fallbackLocales(currentLocale.value)) {
const val = lookup(messages[locale] || {}, key)
if (val != null) return val

View File

@@ -0,0 +1,157 @@
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { DEFAULT_LOCALE } from 'src/i18n/languages'
import { useLocaleStore } from 'src/stores/localeStore'
import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore'
const textOriginals = new WeakMap()
const attrOriginals = new WeakMap()
const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label']
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA'])
function normalizeRuntimeText (value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
function preserveOuterWhitespace (original, translated) {
const text = String(original || '')
const leading = text.match(/^\s*/)?.[0] || ''
const trailing = text.match(/\s*$/)?.[0] || ''
return `${leading}${translated}${trailing}`
}
function shouldSkipNode (node) {
const parent = node?.parentElement
if (!parent) return true
if (SKIP_TAGS.has(parent.tagName)) return true
if (parent.closest?.('[data-no-runtime-i18n="true"]')) return true
return false
}
export function useRuntimeDomTranslations () {
const route = useRoute()
const localeStore = useLocaleStore()
const runtimeTranslations = useRuntimeTranslationStore()
let observer = null
let applyTimer = null
function translateTextNode (node) {
if (shouldSkipNode(node)) return
const current = String(node.nodeValue || '')
if (!normalizeRuntimeText(current)) return
if (!textOriginals.has(node)) {
textOriginals.set(node, current)
}
const original = textOriginals.get(node)
const key = normalizeRuntimeText(original)
const translated = localeStore.locale === DEFAULT_LOCALE
? key
: runtimeTranslations.byText[key]
const nextValue = preserveOuterWhitespace(original, translated || key)
if (node.nodeValue !== nextValue) {
node.nodeValue = nextValue
}
}
function translateElementAttrs (el) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return
if (el.closest?.('[data-no-runtime-i18n="true"]')) return
let originals = attrOriginals.get(el)
if (!originals) {
originals = {}
attrOriginals.set(el, originals)
}
for (const attr of TRANSLATABLE_ATTRS) {
if (!el.hasAttribute(attr)) continue
const current = el.getAttribute(attr)
if (!normalizeRuntimeText(current)) continue
if (!Object.prototype.hasOwnProperty.call(originals, attr)) {
originals[attr] = current
}
const key = normalizeRuntimeText(originals[attr])
const translated = localeStore.locale === DEFAULT_LOCALE
? key
: runtimeTranslations.byText[key]
const nextValue = translated || key
if (el.getAttribute(attr) !== nextValue) {
el.setAttribute(attr, nextValue)
}
}
}
function translateRoot (root = document.body) {
if (typeof document === 'undefined' || !root) return
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
{
acceptNode (node) {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeRuntimeText(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
}
)
let node = walker.currentNode
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
translateTextNode(node)
} else if (node.nodeType === Node.ELEMENT_NODE) {
translateElementAttrs(node)
}
node = walker.nextNode()
}
}
function scheduleApply () {
if (applyTimer) clearTimeout(applyTimer)
applyTimer = setTimeout(async () => {
applyTimer = null
await nextTick()
translateRoot(document.body)
}, 30)
}
onMounted(async () => {
await runtimeTranslations.loadLocale(localeStore.locale)
scheduleApply()
observer = new MutationObserver(() => scheduleApply())
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATABLE_ATTRS
})
})
watch(
() => localeStore.locale,
async (locale) => {
await runtimeTranslations.loadLocale(locale)
scheduleApply()
}
)
watch(
() => route.fullPath,
() => scheduleApply()
)
watch(
() => runtimeTranslations.version,
() => scheduleApply()
)
onBeforeUnmount(() => {
if (observer) observer.disconnect()
if (applyTimer) clearTimeout(applyTimer)
})
}

View File

@@ -171,6 +171,7 @@ import { Dialog, useQuasar } from 'quasar'
import { useAuthStore } from 'stores/authStore'
import { usePermissionStore } from 'stores/permissionStore'
import { useI18n } from 'src/composables/useI18n'
import { useRuntimeDomTranslations } from 'src/composables/useRuntimeDomTranslations'
import { UI_LANGUAGE_OPTIONS } from 'src/i18n/languages'
import { useLocaleStore } from 'src/stores/localeStore'
import { activityLogsMenuItem, getAuthUserId } from 'src/modules/activityLogs'
@@ -185,6 +186,7 @@ const auth = useAuthStore()
const perm = usePermissionStore()
const localeStore = useLocaleStore()
const { t } = useI18n()
useRuntimeDomTranslations()
const languageOptions = UI_LANGUAGE_OPTIONS
const selectedLocale = computed({

View File

@@ -4,7 +4,7 @@
<div class="col-12 col-md">
<div class="text-h6">Marka Sınıflandırma</div>
<div class="text-caption text-grey-7">
Kaynak: BAGGI_V3 `cdItemAttribute` (ItemTypeCode=1, AttributeTypeCode=10)
Kaynak: NEBIM_V3 marka bilgileri
</div>
</div>

View File

@@ -371,7 +371,7 @@ const importKeyFieldLabels = [
const importFieldMap = {
AKTIF: 'is_active',
'HESAP AKTIF': 'calc_enabled',
'PG YAYIN': 'publish_postgres',
'B2B YAYIN': 'publish_postgres',
'NEBIM YAYIN': 'publish_nebim',
'TRY TOPTAN YUVARLAMA': 'try_wholesale_step',
'TRY PERAKENDE MODU': 'try_retail_mode',
@@ -454,7 +454,7 @@ const columns = [
col('brand_group', 'MARKA GRUBU', 'brand_group', 76, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('anchor_mode', 'ANCHOR MODE', 'anchor_mode', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('calc_enabled', 'HESAP AKTIF', 'calc_enabled', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('publish_postgres', 'PG YAYIN', 'publish_postgres', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('publish_postgres', 'B2B YAYIN', 'publish_postgres', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('publish_nebim', 'NEBIM YAYIN', 'publish_nebim', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 76, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),

View File

@@ -71,6 +71,31 @@
</q-btn>
</div>
</div>
<div class="col-fixed kategori">
<div class="filter-head">
<span>KATEGORI</span>
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('kategori') ? 'primary' : 'grey-8'">
<q-badge v-if="activeFilterCount('kategori')" floating color="primary">{{ activeFilterCount('kategori') }}</q-badge>
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
<div class="q-pa-sm">
<q-input v-model="filterSearch.kategori" dense outlined clearable placeholder="Ara" autofocus />
<div class="row q-gutter-xs q-mt-sm">
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('kategori')" />
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('kategori')" />
</div>
<q-separator class="q-my-sm" />
<q-option-group
v-model="columnFilters.kategori"
:options="filteredFilterOptions('kategori')"
type="checkbox"
dense
class="column-filter-options"
/>
</div>
</q-menu>
</q-btn>
</div>
</div>
<div class="col-fixed renk">
<div class="filter-head">
<span>RENK</span>
@@ -214,6 +239,7 @@
>
<div class="sub-col model">{{ row.product_code || '-' }}</div>
<div class="sub-col desc">{{ row.product_description || '-' }}</div>
<div class="sub-col kategori">{{ row.kategori || '-' }}</div>
<div class="sub-col renk">
<div class="renk-kodu">{{ variantCode(row) }}</div>
<div class="renk-aciklama">{{ row.color_title || '-' }}</div>
@@ -287,6 +313,7 @@ const errorMessage = ref('')
const columnFilters = ref({
model: [],
desc: [],
kategori: [],
renk: [],
ana: [],
alt: [],
@@ -295,6 +322,7 @@ const columnFilters = ref({
const filterSearch = ref({
model: '',
desc: '',
kategori: '',
renk: '',
ana: '',
alt: '',
@@ -341,6 +369,7 @@ const productGroups = computed(() => displayRows.value)
const filterOptions = computed(() => ({
model: uniqueOptions(rows.value.map(row => row.product_code)),
desc: uniqueOptions(rows.value.map(row => row.product_description)),
kategori: uniqueOptions(rows.value.map(row => row.kategori)),
renk: uniqueOptions(rows.value.map(row => variantCode(row))),
ana: uniqueOptions(rows.value.map(row => row.urun_ana_grubu)),
alt: uniqueOptions(rows.value.map(row => row.urun_alt_grubu)),
@@ -386,6 +415,7 @@ function clearFilter (key) {
function rowPassesFilters (row) {
return filterMatch('model', row.product_code) &&
filterMatch('desc', row.product_description) &&
filterMatch('kategori', row.kategori) &&
filterMatch('renk', variantCode(row)) &&
filterMatch('ana', row.urun_ana_grubu) &&
filterMatch('alt', row.urun_alt_grubu) &&
@@ -548,7 +578,7 @@ function mapRowSizesToSchema (row) {
const rawSizeLabels = sizeEntries.map(([rawSize]) => normalizeBedenLabel(rawSize))
const schemaMap = getSchemaMap()
const grpKey = detectRowGroupKey(row, rawSizeLabels)
const schema = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
const schema = pickSchemaForRow(schemaMap, grpKey, rawSizeLabels)
const schemaLabelMap = normalizedSchemaLabelMap(schema)
const mapped = {}
@@ -569,6 +599,26 @@ function mapRowSizesToSchema (row) {
return { grpKey, schema, mapped }
}
function pickSchemaForRow (schemaMap, grpKey, rawSizeLabels) {
const primary = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
const rawSet = new Set((rawSizeLabels || []).map(v => normalizeBedenLabel(v)).filter(Boolean))
if (!rawSet.size) return primary
const primarySet = new Set((primary?.values || []).map(v => normalizeBedenLabel(v)))
if ([...rawSet].some(v => primarySet.has(v))) return primary
let best = primary
let bestScore = 0
for (const schema of Object.values(schemaMap || {})) {
const schemaSet = new Set((schema?.values || []).map(v => normalizeBedenLabel(v)))
const score = [...rawSet].reduce((sum, v) => sum + (schemaSet.has(v) ? 1 : 0), 0)
if (score > bestScore) {
best = schema
bestScore = score
}
}
return best
}
function formatQty (value) {
const n = Number(value || 0)
if (!Number.isFinite(n) || n === 0) return ''
@@ -605,7 +655,7 @@ function escapeHtml (value) {
function exportVisibleExcel () {
const groups = schemaRows.value
const rowSpan = Math.max(groups.length, 1)
const leftHeaders = ['MODEL', 'DESC', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
const leftHeaders = ['MODEL', 'DESC', 'KATEGORI', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
const headerRows = (groups.length ? groups : [{ key: 'tak', title: 'TAKIM ELBISE', values: [] }]).map((grp, index) => {
const left = index === 0
? leftHeaders.map(h => `<th rowspan="${rowSpan}">${escapeHtml(h)}</th>`).join('')
@@ -631,6 +681,7 @@ function exportVisibleExcel () {
return `<tr>
<td>${escapeHtml(row.product_code || '')}</td>
<td>${escapeHtml(row.product_description || '')}</td>
<td>${escapeHtml(row.kategori || '')}</td>
<td>${escapeHtml(variantCode(row))}</td>
<td>${escapeHtml(row.urun_ana_grubu || '')}</td>
<td>${escapeHtml(row.urun_alt_grubu || '')}</td>
@@ -665,6 +716,7 @@ onMounted(async () => {
--psq-sticky-offset: 12px;
--grp-title-w: 90px;
--col-desc: var(--col-aciklama);
--col-kategori-series: 86px;
--col-marka-series: var(--col-marka, 90px);
--psq-header-h: var(--grid-header-h);
--series-total-w: 76px;
@@ -681,6 +733,7 @@ onMounted(async () => {
grid-template-columns:
var(--col-model)
var(--col-desc)
var(--col-kategori-series)
var(--col-renk)
var(--col-ana)
var(--col-alt)
@@ -774,11 +827,11 @@ onMounted(async () => {
}
.total-header-cell {
grid-column: 8 / 9;
grid-column: 9 / 10;
}
.series-header-cell {
grid-column: 9 / 10;
grid-column: 10 / 11;
}
.series-flat-row {
@@ -816,12 +869,14 @@ onMounted(async () => {
.series-flat-row .sub-col.model { grid-column: 1; }
.series-flat-row .sub-col.desc { grid-column: 2; }
.series-flat-row .sub-col.renk { grid-column: 3; }
.series-flat-row .sub-col.ana { grid-column: 4; }
.series-flat-row .sub-col.alt { grid-column: 5; }
.series-flat-row .sub-col.marka { grid-column: 6; }
.series-flat-row .sub-col.kategori { grid-column: 3; }
.series-flat-row .sub-col.renk { grid-column: 4; }
.series-flat-row .sub-col.ana { grid-column: 5; }
.series-flat-row .sub-col.alt { grid-column: 6; }
.series-flat-row .sub-col.marka { grid-column: 7; }
.series-flat-row .sub-col.model,
.series-flat-row .sub-col.kategori,
.series-flat-row .sub-col.renk,
.series-flat-row .sub-col.ana,
.series-flat-row .sub-col.alt,
@@ -851,7 +906,7 @@ onMounted(async () => {
}
.flat-size-cells {
grid-column: 7;
grid-column: 8;
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--beden-w);
@@ -893,7 +948,7 @@ onMounted(async () => {
}
.total-cell {
grid-column: 8;
grid-column: 9;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -906,7 +961,7 @@ onMounted(async () => {
}
.series-select-cell {
grid-column: 9 / 10;
grid-column: 10 / 11;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -70,6 +70,31 @@
</q-btn>
</div>
</div>
<div class="col-fixed kategori">
<div class="filter-head">
<span>KATEGORI</span>
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('kategori') ? 'primary' : 'grey-8'">
<q-badge v-if="activeFilterCount('kategori')" floating color="primary">{{ activeFilterCount('kategori') }}</q-badge>
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
<div class="q-pa-sm">
<q-input v-model="filterSearch.kategori" dense outlined clearable placeholder="Ara" autofocus />
<div class="row q-gutter-xs q-mt-sm">
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('kategori')" />
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('kategori')" />
</div>
<q-separator class="q-my-sm" />
<q-option-group
v-model="columnFilters.kategori"
:options="filteredFilterOptions('kategori')"
type="checkbox"
dense
class="column-filter-options"
/>
</div>
</q-menu>
</q-btn>
</div>
</div>
<div class="col-fixed renk">
<div class="filter-head">
<span>RENK</span>
@@ -212,6 +237,7 @@
>
<div class="sub-col model">{{ row.product_code || '-' }}</div>
<div class="sub-col desc">{{ row.product_description || '-' }}</div>
<div class="sub-col kategori">{{ row.kategori || '-' }}</div>
<div class="sub-col renk">
<div class="renk-kodu">{{ variantCode(row) }}</div>
<div class="renk-aciklama">{{ row.color_title || '-' }}</div>
@@ -273,6 +299,7 @@ const errorMessage = ref('')
const columnFilters = ref({
model: [],
desc: [],
kategori: [],
renk: [],
ana: [],
alt: [],
@@ -281,6 +308,7 @@ const columnFilters = ref({
const filterSearch = ref({
model: '',
desc: '',
kategori: '',
renk: '',
ana: '',
alt: '',
@@ -325,6 +353,7 @@ const productGroups = computed(() => displayRows.value)
const filterOptions = computed(() => ({
model: uniqueOptions(rows.value.map(row => row.product_code)),
desc: uniqueOptions(rows.value.map(row => row.product_description)),
kategori: uniqueOptions(rows.value.map(row => row.kategori)),
renk: uniqueOptions(rows.value.map(row => variantCode(row))),
ana: uniqueOptions(rows.value.map(row => row.urun_ana_grubu)),
alt: uniqueOptions(rows.value.map(row => row.urun_alt_grubu)),
@@ -370,6 +399,7 @@ function clearFilter (key) {
function rowPassesFilters (row) {
return filterMatch('model', row.product_code) &&
filterMatch('desc', row.product_description) &&
filterMatch('kategori', row.kategori) &&
filterMatch('renk', variantCode(row)) &&
filterMatch('ana', row.urun_ana_grubu) &&
filterMatch('alt', row.urun_alt_grubu) &&
@@ -492,7 +522,7 @@ function mapRowSizesToSchema (row) {
const rawSizeLabels = sizeEntries.map(([rawSize]) => normalizeBedenLabel(rawSize))
const schemaMap = getSchemaMap()
const grpKey = detectRowGroupKey(row, rawSizeLabels)
const schema = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
const schema = pickSchemaForRow(schemaMap, grpKey, rawSizeLabels)
const schemaLabelMap = normalizedSchemaLabelMap(schema)
const mapped = {}
@@ -513,6 +543,26 @@ function mapRowSizesToSchema (row) {
return { grpKey, schema, mapped }
}
function pickSchemaForRow (schemaMap, grpKey, rawSizeLabels) {
const primary = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
const rawSet = new Set((rawSizeLabels || []).map(v => normalizeBedenLabel(v)).filter(Boolean))
if (!rawSet.size) return primary
const primarySet = new Set((primary?.values || []).map(v => normalizeBedenLabel(v)))
if ([...rawSet].some(v => primarySet.has(v))) return primary
let best = primary
let bestScore = 0
for (const schema of Object.values(schemaMap || {})) {
const schemaSet = new Set((schema?.values || []).map(v => normalizeBedenLabel(v)))
const score = [...rawSet].reduce((sum, v) => sum + (schemaSet.has(v) ? 1 : 0), 0)
if (score > bestScore) {
best = schema
bestScore = score
}
}
return best
}
function formatQty (value) {
const n = Number(value || 0)
if (!Number.isFinite(n) || n === 0) return ''
@@ -544,7 +594,7 @@ function csvEscape (v) {
}
function exportVisibleExcel () {
const cols = ['MODEL', 'DESC', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
const cols = ['MODEL', 'DESC', 'KATEGORI', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
const sizeCols = schemaRows.value.flatMap(grp => paddedSchemaValues(grp).map(v => (v.ghost ? '' : v.value))).filter(Boolean)
cols.push(...sizeCols)
cols.push('TOPLAM', 'SERI', 'UYARI')
@@ -559,6 +609,7 @@ function exportVisibleExcel () {
lines.push([
row.product_code || '',
row.product_description || '',
row.kategori || '',
variantCode(row),
row.urun_ana_grubu || '',
row.urun_alt_grubu || '',
@@ -590,6 +641,7 @@ onMounted(() => reload())
--psq-sticky-offset: 12px;
--grp-title-w: 90px;
--col-desc: var(--col-aciklama);
--col-kategori-series: 86px;
--col-marka-series: var(--col-marka, 90px);
--psq-header-h: var(--grid-header-h);
--series-total-w: 76px;
@@ -606,6 +658,7 @@ onMounted(() => reload())
grid-template-columns:
var(--col-model)
var(--col-desc)
var(--col-kategori-series)
var(--col-renk)
var(--col-ana)
var(--col-alt)
@@ -699,11 +752,11 @@ onMounted(() => reload())
}
.total-header-cell {
grid-column: 8 / 9;
grid-column: 9 / 10;
}
.series-header-cell {
grid-column: 9 / 10;
grid-column: 10 / 11;
}
.series-flat-row {
@@ -741,12 +794,14 @@ onMounted(() => reload())
.series-flat-row .sub-col.model { grid-column: 1; }
.series-flat-row .sub-col.desc { grid-column: 2; }
.series-flat-row .sub-col.renk { grid-column: 3; }
.series-flat-row .sub-col.ana { grid-column: 4; }
.series-flat-row .sub-col.alt { grid-column: 5; }
.series-flat-row .sub-col.marka { grid-column: 6; }
.series-flat-row .sub-col.kategori { grid-column: 3; }
.series-flat-row .sub-col.renk { grid-column: 4; }
.series-flat-row .sub-col.ana { grid-column: 5; }
.series-flat-row .sub-col.alt { grid-column: 6; }
.series-flat-row .sub-col.marka { grid-column: 7; }
.series-flat-row .sub-col.model,
.series-flat-row .sub-col.kategori,
.series-flat-row .sub-col.renk,
.series-flat-row .sub-col.ana,
.series-flat-row .sub-col.alt,
@@ -776,7 +831,7 @@ onMounted(() => reload())
}
.flat-size-cells {
grid-column: 7;
grid-column: 8;
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--beden-w);
@@ -818,7 +873,7 @@ onMounted(() => reload())
}
.total-cell {
grid-column: 8;
grid-column: 9;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -831,7 +886,7 @@ onMounted(() => reload())
}
.series-select-cell {
grid-column: 9 / 10;
grid-column: 10 / 11;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -840,7 +840,7 @@
<div>
<div class="text-subtitle1 text-weight-bold">Fiyat Kontrolu (Satinalma Ortalama)</div>
<div class="text-caption text-grey-7">
BAGGI_V3 satinalma gecmisindeki son 10 USD ortalamasindan %10'dan fazla sapan tum satirlar.
NEBIM_V3 satinalma gecmisindeki son 10 USD ortalamasindan %10'dan fazla sapan tum satirlar.
</div>
</div>
<q-badge color="primary" outline>{{ priceDeviationRows.length }} satir</q-badge>
@@ -1105,6 +1105,7 @@ import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { usePermission } from 'src/composables/usePermission'
import { get, post, download, extractApiErrorDetail } from 'src/services/api'
import { createTraceId, slog } from 'src/utils/slog'
import { formatDataSourceLabel } from 'src/utils/formatters'
const route = useRoute()
const router = useRouter()
@@ -2334,8 +2335,8 @@ function resolveHistorySourceType (item, forcedType = '') {
}
function resolveHistorySourceLabel (sourceType) {
if (sourceType === 'purchase') return 'BAGGI_V3'
if (sourceType === 'recipe') return 'URETIM'
if (sourceType === 'purchase') return formatDataSourceLabel('BAGGI_V3')
if (sourceType === 'recipe') return formatDataSourceLabel('URETIM')
return 'HISTORY'
}
@@ -4770,7 +4771,7 @@ async function confirmBrPriceDeviationIfNeeded () {
html: true,
message: `
<div style="margin-bottom:10px;">
Bazı satırlarda girilen fiyat, BAGGI_V3 satınalma geçmişindeki <b>son 10</b> kaydın USD ortalamasından <b>%10</b>'dan fazla sapıyor.
Bazı satırlarda girilen fiyat, NEBIM_V3 satınalma geçmişindeki <b>son 10</b> kaydın USD ortalamasından <b>%10</b>'dan fazla sapıyor.
</div>
<div style="max-height: 360px; overflow:auto; border:1px solid #e0e0e0; border-radius:6px;">
<table style="width:100%; border-collapse:collapse; font-size:13px;">

View File

@@ -5,7 +5,7 @@
<div class="col">
<div class="text-h6">Maliyet Parca Eslestirme</div>
<div class="text-caption text-grey-7">
V3 Urun Ilk Grubu (42. ozellik) + Urun Ana/Alt Grup + URETIM Parca Bolum + Hammadde Turleri eslestirmesi (URETIM mk_ tablolarinda tutulur)
NEBIM_V3 urun gruplari + TEDARIK URETIM PROGRAMI parca bolum + hammadde turleri eslestirmesi
</div>
</div>

View File

@@ -207,6 +207,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useTranslationStore } from 'src/stores/translationStore'
import { formatDataSourceLabel } from 'src/utils/formatters'
const $q = useQuasar()
const store = useTranslationStore()
@@ -231,15 +232,10 @@ const tablePagination = ref({
let filterReloadTimer = null
const sourceTypeOptions = [
{ label: 'dummy', value: 'dummy' },
{ label: 'postgre', value: 'postgre' },
{ label: 'mssql', value: 'mssql' }
{ label: formatDataSourceLabel('dummy'), value: 'dummy' },
{ label: formatDataSourceLabel('postgre'), value: 'postgre' },
{ label: formatDataSourceLabel('mssql'), value: 'mssql' }
]
const sourceTypeLabelMap = {
dummy: 'UI',
postgre: 'PostgreSQL',
mssql: 'MSSQL'
}
const columns = [
{ name: 'actions', label: 'Güncelle', field: 'actions', align: 'left' },
@@ -413,7 +409,7 @@ function cellClass (key, field) {
function sourceTypeLabel (key) {
const val = String(rowDraft(key).source_type || 'dummy').toLowerCase()
return sourceTypeLabelMap[val] || val || '-'
return formatDataSourceLabel(val)
}
function toggleSelected (key, checked) {

View File

@@ -3,9 +3,8 @@
v-if="canReadUser"
class="q-pa-md user-sync-page"
>
<div class="row items-center justify-between q-mb-md">
<div class="text-h6 text-primary">👤 Kullanıcı Yönetimi</div>
<div class="text-h6 text-primary">Kullanici Yonetimi</div>
<q-btn
v-if="canUpdateUser"
color="primary"
@@ -20,11 +19,10 @@
<q-separator />
<div class="row q-col-gutter-md q-mt-md">
<!-- 🔹 PostgreSQL Kullanıcıları -->
<div class="col-6">
<q-card flat bordered>
<q-card-section class="bg-primary text-white text-subtitle1">
PostgreSQL Kullanıcıları
B2B Kullanicilari
</q-card-section>
<q-table
@@ -36,7 +34,7 @@
bordered
separator="cell"
>
<template v-slot:body-cell-sync_status="props">
<template #body-cell-sync_status="props">
<q-td :props="props">
<q-chip
:color="statusColor(props.row.sync_status)"
@@ -48,23 +46,27 @@
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
v-if="canUpdateUser"
dense flat icon="link"
dense
flat
icon="link"
color="primary"
size="sm"
@click="openMapDialog(props.row)"
:disable="store.loading || !canUpdateUser"
@click="openMapDialog(props.row)"
/>
<q-btn
v-if="canUpdateUser"
dense flat icon="link_off"
dense
flat
icon="link_off"
color="negative"
size="sm"
@click="store.unmap(props.row.id)"
:disable="store.loading || !props.row.mssql_username || !canUpdateUser"
@click="store.unmap(props.row.id)"
/>
</q-td>
</template>
@@ -72,11 +74,10 @@
</q-card>
</div>
<!-- 🔸 MSSQL Kullanıcıları -->
<div class="col-6">
<q-card flat bordered>
<q-card-section class="bg-secondary text-white text-subtitle1">
MSSQL Kullanıcıları
NEBIM_V3 Kullanicilari
</q-card-section>
<q-table
@@ -88,7 +89,7 @@
bordered
separator="cell"
>
<template v-slot:body-cell-is_blocked="props">
<template #body-cell-is_blocked="props">
<q-td :props="props">
<q-chip
:color="props.row.is_blocked ? 'negative' : 'positive'"
@@ -117,9 +118,9 @@
<script setup>
import { onMounted } from 'vue'
import { useUserSyncStore } from 'src/stores/userSyncStore'
import { Dialog } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useUserSyncStore } from 'src/stores/userSyncStore'
const { canRead, canUpdate } = usePermission()
const canReadUser = canRead('user')
@@ -132,20 +133,20 @@ const pgColumns = [
{ name: 'code', label: 'Kodu', field: 'code', align: 'left' },
{ name: 'full_name', label: 'Ad Soyad', field: 'full_name', align: 'left' },
{ name: 'email', label: 'E-posta', field: 'email', align: 'left' },
{ name: 'mssql_username', label: 'MSSQL Kullanıcı', field: 'mssql_username', align: 'left' },
{ name: 'mssql_username', label: 'NEBIM_V3 Kullanicisi', field: 'mssql_username', align: 'left' },
{ name: 'sync_status', label: 'Durum', field: 'sync_status', align: 'center' },
{ name: 'actions', label: 'İşlemler', field: 'actions', align: 'center' }
{ name: 'actions', label: 'Islemler', field: 'actions', align: 'center' }
]
const msColumns = [
{ name: 'username', label: 'Kullanıcı Adı', field: 'username', align: 'left' },
{ name: 'username', label: 'Kullanici Adi', field: 'username', align: 'left' },
{ name: 'first_name', label: 'Ad', field: 'first_name', align: 'left' },
{ name: 'last_name', label: 'Soyad', field: 'last_name', align: 'left' },
{ name: 'email', label: 'E-posta', field: 'email', align: 'left' },
{ name: 'is_blocked', label: 'Durum', field: 'is_blocked', align: 'center' }
]
function statusColor(status) {
function statusColor (status) {
switch (status) {
case 'synced': return 'positive'
case 'manual': return 'primary'
@@ -155,14 +156,12 @@ function statusColor(status) {
}
}
function openMapDialog(pgUser) {
if (!canUpdateUser.value) {
return
}
function openMapDialog (pgUser) {
if (!canUpdateUser.value) return
Dialog.create({
title: 'Kullanıcı Eşleme',
message: 'Bu PostgreSQL kullanıcısını hangi MSSQL kullanıcısına bağlamak istiyorsunuz?',
title: 'Kullanici Esleme',
message: 'Bu B2B kullanicisini hangi NEBIM_V3 kullanicisina baglamak istiyorsunuz?',
options: {
type: 'radio',
model: '',
@@ -186,6 +185,7 @@ onMounted(() => {
.user-sync-page {
background: #fafafa;
}
.q-card-section {
font-weight: 600;
}

View File

@@ -3,6 +3,7 @@ import qs from 'qs'
import { useAuthStore } from 'stores/authStore'
import { DEFAULT_LOCALE, normalizeLocale } from 'src/i18n/languages'
import { slog } from 'src/utils/slog'
import { formatDataSourceLabel } from 'src/utils/formatters'
const rawBaseUrl =
(typeof process !== 'undefined' && process.env?.VITE_API_BASE_URL) || '/api'
@@ -63,7 +64,7 @@ function sanitizeApiErrorDetail(detail, status) {
return `Unexpected HTML error response (${status || '-'})`
}
const compact = normalized.replace(/\s+/g, ' ').trim()
const compact = normalizeTechnicalSourceLabels(normalized.replace(/\s+/g, ' ').trim())
if (compact.length > 320) {
return `${compact.slice(0, 320)}...`
}
@@ -71,6 +72,17 @@ function sanitizeApiErrorDetail(detail, status) {
return compact
}
function normalizeTechnicalSourceLabels(text) {
return String(text || '')
.replace(/\bPostgreSQL\b/gi, formatDataSourceLabel('postgresql'))
.replace(/\bPostgres\b/gi, formatDataSourceLabel('postgres'))
.replace(/\bpostgre\b/gi, formatDataSourceLabel('postgre'))
.replace(/\bPG\b/g, formatDataSourceLabel('pg'))
.replace(/\bMSSQL\b/gi, formatDataSourceLabel('mssql'))
.replace(/\bBAGGI_V3\b/gi, formatDataSourceLabel('BAGGI_V3'))
.replace(/\bURETIM\b/g, formatDataSourceLabel('URETIM'))
}
function redirectToLogin() {
if (typeof window === 'undefined') return
if (window.location.hash === '#/login') return
@@ -203,6 +215,8 @@ api.interceptors.response.use(
requestUrl.startsWith('/me/password')
const isPublicRequest = isPublicPath(requestUrl)
normalizeErrorResponsePayload(error)
if ((status >= 500 || hasBlob) && error) {
const method = String(requestConfig.method || 'GET').toUpperCase()
const detail = sanitizeApiErrorDetail(
@@ -262,6 +276,22 @@ api.interceptors.response.use(
}
)
function normalizeErrorResponsePayload(error) {
const data = error?.response?.data
if (!data || typeof Blob !== 'undefined' && data instanceof Blob) return
if (typeof data === 'string') {
error.response.data = normalizeTechnicalSourceLabels(data)
return
}
if (typeof data === 'object') {
for (const key of ['detail', 'message', 'error']) {
if (typeof data[key] === 'string') {
data[key] = normalizeTechnicalSourceLabels(data[key])
}
}
}
}
export const get = (u, p = {}, c = {}) =>
api.get(u, { params: p, ...c }).then(r => r.data)

View File

@@ -53,7 +53,13 @@ const SIZE_GROUP_TITLES = {
}
const FALLBACK_SCHEMA_MAP = {
tak: { key: 'tak', title: 'TAKIM ELBISE', values: ['44', '46', '48', '50', '52', '54', '56', '58', '60', '62', '64', '66', '68', '70', '72', '74'] }
tak: { key: 'tak', title: 'TAKIM ELBISE', values: ['44', '46', '48', '50', '52', '54', '56', '58', '60', '62', '64', '66', '68', '70', '72', '74'] },
ayk: { key: 'ayk', title: 'AYAKKABI', values: ['39', '40', '41', '42', '43', '44', '45'] },
ayk_garson: { key: 'ayk_garson', title: 'AYAKKABI GARSON', values: ['31', '32', '33', '34', '35', '36', '37', '38'] },
yas: { key: 'yas', title: 'YAS', values: ['2', '4', '6', '8', '10', '12', '14'] },
pan: { key: 'pan', title: 'PANTOLON', values: ['38', '40', '42', '44', '46', '48', '50', '52', '54', '56', '58', '60'] },
gom: { key: 'gom', title: 'GOMLEK', values: ['XS', 'S', 'M', 'L', 'XL', '2XL', '3XL', '4XL', '5XL', '6XL', '7XL'] },
aksbir: { key: 'aksbir', title: 'AKSESUAR', values: [' '] }
}
export const schemaByKey = { ...FALLBACK_SCHEMA_MAP }

View File

@@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from 'src/services/api'
import { DEFAULT_LOCALE, normalizeLocale } from 'src/i18n/languages'
function normalizeRuntimeText (value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
export const useRuntimeTranslationStore = defineStore('runtimeTranslation', () => {
const locale = ref(DEFAULT_LOCALE)
const byKey = ref({})
const byText = ref({})
const loading = ref(false)
const loadedLocales = ref({})
const version = ref(0)
async function loadLocale (nextLocale, options = {}) {
const normalized = normalizeLocale(nextLocale)
const force = Boolean(options?.force)
locale.value = normalized
if (!force && loadedLocales.value[normalized]) {
byKey.value = loadedLocales.value[normalized].byKey
byText.value = loadedLocales.value[normalized].byText
version.value += 1
return true
}
loading.value = true
try {
const res = await api.get('/language/translations/runtime', {
params: { lang: normalized },
timeout: 180000
})
const payload = res?.data || {}
const nextByKey = payload?.by_key && typeof payload.by_key === 'object' ? payload.by_key : {}
const nextByTextRaw = payload?.by_text && typeof payload.by_text === 'object' ? payload.by_text : {}
const nextByText = {}
for (const [source, translated] of Object.entries(nextByTextRaw)) {
const sourceKey = normalizeRuntimeText(source)
const display = normalizeRuntimeText(translated)
if (sourceKey && display) nextByText[sourceKey] = display
}
loadedLocales.value = {
...loadedLocales.value,
[normalized]: {
byKey: nextByKey,
byText: nextByText
}
}
byKey.value = nextByKey
byText.value = nextByText
version.value += 1
return true
} catch (err) {
console.warn('[runtime-i18n] dictionary load failed', normalized, err)
byKey.value = {}
byText.value = {}
version.value += 1
return false
} finally {
loading.value = false
}
}
function translateKey (key) {
const raw = String(key || '').trim()
if (!raw) return ''
return byKey.value[raw] || raw
}
function translateText (text) {
const normalized = normalizeRuntimeText(text)
if (!normalized) return ''
return byText.value[normalized] || normalized
}
return {
locale,
byKey,
byText,
loading,
version,
loadLocale,
translateKey,
translateText
}
})

View File

@@ -84,3 +84,13 @@ export function formatPercent(value) {
const n = Number(value || 0)
return `${(n * 100).toFixed(2)}%`
}
export function formatDataSourceLabel(value) {
const raw = String(value || '').trim()
const normalized = raw.toLowerCase().replace(/[\s-]+/g, '_')
if (['pg', 'postgre', 'postgres', 'postgresql'].includes(normalized)) return 'B2B'
if (['mssql', 'baggi_v3', 'baggi'].includes(normalized)) return 'NEBIM_V3'
if (normalized === 'uretim') return 'TEDARIK URETIM PROGRAMI'
if (normalized === 'dummy' || normalized === 'ui') return 'UI'
return raw || '-'
}