Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-22 14:57:34 +03:00
parent d886fba6de
commit 1f90b9f9ce
25 changed files with 2767 additions and 687 deletions

View File

@@ -15,7 +15,7 @@
flat
bordered
dense
row-key="urun_ilk_grubu"
row-key="group_code"
:loading="store.loading"
:rows="store.rows"
:columns="columns"
@@ -25,8 +25,8 @@
<template #body-cell-mail_selector="props">
<q-td :props="props">
<q-select
:model-value="editableByGroup[props.row.urun_ilk_grubu] || []"
:options="mailOptionsByGroup[props.row.urun_ilk_grubu] || allMailOptions"
:model-value="editableByGroup[props.row.group_code] || []"
:options="mailOptionsByGroup[props.row.group_code] || allMailOptions"
option-value="id"
option-label="label"
emit-value
@@ -39,8 +39,8 @@
dense
outlined
label="Mail ara ve sec"
@filter="(val, update) => filterMailOptions(props.row.urun_ilk_grubu, val, update)"
@update:model-value="(val) => updateRowSelection(props.row.urun_ilk_grubu, val)"
@filter="(val, update) => filterMailOptions(props.row.group_code, val, update)"
@update:model-value="(val) => updateRowSelection(props.row.group_code, val)"
/>
</q-td>
</template>
@@ -71,7 +71,8 @@ const originalByGroup = ref({})
const mailOptionsByGroup = ref({})
const columns = [
{ name: 'urun_ilk_grubu', label: 'Urun Ilk Grubu', field: 'urun_ilk_grubu', align: 'left' },
{ name: 'group_code', label: 'Urun Ilk Grup Kodu', field: 'group_code', align: 'left' },
{ name: 'group_title', label: 'Urun Ilk Grup Aciklama', field: 'group_title', align: 'left' },
{ name: 'mail_selector', label: 'Maliyet Mail Eslestirme', field: 'mail_selector', align: 'left' }
]
@@ -81,7 +82,7 @@ const allMailOptions = computed(() =>
const changedGroups = computed(() => {
return (store.rows || [])
.map((r) => String(r.urun_ilk_grubu || '').trim())
.map((r) => String(r.group_code || r.urun_ilk_grubu || '').trim())
.filter(Boolean)
.filter((g) => {
const current = normalizeList(editableByGroup.value[g] || [])
@@ -115,7 +116,7 @@ function initEditableState () {
const original = {}
;(store.rows || []).forEach((row) => {
const g = String(row.urun_ilk_grubu || '').trim()
const g = String(row.group_code || row.urun_ilk_grubu || '').trim()
const selected = normalizeList(row.mail_ids || [])
editable[g] = [...selected]
original[g] = [...selected]
@@ -169,4 +170,3 @@ async function saveChanges () {
onMounted(() => { init() })
</script>

View File

@@ -15,7 +15,7 @@
flat
bordered
dense
row-key="urun_ilk_grubu"
row-key="group_code"
:loading="store.loading"
:rows="store.rows"
:columns="columns"
@@ -25,8 +25,8 @@
<template #body-cell-mail_selector="props">
<q-td :props="props">
<q-select
:model-value="editableByGroup[props.row.urun_ilk_grubu] || []"
:options="mailOptionsByGroup[props.row.urun_ilk_grubu] || allMailOptions"
:model-value="editableByGroup[props.row.group_code] || []"
:options="mailOptionsByGroup[props.row.group_code] || allMailOptions"
option-value="id"
option-label="label"
emit-value
@@ -39,8 +39,8 @@
dense
outlined
label="Mail ara ve sec"
@filter="(val, update) => filterMailOptions(props.row.urun_ilk_grubu, val, update)"
@update:model-value="(val) => updateRowSelection(props.row.urun_ilk_grubu, val)"
@filter="(val, update) => filterMailOptions(props.row.group_code, val, update)"
@update:model-value="(val) => updateRowSelection(props.row.group_code, val)"
/>
</q-td>
</template>
@@ -71,7 +71,8 @@ const originalByGroup = ref({})
const mailOptionsByGroup = ref({})
const columns = [
{ name: 'urun_ilk_grubu', label: 'Urun Ilk Grubu', field: 'urun_ilk_grubu', align: 'left' },
{ name: 'group_code', label: 'Urun Ilk Grup Kodu', field: 'group_code', align: 'left' },
{ name: 'group_title', label: 'Urun Ilk Grup Aciklama', field: 'group_title', align: 'left' },
{ name: 'mail_selector', label: 'Fiyatlandirma Mail Eslestirme', field: 'mail_selector', align: 'left' }
]
@@ -81,7 +82,7 @@ const allMailOptions = computed(() =>
const changedGroups = computed(() => {
return (store.rows || [])
.map((r) => String(r.urun_ilk_grubu || '').trim())
.map((r) => String(r.group_code || r.urun_ilk_grubu || '').trim())
.filter(Boolean)
.filter((g) => {
const current = normalizeList(editableByGroup.value[g] || [])
@@ -115,7 +116,7 @@ function initEditableState () {
const original = {}
;(store.rows || []).forEach((row) => {
const g = String(row.urun_ilk_grubu || '').trim()
const g = String(row.group_code || row.urun_ilk_grubu || '').trim()
const selected = normalizeList(row.mail_ids || [])
editable[g] = [...selected]
original[g] = [...selected]
@@ -169,4 +170,3 @@ async function saveChanges () {
onMounted(() => { init() })
</script>

View File

@@ -3,37 +3,65 @@
<div ref="stickyStackRef" class="sticky-stack pcd-sticky-stack">
<div ref="saveToolbarRef" class="save-toolbar pcd-save-toolbar q-px-md">
<div class="pcd-toolbar-row">
<div class="pcd-toolbar-left">
<div class="pcd-toolbar-title">Maliyet Detay Sayfasi</div>
<div v-if="detailHeader && !detailLoading" class="pcd-toolbar-summary">
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">USD</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.usdTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">EUR</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.eurTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">GBP</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.gbpTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">USD Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.usdRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">EUR Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.eurRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">GBP Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.gbpRate) }}</span>
</div>
</div>
</div>
<div class="pcd-toolbar-left">
<div class="pcd-toolbar-title">Maliyet Detay Sayfasi</div>
<div v-if="detailHeader && !detailLoading" class="pcd-toolbar-summary">
<!-- tbStok kontrolu (exists-bulk) manuel giris kapandigi icin devre disi -->
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">USD</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.usdTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">TRY</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.tryTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">EUR</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.eurTotal) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-emphasis">
<span class="pcd-toolbar-pill-label">GBP</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(toolbarSummary.gbpTotal) }}</span>
</div>
</div>
<div class="pcd-toolbar-summary-row">
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">USD Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.usdRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">EUR Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.eurRate) }}</span>
</div>
<div class="pcd-toolbar-pill pcd-toolbar-pill-neutral">
<span class="pcd-toolbar-pill-label">GBP Kur</span>
<span class="pcd-toolbar-pill-value">{{ formatMoney(exchangeRates.gbpRate) }}</span>
</div>
</div>
</div>
</div>
<div class="pcd-toolbar-actions">
<div class="pcd-toolbar-actions">
<div
v-if="last10WarningCount > 0"
class="pcd-toolbar-pill pcd-toolbar-pill-warn"
style="cursor:pointer;"
title="Son 10 ort. fiyata gore %10+ sapma var"
@click="last10WarningDialogOpen = true"
>
<span class="pcd-toolbar-pill-label">Fiyat Uyari</span>
<span class="pcd-toolbar-pill-value">{{ last10WarningCount }}</span>
</div>
<q-btn
flat
dense
color="grey-7"
class="pcd-toolbar-btn"
:label="summaryOpen ? 'OZET GIZLE' : 'OZET GOSTER'"
:icon="summaryOpen ? 'visibility_off' : 'visibility'"
@click="summaryOpen = !summaryOpen"
/>
<q-btn
flat
dense
@@ -114,6 +142,178 @@
Hata: {{ detailError }}
</q-banner>
<q-expansion-item
v-if="detailHeader && !detailLoading"
v-model="summaryOpen"
dense
expand-separator
class="pcd-summary-expansion q-mx-md q-mb-md"
icon="summarize"
label="Ozet"
>
<div class="row q-col-gutter-md q-pa-sm">
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Maliyetlere Islenen Toplam Tutar</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<tbody>
<tr>
<td class="pcd-summary-k">USD</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.usd) }}</td>
<td class="pcd-summary-k">TRY</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.try) }}</td>
</tr>
<tr>
<td class="pcd-summary-k">EUR</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.eur) }}</td>
<td class="pcd-summary-k">GBP</td>
<td class="pcd-summary-v">{{ formatMoney(mailSummary.headerTotals.gbp) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Iscilik Fiyatlari (CM2)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Giris</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
<th class="text-center">CMT/Malzemeli</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.laborRows" :key="'labor-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.inputAmountLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
<td class="text-center">{{ r.hasCmtOrMalzemeli ? '✓' : '' }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.laborRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
<td></td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Malzeme Fiyatlari (DT/TP, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.materialRows" :key="'mat-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.materialRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
<div class="col-12 col-lg-6">
<div class="pcd-summary-title">Kumas Fiyatlari (FABRIC, maliyete dahil)</div>
<q-markup-table dense flat bordered class="pcd-summary-table">
<thead>
<tr>
<th class="text-left">Parca</th>
<th class="text-right">Metraj</th>
<th class="text-right">MT Giris Fiyat</th>
<th class="text-left">Pr.Br.</th>
<th class="text-right">USD Tutar</th>
<th class="text-right">TRY Tutar</th>
</tr>
</thead>
<tbody>
<tr v-for="r in mailSummary.fabricRows" :key="'fab-'+r.part">
<td class="text-left">{{ r.part }}</td>
<td class="text-right">{{ r.meterLabel }}</td>
<td class="text-right">{{ r.inputUnitLabel }}</td>
<td class="text-left">{{ r.inputCurrencyLabel }}</td>
<td class="text-right">{{ formatMoney(r.usd) }}</td>
<td class="text-right">{{ formatMoney(r.try) }}</td>
</tr>
<tr class="pcd-summary-total-row">
<td class="text-left text-weight-bold">TOPLAM</td>
<td class="text-right text-weight-bold">
{{
(() => {
const total = mailSummary.fabricRows.reduce((a, x) => a + (Number(x.meterQty || 0) || 0), 0)
return total > 0 ? `${formatBarQuantity(total)} MT` : '-'
})()
}}
</td>
<td></td>
<td></td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.usd || 0), 0)) }}</td>
<td class="text-right text-weight-bold">{{ formatMoney(mailSummary.fabricRows.reduce((a, x) => a + (x.try || 0), 0)) }}</td>
</tr>
</tbody>
</q-markup-table>
</div>
</div>
</q-expansion-item>
<q-dialog v-model="last10WarningDialogOpen" persistent>
<q-card style="min-width: 860px; max-width: 92vw;">
<q-card-section class="row items-center">
<div class="text-h6">Son 10 Ort. Fiyat Sapmalari</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section style="max-height: 70vh; overflow: auto;">
<q-markup-table dense flat bordered>
<thead>
<tr>
<th class="text-left">Kod</th>
<th class="text-left">Doviz</th>
<th class="text-right">Giris</th>
<th class="text-right">Ort10</th>
<th class="text-right">Sapma %</th>
<th class="text-right">Sample</th>
<th class="text-left">Tarih Araligi</th>
</tr>
</thead>
<tbody>
<tr v-for="w in last10Warnings" :key="w.item_code + '|' + w.currency_code">
<td class="text-left">{{ w.item_code }}</td>
<td class="text-left">{{ w.currency_code }}</td>
<td class="text-right">{{ formatMoney(w.input_price) }}</td>
<td class="text-right">{{ formatMoney(w.avg_doc_price) }}</td>
<td class="text-right">{{ formatPercent(w.diff_ratio) }}</td>
<td class="text-right">{{ w.sample_count }}</td>
<td class="text-left">{{ (w.min_invoice_date || '-') + ' / ' + (w.max_invoice_date || '-') }}</td>
</tr>
<tr v-if="last10Warnings.length === 0">
<td colspan="7" class="text-center text-grey-7">Kayit yok</td>
</tr>
</tbody>
</q-markup-table>
</q-card-section>
</q-card>
</q-dialog>
<div v-if="detailHeader && !detailLoading && !headerInfoCollapsed" class="filter-bar pcd-detail-header-bar q-mx-md q-mb-md">
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-3">
@@ -189,7 +389,7 @@
<q-input dense filled readonly label="nUrtReceteID" :model-value="detailHeader.nUrtReceteID || '-'" />
</div>
<div v-if="partSummary && partSummary.length > 0" class="col-12">
<div v-if="false && partSummary && partSummary.length > 0" class="col-12">
<div class="pcd-part-summary-card">
<div class="pcd-part-summary-title">Parça Bazlı Maliyet Özellikleri</div>
<q-markup-table dense flat bordered separator="cell" class="pcd-part-summary-table">
@@ -386,7 +586,7 @@
<template #body-cell-sKodu="props">
<q-td
:props="props"
:class="resolveAutoOrICodeHighlightClass(props.row)"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
@@ -395,7 +595,7 @@
<template #body-cell-sAciklama="props">
<q-td
:props="props"
:class="resolveAutoOrICodeHighlightClass(props.row)"
:class="[resolveAutoOrICodeHighlightClass(props.row), resolveMissingStockCodeClass(props.row)]"
>
<span>{{ props.value }}</span>
</q-td>
@@ -880,6 +1080,7 @@ const rowEditorHammaddeLoading = ref(false)
const rowEditorItemOptions = ref([])
const rowEditorItemAllOptions = ref([])
const rowEditorItemLoading = ref(false)
const rowEditorLastValidItemValue = ref('')
const rowEditorColorOptions = ref([])
const rowEditorColorAllOptions = ref([])
const rowEditorColorLoading = ref(false)
@@ -894,7 +1095,89 @@ const lineHistoryTargetSummary = ref('')
const lineHistorySearchMode = ref('exact')
const lineHistoryLastPurchaseMatchStage = ref('')
const lineHistoryLastRecipeMatchStage = ref('')
// tbStok validation (missing stock cards)
// Manuel giris kapandigi icin tbStok exists-bulk kontrolunu devre disi biraktik.
// Eski UI/CSS hook'lari kalsin diye state'i tutuyoruz ama request atmayacagiz.
const missingTbStokCodesMap = ref({})
const tbStokValidationLastError = ref('')
const tbStokMissingDialogShown = ref(false)
let tbStokValidationTimer = null
function normalizeStockCodeKey (code) {
return String(code || '').trim().toUpperCase()
}
function openMissingTbStokDialog () {
const missing = Object.keys(missingTbStokCodesMap.value || {})
if (missing.length === 0) return
$q.dialog({
title: 'tbStok Eksik Kodlar',
message: `Asagidaki kodlar tbStok'ta yok. Stok kartlarini duzeltmeden maliyet kaydetmeyin:\n\n${missing.slice(0, 80).join(', ')}${missing.length > 80 ? `\n(+${missing.length - 80} daha)` : ''}`,
ok: { label: 'Tamam' }
})
}
function isTbStokMissingCode (code) {
code = normalizeStockCodeKey(code)
if (!code) return false
return Boolean(missingTbStokCodesMap.value?.[code])
}
function resolveMissingStockCodeClass (row) {
const code = normalizeStockCodeKey(row?.sKodu)
if (!code) return ''
if (isTbStokMissingCode(code)) return 'pcd-missing-stock-code'
return ''
}
function collectUniqueCodesFromRows () {
const out = []
const seen = new Set()
for (const r of flatDetailRows.value || []) {
const code = normalizeStockCodeKey(r?.sKodu)
if (!code) continue
if (seen.has(code)) continue
seen.add(code)
out.push(code)
}
return out
}
async function refreshTbStokMissingCodes () {
tbStokValidationLastError.value = ''
missingTbStokCodesMap.value = {}
}
function formatPercent (ratio) {
const n = Number(ratio)
if (!Number.isFinite(n)) return '-'
return `${(n * 100).toFixed(0)}%`
}
async function refreshLast10Warnings () {
if (!onMLNo.value) {
last10Warnings.value = []
return
}
try {
const resp = await get('/pricing/production-product-costing/last10-warnings', { n_onml_no: onMLNo.value })
const items = Array.isArray(resp?.items) ? resp.items : []
last10Warnings.value = items
} catch (err) {
// non-blocking
}
}
function scheduleTbStokValidation () {
// no-op (tbStok kontrolu devre disi)
if (tbStokValidationTimer) clearTimeout(tbStokValidationTimer)
tbStokValidationTimer = null
}
const purchaseAvgUSDCachedByCode = ref({})
const last10Warnings = ref([])
const last10WarningDialogOpen = ref(false)
const last10WarningCount = computed(() => (Array.isArray(last10Warnings.value) ? last10Warnings.value.length : 0))
const headerInfoCollapsed = ref(false)
const subHeaderTop = ref(140)
const stickyStackRef = ref(null)
@@ -1114,6 +1397,167 @@ const toolbarSummary = computed(() => flatDetailRows.value.reduce((acc, row) =>
gbpTotal: 0
}))
const summaryOpen = ref(false)
function makeEmptySummaryRow (part) {
return {
part,
inputAmountLabel: '-',
inputCurrencyLabel: '-',
inputUnitLabel: '-',
meterLabel: '-',
meterQty: 0,
meterUom: '',
usd: 0,
try: 0,
hasCmtOrMalzemeli: false
}
}
function accumulateInputAmount (bucket, row) {
const qty = resolveNumericRowQuantity(row)
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
const amount = (Number.isFinite(inputPrice) ? inputPrice : 0) * (Number.isFinite(qty) ? qty : 0)
if (!Number.isFinite(amount) || amount === 0) return
if (!bucket.inputByCur) bucket.inputByCur = {}
bucket.inputByCur[cur] = (bucket.inputByCur[cur] || 0) + amount
}
function accumulateUnitPrice (bucket, row) {
// For fabric summary: show a representative unit input price (first non-zero).
const inputPrice = resolveNumericRowInputPrice(row)
const cur = resolveInputCurrency(row) || 'USD'
if (!Number.isFinite(inputPrice) || inputPrice <= 0) return
if (bucket.unitPrice === undefined || bucket.unitPrice === null) {
bucket.unitPrice = inputPrice
bucket.unitCur = cur
}
}
function finalizeInputLabel (bucket) {
if (Number(bucket.meterQty || 0) > 0) {
const uom = String(bucket.meterUom || '').trim()
bucket.meterLabel = `${formatBarQuantity(bucket.meterQty)}${uom ? ' ' + uom : ''}`
} else {
bucket.meterLabel = bucket.meterLabel || '-'
}
const byCur = bucket.inputByCur || {}
const currencies = Object.keys(byCur).filter(Boolean)
if (currencies.length === 0) {
bucket.inputAmountLabel = '-'
bucket.inputCurrencyLabel = '-'
bucket.inputUnitLabel = bucket.inputUnitLabel || '-'
return bucket
}
if (currencies.length === 1) {
const cur = currencies[0]
bucket.inputCurrencyLabel = cur
bucket.inputAmountLabel = formatMoney(byCur[cur] || 0)
if (bucket.unitPrice !== undefined && bucket.unitCur) {
bucket.inputUnitLabel = formatMoney(bucket.unitPrice)
bucket.inputCurrencyLabel = String(bucket.unitCur || cur).toUpperCase()
}
return bucket
}
// Multiple currencies mixed: keep labels short.
bucket.inputCurrencyLabel = 'MIX'
bucket.inputAmountLabel = formatMoney(currencies.reduce((acc, c) => acc + (byCur[c] || 0), 0))
return bucket
}
const mailSummary = computed(() => {
// Parts must be dynamic. Use sParcaAdi coming from backend (spUrtMTBolum.sAdi),
// and keep a stable order: common parts first, then the rest by first appearance.
const preferredParts = ['CEKET', 'PANTOLON', 'YELEK', 'AKSESUAR', 'YAKA']
const seenParts = new Set()
const dynamicParts = []
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part) return
if (seenParts.has(part)) return
seenParts.add(part)
dynamicParts.push(part)
})
const parts = [
...preferredParts.filter(p => seenParts.has(p)),
...dynamicParts.filter(p => !preferredParts.includes(p))
]
const base = {
headerTotals: {
usd: toolbarSummary.value.usdTotal || 0,
try: toolbarSummary.value.tryTotal || 0,
eur: toolbarSummary.value.eurTotal || 0,
gbp: toolbarSummary.value.gbpTotal || 0
},
laborByPart: {},
materialByPart: {},
fabricByPart: {}
}
parts.forEach((p) => {
base.laborByPart[p] = makeEmptySummaryRow(p)
base.materialByPart[p] = makeEmptySummaryRow(p)
base.fabricByPart[p] = makeEmptySummaryRow(p)
})
flatDetailRows.value.forEach((row) => {
const partRaw = normalizeGroupName(row?.sParcaAdi)
const part = String(partRaw || '').trim().toUpperCase()
if (!part || !parts.includes(part)) return
const group = String(normalizeGroupName(row?.sAciklama3)).trim().toUpperCase()
const included = Boolean(row?.maliyeteDahil) || isCMGroupName(group)
if (!included) return
const usdAmount = resolveRowUSDTutar(row)
const tryAmount = resolveRowTRYTutar(row)
if (isCMGroupName(group)) {
const b = base.laborByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
// Tick should reflect actual checkbox state (only when user selected "malzemeli"/type=2).
if (resolveCMPriceTypeChecked(row)) b.hasCmtOrMalzemeli = true
return
}
if (group === 'DT' || group.includes(' DT') || group === 'TP' || group.includes(' TP')) {
const b = base.materialByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
return
}
if (group === 'FABRIC' || group.includes('FABRIC')) {
const b = base.fabricByPart[part]
b.usd += usdAmount
b.try += tryAmount
accumulateInputAmount(b, row)
accumulateUnitPrice(b, row)
const qty = resolveNumericRowQuantity(row)
if (Number.isFinite(qty) && qty > 0) {
b.meterQty += qty
if (!b.meterUom) b.meterUom = String(row?.sBirim || '').trim()
}
}
})
const laborRows = parts.map((p) => finalizeInputLabel(base.laborByPart[p]))
const materialRows = parts.map((p) => finalizeInputLabel(base.materialByPart[p]))
const fabricRows = parts.map((p) => finalizeInputLabel(base.fabricByPart[p]))
return {
headerTotals: base.headerTotals,
laborRows,
materialRows,
fabricRows
}
})
const partSummary = computed(() => {
const summary = {}
flatDetailRows.value.forEach(row => {
@@ -1129,25 +1573,27 @@ const partSummary = computed(() => {
})
return Object.entries(summary).map(([name, totals]) => ({ name, ...totals }))
})
const lineHistoryColumns = [
{ name: 'sourceLabel', label: 'Kaynak', field: 'sourceLabel', align: 'left', sortable: false, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'dateLabel', label: 'Tarih', field: 'dateLabel', align: 'left', sortable: true, style: 'width:7%', headerStyle: 'width:7%' },
{ name: 'invoiceCode', label: 'Fatura/OnML', field: 'invoiceCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'companyCode', label: 'Firma Kodu', field: 'companyCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'companyDescription', label: 'Firma Aciklama', field: 'companyDescription', align: 'left', sortable: true, style: 'width:12%', headerStyle: 'width:12%' },
{ name: 'itemCode', label: 'Masraf/sKodu', field: 'itemCode', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'itemDescription', label: 'Masraf Detay', field: 'itemDescription', align: 'left', sortable: true, style: 'width:11%', headerStyle: 'width:11%' },
{ name: 'colorCode', label: 'Renk', field: 'colorCode', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'colorDescription', label: 'Renk Aciklama', field: 'colorDescription', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'itemDim1Code', label: 'Dim1', field: 'itemDim1Code', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'itemDim1Description', label: 'Dim1 Aciklama', field: 'itemDim1Description', align: 'left', sortable: true, style: 'width:8%', headerStyle: 'width:8%' },
{ name: 'quantity', label: 'Miktar', field: 'quantity', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'unit', label: 'Birim', field: 'unit', align: 'left', sortable: true, style: 'width:4%', headerStyle: 'width:4%' },
{ name: 'price', label: 'Fiyat', field: 'price', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'amount', label: 'Tutar', field: 'amount', align: 'right', sortable: true, style: 'width:6%', headerStyle: 'width:6%' },
{ name: 'currency', label: 'Pr. Br.', field: 'currency', align: 'left', sortable: true, style: 'width:5%', headerStyle: 'width:5%' },
{ name: 'select', label: '', field: 'select', align: 'right', sortable: false, style: 'width:6%', headerStyle: 'width:6%' }
]
const lineHistoryColumns = [
// Not: fixed percentage widths were overflowing at 100% zoom.
// Let the table layout decide widths so the "Sec" column stays visible.
{ name: 'sourceLabel', label: 'Kaynak', field: 'sourceLabel', align: 'left', sortable: false },
{ name: 'dateLabel', label: 'Tarih', field: 'dateLabel', align: 'left', sortable: true },
{ name: 'invoiceCode', label: 'Fatura/OnML', field: 'invoiceCode', align: 'left', sortable: true },
{ name: 'companyCode', label: 'Firma Kodu', field: 'companyCode', align: 'left', sortable: true },
{ name: 'companyDescription', label: 'Firma Aciklama', field: 'companyDescription', align: 'left', sortable: true },
{ name: 'itemCode', label: 'Masraf/sKodu', field: 'itemCode', align: 'left', sortable: true },
{ name: 'itemDescription', label: 'Masraf Detay', field: 'itemDescription', align: 'left', sortable: true },
{ name: 'colorCode', label: 'Renk', field: 'colorCode', align: 'left', sortable: true },
{ name: 'colorDescription', label: 'Renk Aciklama', field: 'colorDescription', align: 'left', sortable: true },
{ name: 'itemDim1Code', label: 'Dim1', field: 'itemDim1Code', align: 'left', sortable: true },
{ name: 'itemDim1Description', label: 'Dim1 Aciklama', field: 'itemDim1Description', align: 'left', sortable: true },
{ name: 'quantity', label: 'Miktar', field: 'quantity', align: 'right', sortable: true },
{ name: 'unit', label: 'Birim', field: 'unit', align: 'left', sortable: true },
{ name: 'price', label: 'Fiyat', field: 'price', align: 'right', sortable: true },
{ name: 'amount', label: 'Tutar', field: 'amount', align: 'right', sortable: true },
{ name: 'currency', label: 'Pr. Br.', field: 'currency', align: 'left', sortable: true },
{ name: 'select', label: '', field: 'select', align: 'right', sortable: false }
]
function resolveLineHistoryRowClass (row) {
if (row?.priceType === 'BNZ') return 'pcd-history-row-similar'
@@ -1166,7 +1612,7 @@ const detailColumns = [
{ name: 'sRenk', label: 'Renk', field: 'sRenk', align: 'left', sortable: true },
{ name: 'lMiktar', label: 'Miktar', field: 'lMiktar', align: 'right', sortable: true, format: val => formatQuantity(val), style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPrice', label: 'Fiyat Giriş', field: 'inputPrice', align: 'right', sortable: false, style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPricePrBr', label: 'Fiyat Giriş Pr.Br.', field: 'inputPricePrBr', align: 'left', sortable: false, style: 'width: 80px', headerStyle: 'width: 80px' },
{ name: 'inputPricePrBr', label: 'Fiyat Giriş Pr.Br.', field: 'inputPricePrBr', align: 'left', sortable: false, style: 'width: 92px', headerStyle: 'width: 92px' },
{ name: 'maliyeteDahil', label: 'Maliyete Dahil', field: 'maliyeteDahil', align: 'center', sortable: false },
{ name: 'cmPriceType', label: 'CMT', field: 'cm_price_type_id', align: 'center', sortable: false, style: 'width: 72px', headerStyle: 'width: 72px' },
{ name: 'lFiyat', label: 'lFiyat', field: 'lFiyat', align: 'right', sortable: true, format: val => formatMoney(val) },
@@ -1290,14 +1736,22 @@ function formatQuantity (value) {
})
}
function formatQuantity2 (value) {
return parseMoneyInput(value).toLocaleString('tr-TR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function formatBarMoney (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatMoney(roundedValue)
}
function formatBarQuantity (value) {
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000
return formatQuantity(roundedValue)
// Bar/summary quantities: show 2 decimals (requested for MT totals).
const roundedValue = Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100
return formatQuantity2(roundedValue)
}
function convertPriceToUSD (price, currency) {
@@ -1630,8 +2084,15 @@ async function loadRowEditorColorOptions () {
function primeRowEditorOptionsFromForm () {
upsertEditorOption(rowEditorHammaddeAllOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
upsertEditorOption(rowEditorHammaddeOptions, buildRowEditorHammaddeOption(rowEditorForm.value))
upsertEditorOption(rowEditorItemAllOptions, buildRowEditorItemOption(rowEditorForm.value))
upsertEditorOption(rowEditorItemOptions, buildRowEditorItemOption(rowEditorForm.value))
// Do not inject invalid free-typed values (e.g. "GAMBOÇ") into the item select options.
// Item options must represent real tbStok model codes (usually like "M.X", "K.X", "I.X").
const itemCandidate = buildRowEditorItemOption(rowEditorForm.value)
const itemKey = String(itemCandidate?.value || '').trim()
const looksLikeModel = /^[A-Za-z0-9]\./.test(itemKey)
if (looksLikeModel) {
upsertEditorOption(rowEditorItemAllOptions, itemCandidate)
upsertEditorOption(rowEditorItemOptions, itemCandidate)
}
upsertEditorOption(rowEditorColorAllOptions, buildRowEditorColorOption(rowEditorForm.value))
upsertEditorOption(rowEditorColorOptions, buildRowEditorColorOption(rowEditorForm.value))
}
@@ -2402,7 +2863,8 @@ async function fetchDetail (options = {}) {
])
detailHeader.value = headerData && typeof headerData === 'object' ? headerData : null
productionTypes.value = Array.isArray(typesData) ? typesData : []
costDate.value = normalizeDateInput(detailHeader.value?.dteKayitTarihi)
// Prefer true costing date (spUrtOnMLMas.Tarihi) over record create/update timestamps.
costDate.value = normalizeDateInput(detailHeader.value?.maliyetTarihi || detailHeader.value?.dteKayitTarihi)
detailGroups.value = normalizeDetailGroups(groupsData)
initialHeaderSnapshot.value = currentHeaderSnapshot.value
// Optional: hydrate local draft after base data load.
@@ -2437,7 +2899,11 @@ async function fetchDetail (options = {}) {
urun_kodu: detailHeader.value?.UrunKodu || productCode.value,
n_urt_recete_id: detailHeader.value?.nUrtReceteID || ''
})
} catch (err) {
// tbStok exists-bulk kontrolu devre disi (manual giris kapali).
// Load last10 avg deviation warnings panel (non-blocking).
await refreshLast10Warnings()
// (eski tbStok missing dialog/notify kaldirildi)
} catch (err) {
detailError.value = await extractApiErrorDetail(err)
slog.error('production-product-costing.detail', 'fetch-detail:error', {
trace_id: traceId.value,
@@ -2908,6 +3374,8 @@ function resolveDetailRowClass (row) {
const key = String(row?.__rowKey || '').trim()
if (key && requiredAttentionRowKeys.value?.[key]) return 'pcd-detail-row-required'
if (row?.requiredPlaceholder) return 'pcd-detail-row-required'
const code = String(row?.sKodu || '').trim()
if (code && isTbStokMissingCode(code)) return 'pcd-detail-row-missing-stock'
return row?.draftChanged ? 'pcd-detail-row-secondary' : ''
}
@@ -3011,8 +3479,27 @@ async function applyFabricCopySelection () {
async function onRowEditorItemChange (value) {
const selected = rowEditorItemOptions.value.find(opt => String(opt?.value || '') === String(value || ''))
// Prevent free-typed values from being accepted as "code". User must pick from list.
if (!selected) {
const typed = String(value || '').trim()
// If user cleared the field, allow clearing (other validations will catch blank-on-save if needed).
if (!typed) {
rowEditorForm.value.sKodu = ''
rowEditorLastValidItemValue.value = ''
return
}
// Revert to last valid selection.
rowEditorForm.value.sKodu = String(rowEditorLastValidItemValue.value || '').trim()
$q.notify({
type: 'negative',
message: 'Kod secilmeden serbest metin girilemez. Listeden secim yapin.',
position: 'top-right'
})
return
}
rowEditorForm.value.sKodu = String(value || '').trim()
if (!selected) return
rowEditorLastValidItemValue.value = String(value || '').trim()
const previousColorCode = String(rowEditorForm.value.ColorCode || '').trim()
rowEditorForm.value.nStokID = String(selected.nStokID || '').trim()
rowEditorForm.value.sModel = String(selected.sModel || '').trim()
@@ -3129,7 +3616,8 @@ function applyEditorRowToGroups (nextRow) {
detailGroups.value = sortDetailGroups(nextGroups)
syncAllGroupsOpen()
schedulePersistLocalDraft()
}
// tbStok validation devre disi
}
function syncAllGroupsOpen () {
const openState = {}
@@ -3704,6 +4192,7 @@ function openNewRowDialog () {
maliyeteDahil: true,
sBirim: 'AD'
})
rowEditorLastValidItemValue.value = ''
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
@@ -3716,6 +4205,7 @@ function openRowEditorForEdit (row) {
...row,
sAciklama3: row?.sAciklama3 || ''
})
rowEditorLastValidItemValue.value = String(rowEditorForm.value?.sKodu || '').trim()
primeRowEditorOptionsFromForm()
rowEditorDialogOpen.value = true
void bootstrapRowEditorOptions()
@@ -4113,6 +4603,7 @@ async function saveChanges () {
saveLoading.value = true
try {
requiredAttentionRowKeys.value = {}
// tbStok exists-bulk kontrolu devre disi (manual giris kapali): save bloke edilmez.
if (isNoCostDetail.value) {
const missing = computeMissingRequiredSlots()
slog.info('production-product-costing.detail', 'required:missing:computed', {
@@ -4319,6 +4810,10 @@ async function saveChanges () {
// For existing costing, just refresh the detail.
await fetchDetail({ clearDraft: true, hydrateDraft: false })
// last10 warnings are computed async on backend; re-check shortly after save.
window.setTimeout(() => {
try { refreshLast10Warnings() } catch {}
}, 1200)
} catch (e) {
// Surface backend message (http.Error text) when available.
const msg = String(
@@ -4485,14 +4980,22 @@ watch(
white-space: nowrap;
}
.pcd-toolbar-summary {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.pcd-toolbar-summary {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.pcd-toolbar-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.pcd-toolbar-pill {
display: flex;
@@ -4511,9 +5014,67 @@ watch(
color: #ffffff;
}
.pcd-toolbar-pill-neutral {
background: #f5f7fa;
.pcd-toolbar-pill-neutral {
background: #f5f7fa;
color: #2b3c54;
}
.pcd-toolbar-pill-warn {
background: #ef6c00;
border-color: #ef6c00;
color: #ffffff;
}
.pcd-summary-expansion :deep(.q-expansion-item__container) {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
background: #fff;
}
.pcd-missing-stock-code {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-missing-stock-code :deep(*) {
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td {
background: #c62828 !important;
color: #ffffff !important;
}
.pcd-detail-row-missing-stock td :deep(*) {
color: #ffffff !important;
}
.pcd-summary-title {
font-weight: 800;
font-size: 12px;
color: #2b3c54;
margin: 2px 0 6px;
}
.pcd-summary-table td,
.pcd-summary-table th {
font-size: 12px;
line-height: 1.2;
}
.pcd-summary-total-row td {
border-top: 2px solid rgba(0, 0, 0, 0.15);
background: #fbfbfd;
}
.pcd-summary-k {
width: 60px;
color: #6b7680;
font-weight: 800;
}
.pcd-summary-v {
font-weight: 800;
}
.pcd-toolbar-pill-label {

View File

@@ -191,7 +191,8 @@ import api from 'src/services/api'
import { usePermission } from 'src/composables/usePermission'
const { canUpdate } = usePermission()
const canUpdateUser = canUpdate('user')
// This screen manages system-wide permission sets; gate by system:update.
const canUpdateUser = canUpdate('system')
const route = useRoute()
const router = useRouter()
@@ -394,13 +395,22 @@ async function save () {
const payload = []
// UI action keys -> backend action codes
const toBackendAction = {
write: 'insert',
read: 'view',
delete: 'delete',
update: 'update',
export: 'export'
}
rows.value.forEach(r => {
actions.forEach(a => {
payload.push({
module: r.module,
action: a.key,
action: toBackendAction[a.key] || a.key,
allowed: r[a.key]
})
})