Compare commits

..

9 Commits

Author SHA1 Message Date
xiaojibeier c635860826 0010 2026-07-07 00:12:27 +08:00
xiaojibeier b575b9c193 111 2026-07-06 19:57:01 +08:00
xiaojibeier f5206a2005 2020706005 2026-07-06 19:26:03 +08:00
xiaojibeier dd59b3aab5 Track .env files in git for personal deployment sync.
Allow VITE_AMAP_KEY and related config to follow the repo; only .env.local remains ignored.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:18:31 +08:00
xiaojibeier 0deccf70a8 20260706003 2026-07-06 17:14:44 +08:00
xiaojibeier a602334039 20260706002 2026-07-06 17:05:09 +08:00
xiaojibeier fcf6aa2df6 20260706001 2026-07-06 12:46:54 +08:00
xiaojibeier 67d1da71a8 2 2026-07-06 03:48:13 +08:00
xiaojibeier 968c4968d2 ndk delete 2026-07-06 03:43:40 +08:00
34 changed files with 7742 additions and 1281 deletions
+6
View File
@@ -0,0 +1,6 @@
# 所有环境都会加载的配置
VITE_APP_TITLE=Vue3 Admin
# 高德 Web JS APIbuild 时打入前端包;dev/prod 共用)
VITE_AMAP_KEY=444de64142b2408b4e37e841244db8a4
VITE_AMAP_SECURITY_CODE=2893df04d1b639c422c9070994100cce
+4
View File
@@ -0,0 +1,4 @@
# 开发环境配置
NODE_ENV=development
VITE_APP_ENV=development
# 高德 Key 见根目录 .env(与生产 build 共用,避免漏打)
+7
View File
@@ -0,0 +1,7 @@
# 复制为 .env 后填写(.env 已被 gitignore,勿提交真实 Key
VITE_APP_TITLE=Vue3 Admin
# 高德开放平台 → Web 端 JS API
VITE_AMAP_KEY=
VITE_AMAP_SECURITY_CODE=
+7
View File
@@ -0,0 +1,7 @@
# 生产环境配置
VITE_APP_ENV=production
VITE_APP_BASE_URL=/api
# 高德 Web JS API Key(地图选点,npm run build 时打入 dist
VITE_AMAP_KEY=444de64142b2408b4e37e841244db8a4
VITE_AMAP_SECURITY_CODE=2893df04d1b639c422c9070994100cce
+4 -4
View File
@@ -7,10 +7,10 @@ dist-ssr/
build/
*.tsbuildinfo
# ── 环境变量(含密钥,勿提交)──────────────────────
.env
.env.*
!.env.example
# ── 环境变量 ───────────────────────────────────────
# 个人项目:.env 随仓库同步;仅忽略本地临时覆盖
.env.local
.env*.local
# ── 日志 ──────────────────────────────────────────
logs/
-1
View File
@@ -1 +0,0 @@
SDK Manager Tools
+3865
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -10,6 +10,38 @@ npm run dev
npm run build
```
## 构建号 `buildId.json`
路径:[`src/config/buildId.json`](src/config/buildId.json)
由 [`scripts/bump-build-id.mjs`](scripts/bump-build-id.mjs) 在 **`npm run build` 前**`prebuild`)自动生成;`npm run dev` 不会改这个文件。
| 字段 | 说明 |
|------|------|
| `buildId` | 11 位构建号:`YYYYMMDD`(8 位)+ 当天第几次构建(3 位,001~999),如 `20260706004` |
| `generatedAt` | 生成时间(ISO |
侧栏「更新说明」弹窗底部会显示 `版本 · build 构建号`(见 [`src/layouts/index.vue`](src/layouts/index.vue))。与 [`src/data/appChangelog.js`](src/data/appChangelog.js) 里的 `APP_VERSION` 无关:版本号手动维护,构建号每次正式 build 自动递增。
### `git pull` 提示本地修改会被覆盖
本地执行过 `npm run build` 后,`buildId.json` 常有未提交改动;若远程也更新了同一文件,`git pull` 会中止并提示:
```text
Your local changes to the following files would be overwritten by merge:
src/config/buildId.json
Please commit your changes or stash them before you merge.
```
**一般不需要保留或提交这次本地改动**,丢弃后拉取即可(拉完下次 build 会重新生成):
```sh
git restore src/config/buildId.json
git pull
```
若确需暂存本地改动:`git stash``git pull``git stash pop`
## 变更记录
- **应用内「更新说明」弹层:** [`src/data/appChangelog.js`](src/data/appChangelog.js)
+579 -54
View File
@@ -1,4 +1,5 @@
import request from '@/utils/request'
import { pickGoodsNameFromListedRow } from '@/utils/goodsCategorys'
function unwrapListed(res) {
const data = res?.data
@@ -22,8 +23,8 @@ function unwrapListed(res) {
export function pickCustomerPriceCode(row) {
if (!row || typeof row !== 'object') return ''
const candidates = [
row.price_code,
row.customer_price_code,
row.price_code,
row.price_book_code,
row.priceCode,
row.code,
@@ -45,6 +46,362 @@ function flattenPriceListedRow(raw) {
return raw
}
function toPriceNum(v, fallback = 0) {
if (v == null || v === '') return fallback
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
function parseJsonObject(raw) {
if (raw == null || raw === '') return null
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
if (typeof raw === 'string') {
const text = raw.trim()
if (!text) return null
try {
const parsed = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
return null
}
/** @returns {{ rate: number, amount: number }} */
export function createEmptyExcessPerformance() {
return { rate: 0, amount: 0 }
}
/** @returns {{ rate: number, fee: number }} */
export function createEmptyBenefit() {
return { rate: 0, fee: 0 }
}
/** 超额绩效 JSON{ rate, amount } 或 { [bill_code]: { rate, amount } } */
function isNestedMetricEntry(value) {
return value && typeof value === 'object' && !Array.isArray(value)
&& ('rate' in value || 'amount' in value || 'fee' in value)
}
/** @returns {Array<{ bill_code: string, rate: number, amount: number }>} */
export function parseExcessPerformanceGroups(src) {
const row = src && typeof src === 'object' ? src : {}
const json = parseJsonObject(row.excess_performance)
if (json) {
if (isNestedMetricEntry(json) && !Object.keys(json).some((k) => isNestedMetricEntry(json[k]))) {
return [{
bill_code: '',
rate: toPriceNum(json.rate),
amount: toPriceNum(json.amount),
}]
}
const groups = Object.entries(json)
.filter(([, val]) => isNestedMetricEntry(val))
.map(([bill_code, val]) => ({
bill_code: String(bill_code || '').trim(),
rate: toPriceNum(val.rate),
amount: toPriceNum(val.amount),
}))
if (groups.length) return groups
}
if (row.excess_performance_rate != null || row.excess_performance_amount != null) {
return [{
bill_code: '',
rate: toPriceNum(row.excess_performance_rate),
amount: toPriceNum(row.excess_performance_amount),
}]
}
return []
}
/** @returns {Array<{ bill_code: string, rate: number, fee: number }>} */
export function parseBenefitGroups(src) {
const row = src && typeof src === 'object' ? src : {}
const json = parseJsonObject(row.benefit)
if (json) {
if (isNestedMetricEntry(json) && !Object.keys(json).some((k) => isNestedMetricEntry(json[k]))) {
return [{
bill_code: '',
rate: toPriceNum(json.rate),
fee: toPriceNum(json.fee ?? json.amount),
}]
}
const groups = Object.entries(json)
.filter(([, val]) => isNestedMetricEntry(val))
.map(([bill_code, val]) => ({
bill_code: String(bill_code || '').trim(),
rate: toPriceNum(val.rate),
fee: toPriceNum(val.fee ?? val.amount),
}))
if (groups.length) return groups
}
if (row.benefit_rate != null || row.benefit_fee != null) {
return [{
bill_code: '',
rate: toPriceNum(row.benefit_rate),
fee: toPriceNum(row.benefit_fee),
}]
}
return []
}
/** 列表展示:0.05 → 5% */
export function formatCustomerPriceRate(rate) {
if (rate == null || rate === '') return '—'
const n = Number(rate)
if (!Number.isFinite(n)) return '—'
const pct = n !== 0 && Math.abs(n) <= 1 ? n * 100 : n
const text = Number.isInteger(pct)
? String(pct)
: String(Number(pct.toFixed(4))).replace(/\.?0+$/, '')
return `${text}%`
}
export function formatCustomerPriceMetricAmount(v) {
if (v == null || v === '') return '—'
const n = Number(v)
return Number.isFinite(n) ? String(n) : '—'
}
/** 展示用:金额 + 元 */
export function formatCustomerPriceAmount(v) {
const text = formatCustomerPriceMetricAmount(v)
return text === '—' ? '—' : `${text}`
}
/** 展示用:月数 + 月 */
export function formatCustomerPriceMonths(v) {
const text = formatCustomerPriceMetricAmount(v)
return text === '—' ? '—' : `${text}`
}
/** @param {{ rate?: number, amount?: number, fee?: number }} group */
export function formatCustomerPriceMetricPair(group, amountKey = 'amount') {
if (!group) return '—'
const amt = group[amountKey]
const rateText = formatCustomerPriceRate(group.rate)
const amtText = formatCustomerPriceAmount(amt)
if (rateText === '—' && amtText === '—') return '—'
return `${rateText} / ${amtText}`
}
function metricGroupSignature(group, amountKey) {
return `${group?.rate ?? ''}|${group?.[amountKey] ?? ''}`
}
/**
* 列表单元格:摘要 + 多种悬停
* @param {Array<{ bill_code?: string, rate?: number }>} groups
* @param {{ amountKey?: string, billNameMap?: Record<string, string> }} [opts]
*/
export function summarizeCustomerPriceMetricGroups(groups, opts = {}) {
const amountKey = opts.amountKey || 'amount'
const billNameMap = opts.billNameMap || {}
const list = Array.isArray(groups) ? groups : []
if (!list.length) {
return { display: '—', multi: false, count: 0, tooltip: '' }
}
const fmt = (g) => formatCustomerPriceMetricPair(g, amountKey)
const pickBillLabel = (code) => {
const c = String(code || '').trim()
if (!c) return '默认'
return billNameMap[c] || c
}
if (list.length === 1) {
return { display: fmt(list[0]), multi: false, count: 1, tooltip: '' }
}
const sig0 = metricGroupSignature(list[0], amountKey)
const allSame = list.every((g) => metricGroupSignature(g, amountKey) === sig0)
if (allSame) {
return { display: fmt(list[0]), multi: false, count: list.length, tooltip: '' }
}
const tooltip = list
.map((g) => `${pickBillLabel(g.bill_code)}${fmt(g)}`)
.join('\n')
return {
display: fmt(list[0]),
multi: true,
count: list.length,
tooltip,
}
}
/** 超额绩效 JSON{ rate, amount } */
export function normalizeExcessPerformance(raw) {
const json = parseJsonObject(raw)
if (json) {
const groups = parseExcessPerformanceGroups({ excess_performance: json })
if (groups.length) return { rate: groups[0].rate, amount: groups[0].amount }
}
return createEmptyExcessPerformance()
}
/** 让利 JSON{ rate, fee } */
export function normalizeBenefit(raw) {
const json = parseJsonObject(raw)
if (json) {
const groups = parseBenefitGroups({ benefit: json })
if (groups.length) return { rate: groups[0].rate, fee: groups[0].fee }
}
return createEmptyBenefit()
}
function normalizeExcessPerformanceFromRow(row) {
const groups = parseExcessPerformanceGroups(row)
if (!groups.length) return createEmptyExcessPerformance()
return { rate: groups[0].rate, amount: groups[0].amount }
}
function normalizeBenefitFromRow(row) {
const groups = parseBenefitGroups(row)
if (!groups.length) return createEmptyBenefit()
return { rate: groups[0].rate, fee: groups[0].fee }
}
/** @returns {{ rate: number, amount: number }} */
export function createEmptyExcessPerformanceBucket() {
return { rate: 0, amount: 0 }
}
/** @returns {{ rate: number, fee: number }} */
export function createEmptyBenefitBucket() {
return { rate: 0, fee: 0 }
}
function groupsToPerformanceBuckets(groups) {
/** @type {Record<string, { rate: number, amount: number }>} */
const buckets = {}
for (const g of groups || []) {
const code = String(g.bill_code ?? '').trim()
buckets[code] = {
rate: toPriceNum(g.rate),
amount: toPriceNum(g.amount),
}
}
if (!Object.keys(buckets).length) {
buckets[''] = createEmptyExcessPerformanceBucket()
}
return buckets
}
function groupsToBenefitBuckets(groups) {
/** @type {Record<string, { rate: number, fee: number }>} */
const buckets = {}
for (const g of groups || []) {
const code = String(g.bill_code ?? '').trim()
buckets[code] = {
rate: toPriceNum(g.rate),
fee: toPriceNum(g.fee),
}
}
if (!Object.keys(buckets).length) {
buckets[''] = createEmptyBenefitBucket()
}
return buckets
}
function mergeCustomerPriceBucketKeys(excessBuckets, benefitBuckets) {
const keys = new Set([
...Object.keys(excessBuckets || {}),
...Object.keys(benefitBuckets || {}),
])
for (const key of keys) {
if (!excessBuckets[key]) excessBuckets[key] = createEmptyExcessPerformanceBucket()
if (!benefitBuckets[key]) benefitBuckets[key] = createEmptyBenefitBucket()
}
}
/** 编辑 Tab:仅 JSON 里实际存在的分桶 key(空 key 表示单组,不出 Tab) */
export function listCustomerPriceJsonBucketKeys(excessBuckets, benefitBuckets) {
const ordered = []
const seen = new Set()
const add = (raw) => {
const key = String(raw ?? '').trim()
if (seen.has(key)) return
seen.add(key)
ordered.push(key)
}
for (const k of Object.keys(excessBuckets || {})) add(k)
for (const k of Object.keys(benefitBuckets || {})) add(k)
const named = ordered.filter((k) => k !== '')
return named.length ? named : []
}
/** 编辑保存:buckets → 接口 JSON(有命名 key 则分桶,否则 { rate, amount } */
export function serializeExcessPerformanceBuckets(buckets) {
const src = buckets && typeof buckets === 'object' ? buckets : {}
const namedKeys = Object.keys(src).filter((k) => String(k).trim() !== '')
if (!namedKeys.length) {
const b = src[''] || createEmptyExcessPerformanceBucket()
return { rate: toPriceNum(b.rate), amount: toPriceNum(b.amount) }
}
/** @type {Record<string, { rate: number, amount: number }>} */
const out = {}
for (const code of namedKeys) {
const b = src[code] || createEmptyExcessPerformanceBucket()
out[code] = { rate: toPriceNum(b.rate), amount: toPriceNum(b.amount) }
}
return out
}
/** 编辑保存:buckets → 接口 JSON(有命名 key 则分桶,否则 { rate, fee } */
export function serializeBenefitBuckets(buckets) {
const src = buckets && typeof buckets === 'object' ? buckets : {}
const namedKeys = Object.keys(src).filter((k) => String(k).trim() !== '')
if (!namedKeys.length) {
const b = src[''] || createEmptyBenefitBucket()
return { rate: toPriceNum(b.rate), fee: toPriceNum(b.fee) }
}
/** @type {Record<string, { rate: number, fee: number }>} */
const out = {}
for (const code of namedKeys) {
const b = src[code] || createEmptyBenefitBucket()
out[code] = { rate: toPriceNum(b.rate), fee: toPriceNum(b.fee) }
}
return out
}
/** 表单 → 新增/修改请求体 */
export function buildCustomerPriceWriteBody(form, extras = {}) {
const excess_performance = serializeExcessPerformanceBuckets(form?.excess_performance_buckets)
const benefit = serializeBenefitBuckets(form?.benefit_buckets)
const body = {
is_support_acceptance: !!form?.is_support_acceptance,
rebate_delay_base_months: toPriceNum(form?.rebate_delay_base_months),
excess_performance,
excess_profit_rate: toPriceNum(form?.excess_profit_rate),
excess_profit_amount: toPriceNum(form?.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(form?.habitual_bargain_ratio),
benefit,
is_special_price: !!form?.is_special_price,
is_valid: form?.is_valid == null ? true : !!form?.is_valid,
}
if (extras.sales_policy_type != null) {
body.sales_policy_type = toPriceNum(extras.sales_policy_type)
} else if (form?.sales_policy_type != null) {
body.sales_policy_type = toPriceNum(form.sales_policy_type)
}
if (extras.pos != null) body.pos = toPriceNum(extras.pos)
else if (form?.pos != null) body.pos = toPriceNum(form.pos)
const pc = String(
extras.customer_price_code ?? extras.price_code
?? form?.customer_price_code ?? form?.price_code ?? '',
).trim()
const cc = String(extras.company_code ?? form?.company_code ?? '').trim()
const gc = String(extras.goods_code ?? form?.goods_code ?? '').trim()
if (pc) body.customer_price_code = pc
if (cc) body.company_code = cc
if (gc) body.goods_code = gc
return body
}
/** POST /api/customer/price/listed */
export async function customerPriceListed(body = {}) {
const payload = { ...(body || {}) }
@@ -71,23 +428,19 @@ export function customerPriceBatchAdd(body) {
export function buildCustomerPriceAddItem(company_code, goods_code, overrides = {}) {
const cc = String(company_code ?? '').trim()
const gc = String(goods_code ?? '').trim()
return {
company_code: cc,
goods_code: gc,
sales_policy_type: 0,
is_support_acceptance: false,
rebate_delay_base_months: 0,
excess_performance_rate: 0,
excess_performance_amount: 0,
excess_profit_rate: 0,
excess_profit_amount: 0,
habitual_bargain_ratio: 0,
benefit_rate: 0,
benefit_fee: 0,
is_special_price: false,
is_valid: true,
...(overrides && typeof overrides === 'object' ? overrides : {}),
}
const o = overrides && typeof overrides === 'object' ? overrides : {}
return buildCustomerPriceWriteBody(
{
excess_performance_buckets: groupsToPerformanceBuckets([]),
benefit_buckets: groupsToBenefitBuckets([]),
...o,
},
{
company_code: cc,
goods_code: gc,
sales_policy_type: 0,
},
)
}
/**
@@ -146,49 +499,32 @@ export function customerPriceBatchModify(body) {
return request.post('/api/customer/price/batch_modify', body)
}
export function buildCustomerPriceModifyPayload(form, { price_code, company_code, goods_code } = {}) {
const pc = String(price_code ?? '').trim()
const body = {
is_support_acceptance: !!form?.is_support_acceptance,
rebate_delay_base_months: Number(form?.rebate_delay_base_months) || 0,
excess_performance_rate: Number(form?.excess_performance_rate) || 0,
excess_performance_amount: Number(form?.excess_performance_amount) || 0,
excess_profit_rate: Number(form?.excess_profit_rate) || 0,
excess_profit_amount: Number(form?.excess_profit_amount) || 0,
habitual_bargain_ratio: Number(form?.habitual_bargain_ratio) || 0,
benefit_rate: Number(form?.benefit_rate) || 0,
benefit_fee: Number(form?.benefit_fee) || 0,
is_special_price: !!form?.is_special_price,
is_valid: !!form?.is_valid,
}
if (pc) body.price_code = pc
const cc = String(company_code ?? form?.company_code ?? '').trim()
const gc = String(goods_code ?? form?.goods_code ?? '').trim()
const cat = String(form?.category_code ?? '').trim()
if (cc) body.company_code = cc
if (gc) body.goods_code = gc
if (cat) body.category_code = cat
return body
export function buildCustomerPriceModifyPayload(form, { customer_price_code, company_code, goods_code, price_code } = {}) {
return buildCustomerPriceWriteBody(form, {
customer_price_code: customer_price_code ?? price_code,
company_code,
goods_code,
})
}
/**
* 修改一条或批量修改价格本
* @param {object[]} rows 含 price_code 的行
* @param {object[]} rows 含 customer_price_code 的行
* @param {object} form 表单字段
*/
export async function persistCustomerPriceModify(rows, form) {
const list = (Array.isArray(rows) ? rows : [])
.map((row) => {
const price_code = pickCustomerPriceCode(row)
if (!price_code) return null
const customer_price_code = pickCustomerPriceCode(row)
if (!customer_price_code) return null
return buildCustomerPriceModifyPayload(form, {
price_code,
customer_price_code,
company_code: row.company_code,
goods_code: row.goods_code,
})
})
.filter(Boolean)
if (!list.length) throw new Error('缺少 price_code,无法修改')
if (!list.length) throw new Error('缺少 customer_price_code,无法修改')
if (list.length === 1) {
await customerPriceModify(list[0])
} else {
@@ -199,8 +535,117 @@ export async function persistCustomerPriceModify(rows, form) {
/** POST /api/customer/price/remove */
export function customerPriceRemove(body) {
const price_code = pickCustomerPriceCode(body)
return request.post('/api/customer/price/remove', { price_code })
const customer_price_code = pickCustomerPriceCode(body)
return request.post('/api/customer/price/remove', { customer_price_code })
}
/** POST /api/customer/price/modify_custom */
export function customerPriceModifyCustom(body) {
return request.post('/api/customer/price/modify_custom', body)
}
/** POST /api/customer/price/remove_custom */
export function customerPriceRemoveCustom(body) {
const customer_price_code = pickCustomerPriceCode(body)
return request.post('/api/customer/price/remove_custom', { customer_price_code })
}
function normalizeCustomerPriceCustom(raw) {
if (raw == null || typeof raw !== 'object') return null
const src = raw
const excess_performance_groups = parseExcessPerformanceGroups(src)
const benefit_groups = parseBenefitGroups(src)
return {
...src,
sales_policy_type: src.sales_policy_type == null ? 0 : toPriceNum(src.sales_policy_type),
is_support_acceptance: src.is_support_acceptance === true
|| src.is_support_acceptance === 1
|| src.is_support_acceptance === '1',
rebate_delay_base_months: toPriceNum(src.rebate_delay_base_months),
excess_performance_groups,
benefit_groups,
excess_performance: normalizeExcessPerformanceFromRow(src),
benefit: normalizeBenefitFromRow(src),
excess_profit_rate: toPriceNum(src.excess_profit_rate),
excess_profit_amount: toPriceNum(src.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(src.habitual_bargain_ratio),
}
}
/** 特殊定价表单默认值(无 custom 时全部为 0) */
export function createEmptyCustomerPriceCustomForm() {
return {
sales_policy_type: 0,
is_support_acceptance: false,
rebate_delay_base_months: 0,
excess_performance_buckets: groupsToPerformanceBuckets([]),
excess_profit_rate: 0,
excess_profit_amount: 0,
habitual_bargain_ratio: 0,
benefit_buckets: groupsToBenefitBuckets([]),
}
}
/** 列表行 custom → 特殊定价表单;custom 为空则全 0 */
export function customerPriceCustomFormFromRow(row) {
const src = row && typeof row === 'object' ? row : {}
const custom = src.custom != null && typeof src.custom === 'object' ? src.custom : null
if (!custom) return createEmptyCustomerPriceCustomForm()
const excess_performance_buckets = groupsToPerformanceBuckets(
Array.isArray(custom.excess_performance_groups) && custom.excess_performance_groups.length
? custom.excess_performance_groups
: parseExcessPerformanceGroups(custom),
)
const benefit_buckets = groupsToBenefitBuckets(
Array.isArray(custom.benefit_groups) && custom.benefit_groups.length
? custom.benefit_groups
: parseBenefitGroups(custom),
)
mergeCustomerPriceBucketKeys(excess_performance_buckets, benefit_buckets)
return {
...createEmptyCustomerPriceCustomForm(),
sales_policy_type: custom.sales_policy_type == null ? 0 : toPriceNum(custom.sales_policy_type),
is_support_acceptance: custom.is_support_acceptance === true
|| custom.is_support_acceptance === 1
|| custom.is_support_acceptance === '1',
rebate_delay_base_months: toPriceNum(custom.rebate_delay_base_months),
excess_performance_buckets,
excess_profit_rate: toPriceNum(custom.excess_profit_rate),
excess_profit_amount: toPriceNum(custom.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(custom.habitual_bargain_ratio),
benefit_buckets,
}
}
/** 特殊定价 → modify_custom 请求体 */
export function buildCustomerPriceCustomWriteBody(form, { customer_price_code } = {}) {
const pc = String(customer_price_code ?? '').trim()
return {
customer_price_code: pc,
sales_policy_type: toPriceNum(form?.sales_policy_type),
is_support_acceptance: !!form?.is_support_acceptance,
rebate_delay_base_months: toPriceNum(form?.rebate_delay_base_months),
excess_performance: serializeExcessPerformanceBuckets(form?.excess_performance_buckets),
excess_profit_rate: toPriceNum(form?.excess_profit_rate),
excess_profit_amount: toPriceNum(form?.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(form?.habitual_bargain_ratio),
benefit: serializeBenefitBuckets(form?.benefit_buckets),
}
}
/** 保存特殊定价(modify_custom */
export async function persistCustomerPriceModifyCustom(row, form) {
const customer_price_code = pickCustomerPriceCode(row)
if (!customer_price_code) throw new Error('缺少 customer_price_code,无法保存自定义')
await customerPriceModifyCustom(buildCustomerPriceCustomWriteBody(form, { customer_price_code }))
}
/** 删除自定义(remove_custom */
export async function persistCustomerPriceRemoveCustom(row) {
const customer_price_code = pickCustomerPriceCode(row)
if (!customer_price_code) throw new Error('缺少 customer_price_code,无法删除自定义')
await customerPriceRemoveCustom({ customer_price_code })
}
function normalizeGoodsCategorys(raw) {
@@ -239,25 +684,105 @@ export function formatCustomerPriceGoodsDisplay(row) {
export function normalizeCustomerPriceRow(raw) {
const src = flattenPriceListedRow(raw)
if (!src || typeof src !== 'object') return null
const price_code = pickCustomerPriceCode(src)
const customer_price_code = pickCustomerPriceCode(src)
const company_code = String(src.company_code ?? '').trim()
const goods_code = String(src.goods_code ?? '').trim()
if (!price_code && !company_code && !goods_code) return null
if (!customer_price_code && !company_code && !goods_code) return null
const goods_categorys_list = normalizeGoodsCategorys(src)
const goods_categorys_text = formatCustomerPriceGoodsDisplay({ goods_categorys_list })
const excess_performance_groups = parseExcessPerformanceGroups(src)
const benefit_groups = parseBenefitGroups(src)
const excess_performance = normalizeExcessPerformanceFromRow(src)
const benefit = normalizeBenefitFromRow(src)
const has_custom = src.custom != null && typeof src.custom === 'object'
const custom = has_custom ? normalizeCustomerPriceCustom(src.custom) : null
return {
...src,
price_code,
customer_price_code,
price_code: customer_price_code,
company_code,
goods_code,
category_code: String(src.category_code ?? '').trim(),
sales_policy_type: src.sales_policy_type == null ? 0 : toPriceNum(src.sales_policy_type),
pos: src.pos == null || src.pos === '' ? null : toPriceNum(src.pos),
excess_performance_groups,
benefit_groups,
excess_performance,
benefit,
has_custom,
custom,
custom_excess_performance_groups: custom?.excess_performance_groups ?? [],
custom_benefit_groups: custom?.benefit_groups ?? [],
excess_profit_rate: toPriceNum(src.excess_profit_rate),
excess_profit_amount: toPriceNum(src.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(src.habitual_bargain_ratio),
goods_categorys_list,
goods_categorys_text,
goods_name: String(
src.goods_name ?? src.goods?.name ?? src.name ?? '',
).trim(),
goods_name: pickGoodsNameFromListedRow(src)
|| String(src.goods_name ?? src.goods?.name ?? src.name ?? '').trim(),
company_name: String(
src.company_name ?? src.customer_name ?? src.full_name ?? '',
).trim(),
}
}
function pickExcessPerformanceGroupsFromRow(row) {
const src = row && typeof row === 'object' ? row : {}
const groups = src.excess_performance_groups
if (Array.isArray(groups) && groups.length) return groups
return parseExcessPerformanceGroups(src)
}
function pickBenefitGroupsFromRow(row) {
const src = row && typeof row === 'object' ? row : {}
const groups = src.benefit_groups
if (Array.isArray(groups) && groups.length) return groups
return parseBenefitGroups(src)
}
/** 编辑表单默认值 */
export function createEmptyCustomerPriceForm() {
return {
company_code: '',
goods_code: '',
category_code: '',
goods_categorys_text: '',
sales_policy_type: 0,
is_support_acceptance: false,
rebate_delay_base_months: 0,
excess_performance_buckets: groupsToPerformanceBuckets([]),
excess_profit_rate: 0,
excess_profit_amount: 0,
habitual_bargain_ratio: 0,
benefit_buckets: groupsToBenefitBuckets([]),
is_special_price: false,
is_valid: true,
pos: null,
}
}
/** 列表行 → 编辑表单(JSON 有几组就加载几组) */
export function customerPriceFormFromRow(row) {
const src = row && typeof row === 'object' ? row : {}
const excess_performance_buckets = groupsToPerformanceBuckets(pickExcessPerformanceGroupsFromRow(src))
const benefit_buckets = groupsToBenefitBuckets(pickBenefitGroupsFromRow(src))
mergeCustomerPriceBucketKeys(excess_performance_buckets, benefit_buckets)
return {
...createEmptyCustomerPriceForm(),
company_code: src.company_code || '',
goods_code: src.goods_code || '',
category_code: src.category_code || '',
goods_categorys_text: src.goods_categorys_text || formatCustomerPriceGoodsDisplay(src) || '',
sales_policy_type: src.sales_policy_type == null ? 0 : toPriceNum(src.sales_policy_type),
is_support_acceptance: src.is_support_acceptance === true || src.is_support_acceptance === 1 || src.is_support_acceptance === '1',
rebate_delay_base_months: toPriceNum(src.rebate_delay_base_months),
excess_performance_buckets,
excess_profit_rate: toPriceNum(src.excess_profit_rate),
excess_profit_amount: toPriceNum(src.excess_profit_amount),
habitual_bargain_ratio: toPriceNum(src.habitual_bargain_ratio),
benefit_buckets,
is_special_price: src.is_special_price === true || src.is_special_price === 1 || src.is_special_price === '1',
is_valid: src.is_valid == null ? true : (src.is_valid === true || src.is_valid === 1 || src.is_valid === '1'),
pos: src.pos == null || src.pos === '' ? null : toPriceNum(src.pos),
}
}
-19
View File
@@ -160,25 +160,6 @@ export async function prCostRemove(expense_code) {
return res?.data ?? res
}
/** POST /api/pr/cost/amortize — 月度批量分摊(聚合时间段内已确认采购,逐笔未分摊费用) */
export async function prCostAmortize(body) {
const payload = {
start_time: str(body?.start_time),
end_time: str(body?.end_time),
}
if (!payload.start_time || !payload.end_time) throw new Error('请选择分摊时间段')
const expense_code = pickPrExpenseCode(body)
if (expense_code) payload.expense_code = expense_code
const res = await request.post('/api/pr/cost/amortize', payload, { skipErrorNotify: true })
return res?.data ?? res
}
/** 已分摊 / 无匹配采购 / 时段内无可分摊等业务提示,非致命错误 */
export function isBenignAmortizeError(err) {
const msg = pickApiErrorMessage(err, '')
return !msg || /没有未分摊|已分摊|无需分摊|无可分摊|无匹配|没有.*采购|暂无/.test(msg)
}
/** 分页拉全量公关费用 */
export async function prCostListedAll(body = {}) {
const limit = 50
+142
View File
@@ -0,0 +1,142 @@
import request from '@/utils/request'
import { pickGoodsNameFromListedRow } from '@/utils/goodsCategorys'
function str(v) {
return String(v ?? '').trim()
}
/** 默认内销(暂无出口场景) */
export const PRICING_TRADE_DOMESTIC = 'DOMESTIC'
/** 成本围栏 · 纳税身份 Tabkey 即接口 taxpayer_type 枚举) */
export const PRICING_TAXPAYER_TABS = [
{ key: 'GENERAL', label: '一般纳税人' },
{ key: 'SMALL', label: '小规模纳税人' },
{ key: 'INDIVIDUAL', label: '收据组织' },
]
/** @deprecated 请直接用 PRICING_TAXPAYER_TABS[].key */
export const PRICING_TAXPAYER_BY_ORG = {
general: 'GENERAL',
small: 'SMALL',
personal: 'INDIVIDUAL',
}
export const PRICING_TAXPAYER_DEFAULT = 'GENERAL'
const PRICING_TAXPAYER_KEYS = new Set(PRICING_TAXPAYER_TABS.map((t) => t.key))
/** 接口 data.list */
function unwrapPricingTableList(res) {
const envelope = res?.data ?? res
if (Array.isArray(envelope)) return envelope
const payload = envelope?.data ?? envelope
if (Array.isArray(payload)) return payload
if (payload && typeof payload === 'object') {
const list = payload.list ?? payload.rows ?? payload.table
if (Array.isArray(list)) return list
}
return []
}
function pickText(raw, ...keys) {
for (const k of keys) {
const v = raw[k]
if (v == null) continue
const s = String(v).trim()
if (s) return s
}
return null
}
/** 税率等:0 为有效值 */
function pickRate(raw, ...keys) {
for (const k of keys) {
const v = raw[k]
if (v == null || v === '') continue
const n = Number(v)
if (Number.isFinite(n)) return n
}
return null
}
/** 金额:过滤 null / 非有限数 / 明显异常值 */
function pickMoney(raw, ...keys) {
for (const k of keys) {
const v = raw[k]
if (v == null || v === '') continue
const n = Number(v)
if (!Number.isFinite(n)) continue
if (Math.abs(n) >= 1e12) continue
return n
}
return null
}
/** 定价决策表行 → 成本围栏表格(字段对齐 /api/pricing/table */
export function normalizePricingTableRow(raw, idx = 0) {
if (!raw || typeof raw !== 'object') return null
const supplier_bloc_name = pickText(raw, 'supplier_bloc_name')
const supplier_company_name = pickText(raw, 'supplier_company_name')
const goods_name = pickGoodsNameFromListedRow(raw) || pickText(raw, 'goods_name')
const bill_type = pickText(raw, 'bill_name', 'bill_type')
const input_tax_rate = pickRate(raw, 'bill_tax', 'input_tax_rate')
const source_company_name = pickText(raw, 'receive_company_name', 'source_company_name')
const internal_min_price = pickMoney(raw, 'internal_price', 'internal_min_price')
const internal_bill_type = pickText(raw, 'internal_bill_name', 'internal_bill_type')
const internal_input_tax_rate = pickRate(raw, 'internal_bill_tax', 'internal_input_tax_rate')
const source_rdc_name = pickText(raw, 'rdc_warehouse_name', 'source_rdc_name')
const source_rdc_code = pickText(raw, 'rdc_warehouse_code', 'source_rdc')
const fence_value = pickMoney(raw, 'fence_price', 'fence_value')
const bloc = pickText(raw, 'supplier_bloc_code')
const company = pickText(raw, 'supplier_company_code')
return {
key: pickText(raw, 'key', 'id') ?? `${bloc || 'bloc'}-${company || idx}`,
supplier_bloc_name,
supplier_company_name,
goods_name,
bill_type,
input_tax_rate,
source_company_name,
internal_min_price,
internal_bill_type,
internal_input_tax_rate,
source_rdc: source_rdc_name || source_rdc_code,
source_rdc_name,
fence_value,
}
}
/**
* POST /api/pricing/table
* 定价决策表:按 RDC、商品、纳税人身份、交易类型生成围栏支持数据
*/
export async function pricingTableQuery(body = {}) {
const taxpayer_type = str(body.taxpayer_type).toUpperCase()
const trade_type = str(body.trade_type) || PRICING_TRADE_DOMESTIC
const goods_code = str(body.goods_code)
const rdc_code = str(body.rdc_code)
if (!taxpayer_type) throw new Error('缺少纳税人身份')
if (!PRICING_TAXPAYER_KEYS.has(taxpayer_type)) {
throw new Error(`纳税人身份无效:${taxpayer_type}`)
}
if (!goods_code) throw new Error('缺少商品编码')
if (!rdc_code) throw new Error('缺少 RDC 仓库编码')
const res = await request.post('/api/pricing/table', {
taxpayer_type,
trade_type,
goods_code,
rdc_code,
})
return unwrapPricingTableList(res)
.map((row, idx) => normalizePricingTableRow(row, idx))
.filter(Boolean)
}
export function pickPricingTableError(err, fallback = '加载定价决策表失败') {
return String(err?.data?.message || err?.message || fallback).trim() || fallback
}
+23 -6
View File
@@ -5,6 +5,7 @@ import { loadGoodsInScenes, sceneValuesObjectFromRaw } from '@/utils/rfSceneGood
import {
pickGoodsCategoryEntry,
pickGoodsDisplayName,
pickGoodsNameFromListedRow,
resolveGoodsCategoryCode,
} from '@/utils/goodsCategorys'
@@ -571,7 +572,15 @@ function parseStableSupply(v) {
/** @param {unknown} row */
export function normalizeCollectionGoodsRow(row) {
if (!row || typeof row !== 'object') return null
const r = row
const embedded = row.raw && typeof row.raw === 'object' ? row.raw : null
const r = embedded
? {
...embedded,
...row,
goods_categorys: row.goods_categorys ?? embedded.goods_categorys,
goods: row.goods ?? embedded.goods,
}
: row
const nested = r.goods && typeof r.goods === 'object' ? r.goods : null
const goods_code = String(
r.goods_code
@@ -600,7 +609,8 @@ export function normalizeCollectionGoodsRow(row) {
).trim()
const name = pickGoodsDisplayName(r, category_code, { allowTopLevelName: true })
|| String(
r.goods_name
r.name
|| r.goods_name
|| r.goodsName
|| r.product_name
|| r.material_name
@@ -671,15 +681,22 @@ export function pickCollectionGoodsPreferredTax(row) {
export function pickCollectionGoodsPickFields(row) {
const normalized = normalizeCollectionGoodsRow(row) || (row && typeof row === 'object' ? row : null)
if (!normalized) return null
const raw = normalized.raw || row
const sourceRow = normalized.raw || row
const goods_code = String(normalized.goods_code || '').trim()
if (!goods_code) return null
const payRaw = raw?.payment_price ?? ''
const payRaw = sourceRow?.payment_price ?? row?.payment_price ?? ''
const payment_price = payRaw !== '' && payRaw != null ? Number(payRaw) : null
const bill_tax = pickCollectionGoodsPreferredTax(row)
const bill_tax = pickCollectionGoodsPreferredTax(sourceRow)
const nameCandidates = [
String(row?.name || '').trim(),
String(normalized.name || '').trim(),
pickGoodsNameFromListedRow(sourceRow),
pickGoodsNameFromListedRow(row),
].filter(Boolean)
const name = nameCandidates.find((n) => n !== goods_code) || nameCandidates[0] || goods_code
return {
goods_code,
name: String(normalized.name || goods_code).trim(),
name,
payment_price: Number.isFinite(payment_price) ? payment_price : null,
bill_tax,
raw: normalized,
+17 -16
View File
@@ -186,6 +186,23 @@ export async function allocAttrListed(body = {}) {
}
}
/** POST /api/warehouse/alloc/attr/search — 按商品/RDC/FDC 检索配货属性编码 */
export async function allocAttrSearch(body = {}) {
const payload = buildListedPayload({
goods_code: str(body.goods_code) || undefined,
rdc_warehouse_code: str(body.rdc_warehouse_code) || undefined,
fdc_warehouse_code: str(body.fdc_warehouse_code) || undefined,
page: body.page,
limit: body.limit,
})
const res = await request.post('/api/warehouse/alloc/attr/search', payload)
const { list, total } = unwrapListed(res)
return {
list: list.map(normalizeAllocAttrRow).filter(Boolean),
total,
}
}
/** POST /api/warehouse/alloc/logs/listed */
export async function allocLogsListed(body = {}) {
const res = await request.post('/api/warehouse/alloc/logs/listed', buildListedPayload(body))
@@ -270,19 +287,3 @@ export async function allocShareListed(body = {}) {
total,
}
}
/** POST /api/warehouse/alloc/amortize */
export async function allocAmortize(body) {
const payload = {
start_time: str(body?.start_time),
end_time: str(body?.end_time),
}
if (!payload.start_time || !payload.end_time) throw new Error('请选择分摊时间段')
const res = await request.post('/api/warehouse/alloc/amortize', payload, { skipErrorNotify: true })
return res?.data ?? res
}
export function isBenignAllocAmortizeError(err) {
const msg = pickApiErrorMessage(err, '')
return !msg || /没有|已分摊|无需分摊|无可分摊|暂无|为空/.test(msg)
}
-16
View File
@@ -270,19 +270,3 @@ export async function deployShareListed(body = {}) {
total,
}
}
/** POST /api/warehouse/deploy/amortize */
export async function deployAmortize(body) {
const payload = {
start_time: str(body?.start_time),
end_time: str(body?.end_time),
}
if (!payload.start_time || !payload.end_time) throw new Error('请选择分摊时间段')
const res = await request.post('/api/warehouse/deploy/amortize', payload, { skipErrorNotify: true })
return res?.data ?? res
}
export function isBenignDeployAmortizeError(err) {
const msg = pickApiErrorMessage(err, '')
return !msg || /没有|已分摊|无需分摊|无可分摊|暂无|为空/.test(msg)
}
-34
View File
@@ -291,40 +291,6 @@ export async function freightShareListed(body = {}) {
}
}
/** POST /api/warehouse/freight/amortize — 月度批量分摊(按已到库订单 + 运费日志维度) */
export async function freightAmortize(body) {
const payload = {
start_time: str(body?.start_time),
end_time: str(body?.end_time),
}
if (!payload.start_time || !payload.end_time) throw new Error('请选择分摊时间段')
const res = await request.post('/api/warehouse/freight/amortize', payload, { skipErrorNotify: true })
return res?.data ?? res
}
/** 时段内无运费日志 / 已分摊等业务提示,非致命错误 */
export function isBenignFreightAmortizeError(err) {
const msg = pickApiErrorMessage(err, '')
return !msg || /没有|已分摊|无需分摊|无可分摊|无运费|暂无|为空/.test(msg)
}
/** 分页拉全量物料运费日志(跑批前校验时段内是否有日志) */
export async function freightLogsListedAll(body = {}) {
const limit = 50
let page = 1
const all = []
let total = 0
for (;;) {
const res = await freightLogsListed({ ...body, page, limit })
const batch = res.list || []
all.push(...batch)
total = res.total
if (!batch.length || all.length >= total) break
page += 1
}
return { list: all, total }
}
export function pickApiErrorMessage(err, fallback = '操作失败') {
return String(err?.data?.message || err?.message || fallback).trim() || fallback
}
+62 -1
View File
@@ -198,6 +198,67 @@ export async function wasteLogsAdd(body) {
return res?.data ?? res
}
function normalizeWasteShareRow(raw) {
if (!raw || typeof raw !== 'object') return null
const wh = pickRdcFdcPair(raw)
const goods = pickGoodsFields(raw)
const goods_code = goods.goods_code
if (!goods_code && !wh.rdc_warehouse_code) return null
const waste_count = num(
raw.waste_count
?? raw.rdc_waste_count
?? raw.rdc_waste
?? raw.waste_num,
)
const rdc_in_count = num(
raw.rdc_in_count
?? raw.total_rdc_in
?? raw.rdc_inbound_count
?? raw.in_count,
)
const share_total_amount = num(
raw.total_amount
?? raw.share_total_amount
?? raw.share_amount,
)
return {
...raw,
...wh,
...goods,
goods_display_name: pickGoodsNameFromListedRow(raw) || goods.goods_name,
waste_count,
rdc_in_count,
ratio: num(raw.ratio ?? raw.share_ratio ?? raw.waste_ratio),
pre_price: num(raw.pre_price ?? raw.price ?? raw.cost_price),
unit_amount: num(raw.unit_amount ?? raw.share_unit_amount ?? raw.unit_share_amount),
share_total_amount,
share_time: str(raw.share_time ?? raw.amortize_time ?? raw.ctime ?? raw.create_time),
day_id: raw.day_id != null && raw.day_id !== '' ? Number(raw.day_id) : undefined,
}
}
/** POST /api/warehouse/waste/share/listed */
export async function wasteShareListed(body = {}) {
const payload = buildListedPayload({
goods_code: str(body.goods_code) || undefined,
rdc_warehouse_code: str(body.rdc_warehouse_code) || undefined,
day_id: body.day_id != null && body.day_id !== '' ? Number(body.day_id) : undefined,
page: body.page,
limit: body.limit,
})
const res = await request.post('/api/warehouse/waste/share/listed', payload)
const { list, total } = unwrapListed(res)
return {
list: list.map(normalizeWasteShareRow).filter(Boolean),
total,
}
}
export function pickApiErrorMessage(err, fallback = '操作失败') {
return String(err?.data?.message || err?.message || fallback).trim() || fallback
return String(
err?.data?.message
?? err?.data?.data?.message
?? err?.message
?? fallback,
).trim() || fallback
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
<div class="gt-wh-panel" v-loading="panelLoading" element-loading-text="加载中…">
<div v-if="showSchemaHint" class="gt-wh-hint">
<el-icon><InfoFilled /></el-icon>
<span>仓库管理为系统固定结构左侧选 RDC上方横向切换费用票据填写提成 / 利润内部零售渠道的率与额</span>
<span>仓库管理左侧选 RDC右侧按费用票据分组填写提成 / 利润各渠道的率与额</span>
</div>
<template v-if="!rdcOptions.length">
@@ -22,71 +22,70 @@
<span v-if="rdcDisplaySub(rdc)" class="gt-wh-rdc-nav-sub">{{ rdcDisplaySub(rdc) }}</span>
</button>
</nav>
<div class="gt-wh-main">
<div v-if="showLegacyRdcHint" class="gt-wh-legacy-hint">
当前 RDC 存在历史格式数据未按票据分桶请选择票据后重新填写并保存以迁移到新结构
</div>
<div class="gt-wh-workspace">
<template v-if="!expenseBills.length">
<el-empty :image-size="64" description="暂无费用票据" class="gt-wh-empty" />
</template>
<template v-else-if="!activeBillCode">
<el-empty :image-size="64" description="请选择费用票据" class="gt-wh-empty" />
</template>
<template v-else>
<el-tabs v-model="activeBillCode" class="gt-wh-bill-tabs">
<el-tab-pane
v-for="bill in expenseBills"
:key="bill.bill_code"
:name="normBillCode(bill.bill_code)"
>
<template #label>
<span class="gt-wh-bill-tab" :title="billTabTitle(bill)">
<span class="gt-wh-bill-tab-name">{{ billTabParts(bill).name }}</span>
<span v-if="billTabParts(bill).tax" class="gt-wh-bill-tab-tax">{{ billTabParts(bill).tax }}%</span>
</span>
</template>
</el-tab-pane>
</el-tabs>
<div class="gt-wh-form-body">
<div v-if="showLegacyRdcHint" class="gt-wh-legacy-hint gt-wh-legacy-hint--workspace">
当前 RDC 存在历史格式数据未按票据分桶请重新填写并保存以迁移到新结构
</div>
<div class="gt-wh-bills-scroll">
<div
v-for="sec in WAREHOUSE_SECTIONS"
:key="sec.key"
class="gt-wh-section-block"
v-for="bill in expenseBills"
:key="normBillCode(bill.bill_code)"
class="gt-wh-bill-group"
>
<div class="gt-wh-section-title">{{ sec.label }}</div>
<div class="gt-wh-channel-grid">
<div
v-for="ch in WAREHOUSE_CHANNELS"
:key="ch.key"
class="gt-wh-channel-card"
>
<div class="gt-wh-channel-title">{{ ch.label }}</div>
<div class="gt-wh-metrics">
<div class="gt-wh-metric">
<span class="gt-wh-metric-label"></span>
<el-input-number
:model-value="metricValue(sec.key, ch.key, 'rate')"
:disabled="readonly"
:controls="false"
placeholder="—"
size="small"
class="gt-wh-metric-input"
@update:model-value="(v) => setMetric(sec.key, ch.key, 'rate', v)"
/>
<span class="gt-wh-metric-suffix">%</span>
</div>
<div class="gt-wh-metric">
<span class="gt-wh-metric-label">额</span>
<el-input-number
:model-value="metricValue(sec.key, ch.key, 'amount')"
:disabled="readonly"
:controls="false"
placeholder=""
size="small"
class="gt-wh-metric-input"
@update:model-value="(v) => setMetric(sec.key, ch.key, 'amount', v)"
/>
<div class="gt-wh-bill-group-title">
<span class="gt-wh-bill-group-name">{{ billTabParts(bill).name }}</span>
<span v-if="billTabParts(bill).tax" class="gt-wh-bill-group-tax">{{ billTabParts(bill).tax }}%</span>
</div>
<div class="gt-wh-bill-group-body">
<div class="gt-wh-sections-row">
<div
v-for="sec in WAREHOUSE_SECTIONS"
:key="`${normBillCode(bill.bill_code)}-${sec.key}`"
class="gt-wh-section-block"
:class="sectionBlockClass(sec.key)"
>
<div class="gt-wh-section-title">{{ sec.label }}</div>
<div class="gt-wh-channel-grid">
<div
v-for="ch in WAREHOUSE_CHANNELS"
:key="`${normBillCode(bill.bill_code)}-${sec.key}-${ch.key}`"
class="gt-wh-channel-card"
>
<div class="gt-wh-channel-title">{{ ch.label }}</div>
<div class="gt-wh-metrics">
<div class="gt-wh-metric">
<span class="gt-wh-metric-label"></span>
<el-input-number
:model-value="metricValue(sec.key, ch.key, 'rate', bill.bill_code)"
:disabled="readonly"
:controls="false"
placeholder="—"
size="small"
class="gt-wh-metric-input"
@update:model-value="(v) => setMetric(sec.key, ch.key, 'rate', v, bill.bill_code)"
/>
<span class="gt-wh-metric-suffix">%</span>
</div>
<div class="gt-wh-metric">
<span class="gt-wh-metric-label">额</span>
<el-input-number
:model-value="metricValue(sec.key, ch.key, 'amount', bill.bill_code)"
:disabled="readonly"
:controls="false"
placeholder=""
size="small"
class="gt-wh-metric-input"
@update:model-value="(v) => setMetric(sec.key, ch.key, 'amount', v, bill.bill_code)"
/>
<span class="gt-wh-metric-suffix"></span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -120,9 +119,7 @@ import {
const props = defineProps({
modelValue: { type: Object, default: () => ({}) },
readonly: { type: Boolean, default: false },
/** 全局页签管理:展示结构说明 */
showSchemaHint: { type: Boolean, default: false },
/** 物料 /goods/view 返回的 rdcs;公共页签、物料分类留空即可 */
rdcList: { type: Array, default: () => [] },
})
@@ -136,7 +133,6 @@ const billsLoaded = ref(false)
/** @type {import('vue').Ref<object[]>} */
const allBills = ref([])
const activeRdcCode = ref('')
const activeBillCode = ref('')
const panelLoading = computed(() => billLoading.value)
@@ -147,7 +143,6 @@ const bucket = computed({
set: (v) => emit('update:modelValue', v),
})
/** 仓库管理仅使用费用票据 type=4 */
const expenseBills = computed(() => {
return allBills.value.filter((b) => {
if (b?.is_valid === false || b?.is_valid === 0 || b?.is_valid === '0') return false
@@ -161,6 +156,12 @@ const showLegacyRdcHint = computed(() => {
return isLegacyRdcMetricsBucket(rdc)
})
function sectionBlockClass(sectionKey) {
if (sectionKey === 'commission') return 'is-commission'
if (sectionKey === 'profit') return 'is-profit'
return ''
}
function rdcDisplayName(rdc) {
const name = String(rdc?.name || '').trim()
const code = String(rdc?.code || '').trim()
@@ -179,11 +180,6 @@ function billTabParts(bill) {
return expenseBillTabParts(bill)
}
function billTabTitle(bill) {
const { name, tax } = billTabParts(bill)
return tax ? `${name} · ${tax}%` : name
}
function normBillCode(code) {
return String(code ?? '').trim()
}
@@ -192,26 +188,18 @@ function normRdcCode(code) {
return String(code ?? '').trim()
}
function pickInitialBillCode() {
const list = expenseBills.value
if (!list.length) {
activeBillCode.value = ''
return
function ensureAllBillBucketsForRdc(rdcCode) {
const rdc = normRdcCode(rdcCode)
if (!rdc || props.readonly) return
for (const bill of expenseBills.value) {
ensureBillBucket(rdc, bill.bill_code)
}
const cur = normBillCode(activeBillCode.value)
if (cur && list.some((b) => normBillCode(b.bill_code) === cur)) {
activeBillCode.value = cur
return
}
activeBillCode.value = normBillCode(list[0].bill_code)
}
function syncActiveWarehouseSelection() {
pickInitialBillCode()
function syncActiveRdcBuckets() {
const rdc = normRdcCode(activeRdcCode.value)
const bill = normBillCode(activeBillCode.value)
if (!rdc || !bill) return
if (!props.readonly) ensureBillBucket(rdc, bill)
if (!rdc || !expenseBills.value.length) return
if (!props.readonly) ensureAllBillBucketsForRdc(rdc)
}
function selectRdc(code) {
@@ -219,12 +207,11 @@ function selectRdc(code) {
activeRdcCode.value = c
if (!c || props.readonly) return
const next = { ...bucket.value }
const rdc = next[c]
if (!rdc) {
if (!next[c]) {
next[c] = {}
bucket.value = next
}
syncActiveWarehouseSelection()
syncActiveRdcBuckets()
}
function ensureBillBucket(rdcCode, billCode) {
@@ -246,25 +233,25 @@ function ensureBillBucket(rdcCode, billCode) {
return next[rdc][bill]
}
function currentBillBucket() {
const rdc = bucket.value[normRdcCode(activeRdcCode.value)]
const bill = normBillCode(activeBillCode.value)
function billBucketFor(rdcCode, billCode) {
const rdc = bucket.value[normRdcCode(rdcCode)]
const bill = normBillCode(billCode)
if (!rdc || !bill) return null
if (isLegacyRdcMetricsBucket(rdc)) return rdc
return rdc[bill] || null
}
function metricValue(sectionKey, channelKey, metricKey) {
const b = currentBillBucket()
function metricValue(sectionKey, channelKey, metricKey, billCode) {
const b = billBucketFor(activeRdcCode.value, billCode)
const v = b?.[sectionKey]?.[channelKey]?.[metricKey]
return v === null || v === undefined ? null : Number(v)
}
function setMetric(sectionKey, channelKey, metricKey, raw) {
function setMetric(sectionKey, channelKey, metricKey, raw, billCode) {
const rdcCode = normRdcCode(activeRdcCode.value)
const billCode = normBillCode(activeBillCode.value)
if (props.readonly || !rdcCode || !billCode) return
const billBucket = ensureBillBucket(rdcCode, billCode)
const bill = normBillCode(billCode)
if (props.readonly || !rdcCode || !bill) return
const billBucket = ensureBillBucket(rdcCode, bill)
if (!billBucket) return
if (!billBucket[sectionKey]) billBucket[sectionKey] = createEmptyRdcBucket()[sectionKey]
if (!billBucket[sectionKey][channelKey]) {
@@ -281,17 +268,16 @@ async function loadBillList() {
const res = await request.post('/api/bill/listed', { page: 1, limit: 500, type: FINANCE_BILL_TYPE_EXPENSE })
const list = Array.isArray(res?.data) ? res.data : (Array.isArray(res?.data?.list) ? res.data.list : [])
allBills.value = list.filter((b) => normalizeFinanceBillType(b?.type) === FINANCE_BILL_TYPE_EXPENSE)
syncActiveWarehouseSelection()
syncActiveRdcBuckets()
} catch {
allBills.value = []
activeBillCode.value = ''
} finally {
billLoading.value = false
}
}
watch(expenseBills, () => {
syncActiveWarehouseSelection()
syncActiveRdcBuckets()
})
function pickInitialRdcCode(opts) {
@@ -314,7 +300,7 @@ watch(
if (nextRdc !== normRdcCode(activeRdcCode.value)) {
selectRdc(nextRdc)
} else {
syncActiveWarehouseSelection()
syncActiveRdcBuckets()
}
if (!billsLoaded.value) {
billsLoaded.value = true
@@ -324,10 +310,6 @@ watch(
{ immediate: true },
)
watch(activeBillCode, () => {
syncActiveWarehouseSelection()
})
watch(
() => props.modelValue,
() => {
@@ -364,12 +346,6 @@ defineExpose({
border: 1px solid #d9ecff;
border-radius: 6px;
}
.gt-wh-hint code {
font-size: 11px;
padding: 0 4px;
background: #ecf5ff;
border-radius: 3px;
}
.gt-wh-legacy-hint {
padding: 8px 12px;
font-size: 12px;
@@ -379,6 +355,10 @@ defineExpose({
border: 1px solid #faecd8;
border-radius: 6px;
}
.gt-wh-legacy-hint--workspace {
flex-shrink: 0;
margin: 10px 12px 0;
}
.gt-wh-split {
display: flex;
align-items: stretch;
@@ -389,7 +369,7 @@ defineExpose({
background: #fff;
}
.gt-wh-rdc-nav {
flex: 0 0 132px;
flex: 0 0 168px;
align-self: stretch;
min-height: 0;
border-right: 1px solid #ebeef5;
@@ -436,44 +416,53 @@ defineExpose({
color: #909399;
word-break: break-all;
}
.gt-wh-main {
.gt-wh-workspace {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 14px 14px;
overflow-y: auto;
}
.gt-wh-bill-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.gt-wh-bill-tabs :deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
.gt-wh-bill-tabs :deep(.el-tabs__item) {
height: 38px;
padding: 0 14px;
}
.gt-wh-bill-tab {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 168px;
vertical-align: middle;
}
.gt-wh-bill-tab-name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 1.2;
}
.gt-wh-bill-tab-tax {
.gt-wh-bills-scroll {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 10px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.gt-wh-bill-group {
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
}
.gt-wh-bill-group-title {
flex-shrink: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 14px 6px;
font-size: 12px;
font-weight: 600;
color: #8b9ab0;
letter-spacing: 0.5px;
border-bottom: 1px solid #ebeef5;
background: #fafbfc;
}
.gt-wh-bill-group-name {
color: #606266;
font-size: 13px;
}
.gt-wh-bill-group-tax {
padding: 0 6px;
height: 18px;
line-height: 18px;
@@ -482,36 +471,32 @@ defineExpose({
font-weight: 600;
background: #fef0f0;
color: #f56c6c;
transition: background 0.15s, color 0.15s;
}
.gt-wh-bill-tabs :deep(.el-tabs__item.is-active) .gt-wh-bill-tab-name {
font-weight: 600;
.gt-wh-bill-group-body {
padding: 12px;
}
.gt-wh-bill-tabs :deep(.el-tabs__item.is-active) .gt-wh-bill-tab-tax {
background: #f56c6c;
color: #fff;
}
.gt-wh-bill-tabs :deep(.el-tabs__item:hover) .gt-wh-bill-tab-tax {
background: #fde2e2;
}
.gt-wh-bill-tabs :deep(.el-tabs__item.is-active:hover) .gt-wh-bill-tab-tax {
background: #f56c6c;
color: #fff;
}
.gt-wh-form-body {
display: flex;
flex-direction: column;
gap: 14px;
.gt-wh-sections-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.gt-wh-section-block {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 10px 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
}
.gt-wh-section-block + .gt-wh-section-block {
margin-top: 4px;
padding-top: 14px;
border-top: 1px solid #ebeef5;
.gt-wh-section-block.is-commission {
background: #f7fafd;
border-color: #e6eef8;
}
.gt-wh-section-block.is-profit {
background: #f8fbf6;
border-color: #e5efe5;
}
.gt-wh-section-title {
font-size: 13px;
@@ -519,56 +504,50 @@ defineExpose({
color: #303133;
padding-left: 2px;
}
.gt-wh-section-block.is-commission .gt-wh-section-title {
color: #4a6fa5;
}
.gt-wh-section-block.is-profit .gt-wh-section-title {
color: #5a8f5a;
}
.gt-wh-channel-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 900px) {
.gt-wh-split {
flex-direction: column;
}
.gt-wh-rdc-nav {
flex: 0 0 auto;
max-height: 140px;
border-right: none;
border-bottom: 1px solid #ebeef5;
}
.gt-wh-channel-grid {
grid-template-columns: 1fr;
}
gap: 10px;
}
.gt-wh-channel-card {
padding: 12px;
padding: 10px 8px;
border: 1px solid #ebeef5;
border-radius: 8px;
border-radius: 6px;
background: #fff;
min-width: 0;
}
.gt-wh-channel-title {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px dashed #ebeef5;
}
.gt-wh-metrics {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.gt-wh-metric {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.gt-wh-metric-label {
flex: 0 0 20px;
flex: 0 0 18px;
font-size: 12px;
color: #909399;
}
.gt-wh-metric-input {
flex: 1;
min-width: 0;
width: 100%;
}
.gt-wh-metric-input :deep(.el-input__inner) {
@@ -582,4 +561,21 @@ defineExpose({
.gt-wh-empty {
padding: 24px 0;
}
@media (max-width: 900px) {
.gt-wh-split {
flex-direction: column;
}
.gt-wh-rdc-nav {
flex: 0 0 auto;
max-height: 120px;
border-right: none;
border-bottom: 1px solid #ebeef5;
}
.gt-wh-sections-row {
grid-template-columns: 1fr;
}
.gt-wh-channel-grid {
grid-template-columns: 1fr;
}
}
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
{
"buildId": "20260703007",
"generatedAt": "2026-07-03T09:28:11.726Z"
"buildId": "20260707001",
"generatedAt": "2026-07-06T16:11:57.042Z"
}
+75
View File
@@ -47,9 +47,84 @@ export const APP_VERSION = '0.7.029'
/** @type {ChangelogDay[]} */
export const APP_CHANGELOG = [
{
date: '2026-07-06',
items: [
{
icon: 'mdi:warehouse',
kind: 'change',
text: '「仓库管理」RDC 物料右侧面板 Tab「可调配仓」更名为「源采购 RDC」。',
},
{
icon: 'mdi:notebook-outline',
kind: 'add',
text: '「客户管理 → 客户价格本 → 特殊定价」:批量值只读展示主记录,「自定义」单独对接 `modify_custom` / `remove_custom`(确定保存、已有 custom 时可删);交易利润区保留费用票据切换;超额绩效/好处费 JSON 按接口实际 key 动态 Tab,无 custom 默认全 0。',
},
{
icon: 'mdi:fence',
kind: 'change',
text: '「物料弹窗 → 成本围栏」切换一般纳税人 / 小规模 / 收据组织 Tab 时,仅表格数据区 loading,Tab 按钮与表头保持不动;请求期间保留上一份数据,避免闪空;`taxpayer_type` 直传 `GENERAL` / `SMALL` / `INDIVIDUAL` 枚举。',
},
{
icon: 'mdi:fence',
kind: 'fix',
text: '「物料弹窗 → 成本围栏」定价决策表字段对齐接口:`bill_name`、`bill_tax`、`receive_company_name`、`internal_price`、`internal_bill_name`、`internal_bill_tax`、`rdc_warehouse_name`、`fence_price` 等;商品名读 `goods_categorys`;税率为 0 显示 `0%`;明显异常金额过滤为「—」。',
},
{
icon: 'mdi:fence',
kind: 'add',
text: '「物料弹窗 → 成本围栏」接入 `POST /api/pricing/table` 定价决策表:左侧售出 RDC(与仓库管理同源 `rdcs`);顶栏纳税身份 Tab;表格展示供应商集团/组织、票据与进项税率、源采购组织、内部交易价与票据、源 RDC、围栏值;`trade_type` 固定 `DOMESTIC`,切换 RDC / 纳税身份 / 物料自动请求。',
},
{
icon: 'mdi:warehouse',
kind: 'change',
text: '「物料弹窗 → 仓库管理」布局调整:去掉中间费用票据竖 Tab;各 type=4 费用票据纵向分组展示,组内「提成 / 利润」并排,各含内部/零售/渠道率与额(额后加「元」);左侧 RDC 列表宽 168px。',
},
{
icon: 'mdi:format-list-bulleted',
kind: 'change',
text: '「物料列表」新增/编辑弹窗宽度改为 88vw;基础属性字段网格按右侧可用宽度自适应列数(1920 下约 6 列),大屏随宽度递增。',
},
{
icon: 'mdi:cart-outline',
kind: 'fix',
text: '「采购管理 → 新增采购订单」从统筹商品添加至明细后,物料列正确展示「名称(编码)」;修复二次归一化丢失 `goods_categorys` 物料名、明细仅显示编码的问题。',
},
{
icon: 'mdi:package-variant-closed-minus',
kind: 'change',
text: '「仓库管理 → 损耗管理 → 新增记录」配货属性编码旁增加「搜索」按钮,弹窗内按物料编码、RDC/FDC 仓查询 `alloc/attr/search` 后点选;主表单展示编码及「物料 · RDC · FDC」说明。',
},
],
},
{
date: '2026-07-05',
items: [
{
icon: 'mdi:cog-outline',
kind: 'change',
text: '系统工程调整:变更说明按日归档,便于发版对照;仓库管理相关 API 封装与字段归一化逻辑补充,提升与后端联调时的数据一致性。',
},
],
},
{
date: '2026-07-04',
items: [
{
icon: 'mdi:tune-variant',
kind: 'change',
text: '系统交互与展示优化:仓库运维及分摊预估类页面统一「进页跑批 / 手动查询 / 重新跑批」职责划分;列表 loading 与空态提示对齐;数字列间距、除物料外仅展示名称等展示规则在各模块间进一步收敛。',
},
],
},
{
date: '2026-07-03',
items: [
{
icon: 'mdi:package-variant-closed-minus',
kind: 'add',
text: '「仓库管理 → 损耗分摊预估」新增页面(`WasteShareListed.vue`,路由 `/warehouse/waste-share`):进页自动 `waste/amortize` 跑批后拉 `waste/share/listed`;筛选分摊时间段、物料编码、RDC 仓库编码;展示损耗数、RDC 入库、占比、定价成本、分摊总额、单件分摊(`is-cols-share-waste`9 列)。',
},
{
icon: 'mdi:help-circle-outline',
kind: 'change',
+10
View File
@@ -505,6 +505,16 @@ export const asyncRoutes = [
powerKey: 'warehouse',
},
},
{
path: 'waste-share',
name: 'WarehouseWasteShare',
component: () => import('@/views/base/WasteShareListed.vue'),
meta: {
title: '损耗分摊预估',
icon: 'mdi:package-variant-closed-minus',
powerKey: 'warehouse',
},
},
],
},
/*
+14
View File
@@ -81,10 +81,18 @@
.wops-flex-table.is-cols-share-deploy {
--wops-cols: 14fr 11fr 11fr 5fr 5fr 5fr 4fr 5fr 8fr;
}
/* 损耗分摊预估(9 列) */
.wops-flex-table.is-cols-share-waste {
--wops-cols: 14fr 11fr 5fr 5fr 4fr 5fr 5fr 5fr 8fr;
}
/* 公关费用(10 列):费用编码略窄,集团 / 组织各加半份 */
.wops-flex-table.is-cols-pr-cost {
--wops-cols: 7fr 10.5fr 10.5fr 7fr 8fr 8fr 6fr 9fr 6fr 8fr;
}
/* 物料成本围栏(11 列):税率/内部最低价收窄,源采购组织加宽 */
.wops-flex-table.is-cols-cost-fence {
--wops-cols: 10fr 10fr 10fr 7fr minmax(4.2em, 4fr) 10fr minmax(7.2em, 8fr) 8fr minmax(8.5em, 9fr) 9fr 5fr;
}
.wops-flex-scroll {
flex: 1;
@@ -191,7 +199,9 @@
.wops-flex-table.is-cols-share-pr .wops-flex-row,
.wops-flex-table.is-cols-share-alloc .wops-flex-row,
.wops-flex-table.is-cols-share-deploy .wops-flex-row,
.wops-flex-table.is-cols-share-waste .wops-flex-row,
.wops-flex-table.is-cols-pr-cost .wops-flex-row,
.wops-flex-table.is-cols-cost-fence .wops-flex-row,
.wops-flex-table.is-cols-collect-whp .wops-flex-row,
.wops-flex-table.is-cols-logs-whp .wops-flex-row,
.wops-flex-table.is-cols-orders-whp .wops-flex-row,
@@ -211,8 +221,12 @@
.wops-flex-table.is-cols-share-alloc .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-share-deploy .wops-col--num,
.wops-flex-table.is-cols-share-deploy .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-share-waste .wops-col--num,
.wops-flex-table.is-cols-share-waste .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-pr-cost .wops-col--num,
.wops-flex-table.is-cols-pr-cost .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-cost-fence .wops-col--num,
.wops-flex-table.is-cols-cost-fence .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-collect-whp .wops-col--num,
.wops-flex-table.is-cols-collect-whp .wops-flex-head .wops-col--num,
.wops-flex-table.is-cols-logs-whp .wops-col--num,
+53
View File
@@ -206,6 +206,59 @@ function isRdcEntryEmpty(rdcEntry) {
return bills.every((b) => isRdcBucketEmpty(b))
}
/** @param {unknown} rdcEntry warehouse[rdcCode] */
export function pickWarehouseMetricsForChannel(rdcEntry, sectionKey, channelKey) {
if (!rdcEntry || typeof rdcEntry !== 'object') {
return normalizeChannelMetrics(null)
}
if (isLegacyRdcMetricsBucket(rdcEntry)) {
return normalizeChannelMetrics(rdcEntry[sectionKey]?.[channelKey])
}
let fallback = null
for (const billBucket of Object.values(rdcEntry)) {
if (!isLegacyRdcMetricsBucket(billBucket)) continue
const m = normalizeChannelMetrics(billBucket[sectionKey]?.[channelKey])
if (m.rate != null || m.amount != null) return m
if (!fallback) fallback = m
}
return fallback ?? normalizeChannelMetrics(null)
}
/** @param {unknown} rdcEntry warehouse[rdcCode] */
export function listWarehouseRdcBillKeys(rdcEntry) {
if (!rdcEntry || typeof rdcEntry !== 'object' || Array.isArray(rdcEntry)) return []
if (isLegacyRdcMetricsBucket(rdcEntry)) return []
return Object.keys(rdcEntry)
.map((k) => String(k || '').trim())
.filter(Boolean)
}
/** @param {unknown} rdcEntry warehouse[rdcCode] @param {unknown} billCode */
export function pickWarehouseMetricsForBillChannel(rdcEntry, billCode, sectionKey, channelKey) {
if (!rdcEntry || typeof rdcEntry !== 'object') {
return normalizeChannelMetrics(null)
}
if (isLegacyRdcMetricsBucket(rdcEntry)) {
return normalizeChannelMetrics(rdcEntry[sectionKey]?.[channelKey])
}
const bill = String(billCode ?? '').trim()
if (!bill) {
return pickWarehouseMetricsForChannel(rdcEntry, sectionKey, channelKey)
}
const billBucket = rdcEntry[bill]
if (isLegacyRdcMetricsBucket(billBucket)) {
return normalizeChannelMetrics(billBucket[sectionKey]?.[channelKey])
}
return normalizeChannelMetrics(null)
}
/** 客户 sale_type → 仓库渠道 key(忽略 internal */
export function warehouseChannelKeyFromSaleType(saleType) {
const n = Number(saleType)
if (n === 2) return 'retail'
return 'channel'
}
/** 保存前剔除全空的 RDC / 票据条目 */
export function pruneEmptyWarehouseRdcEntries(bucket) {
const normalized = normalizeWarehouseGoodsBucket(bucket)
+1 -44
View File
@@ -76,7 +76,6 @@
/>
<el-button type="primary" size="small" @click="doSearch">查询</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
<el-button type="warning" size="small" @click="runBatchAndLoad">重新跑批</el-button>
</div>
<div v-loading="loading" :element-loading-text="loadingText" class="asl-body">
@@ -155,9 +154,7 @@ import {
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import {
allocAmortize,
allocShareListed,
isBenignAllocAmortizeError,
pickApiErrorMessage,
} from '@/api/warehouseAlloc'
@@ -365,46 +362,6 @@ function onSizeChange() {
search()
}
async function runBatchAndLoad() {
const start_time = String(batchRange.start_time || '').trim()
const end_time = String(batchRange.end_time || '').trim()
if (!start_time || !end_time) {
ElMessage.warning('请选择分摊时间段(开始日期、结束日期)')
return
}
if (start_time > end_time) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
page.value = 1
loading.value = true
loadingText.value = '正在跑批中…'
try {
let amortizeHint = ''
try {
await allocAmortize({ start_time, end_time })
} catch (e) {
if (!isBenignAllocAmortizeError(e)) {
throw e
}
amortizeHint = pickApiErrorMessage(e, '')
}
activeBillCode.value = ''
await fetchShareList()
if (amortizeHint) {
ElMessage.warning(amortizeHint)
} else if (rows.value.length) {
ElMessage.success(`跑批完成,分摊结果 ${total.value}`)
}
} catch (e) {
ElMessage.error(pickApiErrorMessage(e, '跑批失败'))
} finally {
loading.value = false
}
}
async function search() {
loading.value = true
loadingText.value = '加载中…'
@@ -433,7 +390,7 @@ function resetFilters() {
async function initPage() {
await loadExpenseBills()
await runBatchAndLoad()
await search()
}
onMounted(initPage)
+1 -44
View File
@@ -76,7 +76,6 @@
/>
<el-button type="primary" size="small" @click="doSearch">查询</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
<el-button type="warning" size="small" @click="runBatchAndLoad">重新跑批</el-button>
</div>
<div v-loading="loading" :element-loading-text="loadingText" class="dsl-body">
@@ -155,9 +154,7 @@ import {
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import {
deployAmortize,
deployShareListed,
isBenignDeployAmortizeError,
pickApiErrorMessage,
} from '@/api/warehouseDeploy'
@@ -365,46 +362,6 @@ function onSizeChange() {
search()
}
async function runBatchAndLoad() {
const start_time = String(batchRange.start_time || '').trim()
const end_time = String(batchRange.end_time || '').trim()
if (!start_time || !end_time) {
ElMessage.warning('请选择分摊时间段(开始日期、结束日期)')
return
}
if (start_time > end_time) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
page.value = 1
loading.value = true
loadingText.value = '正在跑批中…'
try {
let amortizeHint = ''
try {
await deployAmortize({ start_time, end_time })
} catch (e) {
if (!isBenignDeployAmortizeError(e)) {
throw e
}
amortizeHint = pickApiErrorMessage(e, '')
}
activeBillCode.value = ''
await fetchShareList()
if (amortizeHint) {
ElMessage.warning(amortizeHint)
} else if (rows.value.length) {
ElMessage.success(`跑批完成,分摊结果 ${total.value}`)
}
} catch (e) {
ElMessage.error(pickApiErrorMessage(e, '跑批失败'))
} finally {
loading.value = false
}
}
async function search() {
loading.value = true
loadingText.value = '加载中…'
@@ -433,7 +390,7 @@ function resetFilters() {
async function initPage() {
await loadExpenseBills()
await runBatchAndLoad()
await search()
}
onMounted(initPage)
+1 -39
View File
@@ -76,7 +76,6 @@
/>
<el-button type="primary" size="small" @click="doSearch">查询</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
<el-button type="warning" size="small" @click="runBatchAndLoad">重新跑批</el-button>
</div>
<div v-loading="loading" :element-loading-text="loadingText" class="fsl-body">
@@ -158,9 +157,7 @@ import {
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import {
freightAmortize,
freightShareListed,
isBenignFreightAmortizeError,
pickApiErrorMessage,
} from '@/api/warehouseFreight'
@@ -369,41 +366,6 @@ function onSizeChange() {
search()
}
async function runBatchAndLoad() {
const start_time = String(batchRange.start_time || '').trim()
const end_time = String(batchRange.end_time || '').trim()
if (!start_time || !end_time) {
ElMessage.warning('请选择分摊时间段(开始日期、结束日期)')
return
}
if (start_time > end_time) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
page.value = 1
loading.value = true
loadingText.value = '正在跑批中…'
try {
try {
await freightAmortize({ start_time, end_time })
} catch (e) {
if (!isBenignFreightAmortizeError(e)) {
throw e
}
}
await fetchShareList()
if (rows.value.length) {
ElMessage.success(`跑批完成,分摊结果 ${total.value}`)
}
} catch (e) {
ElMessage.error(pickApiErrorMessage(e, '跑批失败'))
} finally {
loading.value = false
}
}
async function search() {
loading.value = true
loadingText.value = '加载中…'
@@ -432,7 +394,7 @@ function resetFilters() {
async function initPage() {
await loadExpenseBills()
await runBatchAndLoad()
await search()
}
onMounted(initPage)
+1 -49
View File
@@ -68,7 +68,6 @@
/>
<el-button type="primary" size="small" @click="doSearch">查询</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
<el-button type="warning" size="small" @click="runBatchAndLoad">重新跑批</el-button>
</div>
<div v-loading="loading" :element-loading-text="loadingText" class="pcsl-body">
@@ -150,11 +149,9 @@ import {
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import {
prCostAmortize,
prCostShareListed,
pickPrCostShareGoodsName,
pickApiErrorMessage,
isBenignAmortizeError,
} from '@/api/prCost'
defineOptions({ name: 'PrCostShareListed' })
@@ -375,51 +372,6 @@ function onSizeChange() {
search()
}
/** cost/amortize(月度批量)→ cost/share/listed */
async function runBatchAndLoad() {
const start_time = String(batchRange.start_time || '').trim()
const end_time = String(batchRange.end_time || '').trim()
if (!start_time || !end_time) {
ElMessage.warning('请选择分摊时间段(开始日期、结束日期)')
return
}
if (start_time > end_time) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
page.value = 1
loading.value = true
loadingText.value = '正在跑批中…'
try {
let amortizeHint = ''
try {
await prCostAmortize({ start_time, end_time })
} catch (e) {
if (!isBenignAmortizeError(e)) {
throw e
}
amortizeHint = pickApiErrorMessage(e, '')
}
activeBillCode.value = ''
await fetchShareList()
if (amortizeHint) {
ElMessage.warning(amortizeHint)
} else if (rows.value.length) {
ElMessage.success(`跑批完成,分摊结果 ${total.value}`)
} else {
ElMessage.warning(
`跑批完成,暂无分摊结果(${start_time} ~ ${end_time})。请确认:① 该时段内有已确认/已到库采购(按订单确认时间,非采购日期);② 公关费用与采购在同一供应组织且商品能匹配;③ 票据 Tab 选「全部」`,
)
}
} catch (e) {
ElMessage.error(pickApiErrorMessage(e, '跑批失败'))
} finally {
loading.value = false
}
}
async function search() {
loading.value = true
loadingText.value = '加载中…'
@@ -447,7 +399,7 @@ function resetFilters() {
async function initPage() {
await loadExpenseBills()
await runBatchAndLoad()
await search()
}
onMounted(initPage)
+377
View File
@@ -0,0 +1,377 @@
<template>
<div class="wsl-page">
<header class="wsl-header">
<div class="wsl-header-main">
<h2 class="wsl-title">损耗分摊预估</h2>
<span class="wsl-total">{{ !loading && queried ? `${total}` : '' }}</span>
</div>
</header>
<div class="wsl-filters">
<el-date-picker
v-model="batchRange.start_time"
type="date"
placeholder="分摊开始日期"
value-format="YYYY-MM-DD"
size="small"
clearable
class="wsl-filter-date"
/>
<el-date-picker
v-model="batchRange.end_time"
type="date"
placeholder="分摊结束日期"
value-format="YYYY-MM-DD"
size="small"
clearable
class="wsl-filter-date"
/>
<el-input
v-model="filters.goods_code"
placeholder="物料编码"
clearable
size="small"
class="wsl-filter-item"
@keyup.enter="doSearch"
/>
<el-input
v-model="filters.rdc_warehouse_code"
placeholder="RDC仓库编码"
clearable
size="small"
class="wsl-filter-item"
@keyup.enter="doSearch"
/>
<el-button type="primary" size="small" @click="doSearch">查询</el-button>
<el-button size="small" @click="resetFilters">重置</el-button>
</div>
<div v-loading="loading" :element-loading-text="loadingText" class="wsl-body">
<div class="wops-flex-table is-cols-share-waste">
<div class="wops-flex-scroll">
<div class="wops-flex-row wops-flex-head">
<div
v-for="col in SHARE_COLUMNS"
:key="col.key"
class="wops-col wsl-head-col"
:class="[colHeadClass(col.key), colHeadColorClass(col.key)]"
>
{{ col.label }}
</div>
</div>
<div v-if="!loading && queried && !rows.length" class="wops-empty-wrap">
<p class="wops-empty">暂无分摊结果</p>
</div>
<div
v-for="(row, idx) in rows"
:key="shareRowKey(row, idx)"
class="wops-flex-row wops-flex-data"
:class="{ 'is-stripe': idx % 2 === 1 }"
>
<WarehouseOpsNameCode
:name="pickShareGoodsName(row)"
:code="row.goods_code"
/>
<div class="wops-col wops-col--wh">
<span class="wops-name wops-ellipsis">{{ row.rdc_warehouse_name || '—' }}</span>
</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.waste_count) }}</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.rdc_in_count) }}</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.ratio, 4) }}</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.pre_price) }}</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.share_total_amount) }}</div>
<div class="wops-col wops-col--num">{{ fmtNum(row.unit_amount) }}</div>
<div class="wops-col wops-col--time">
<span class="wops-ellipsis">{{ formatOpsLogTime(row.share_time || row.ctime) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="queried && total > 0" class="wsl-footer">
<el-pagination
v-model:current-page="page"
v-model:page-size="limit"
:total="total"
:page-sizes="[20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
small
@current-change="loadPage"
@size-change="onSizeChange"
/>
</div>
</div>
</template>
<script setup>
import { onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import WarehouseOpsNameCode from '@/components/warehouse/WarehouseOpsNameCode.vue'
import { formatOpsLogTime } from '@/utils/warehouseOpsDisplay'
import { pickGoodsNameFromListedRow } from '@/utils/goodsCategorys'
import {
pickApiErrorMessage,
wasteShareListed,
} from '@/api/warehouseWaste'
defineOptions({ name: 'WasteShareListed' })
const SHARE_COLUMNS = [
{ key: 'goods_name', label: '物料' },
{ key: 'rdc_warehouse_name', label: 'RDC仓库' },
{ key: 'waste_count', label: '损耗数' },
{ key: 'rdc_in_count', label: 'RDC入库' },
{ key: 'ratio', label: '占比' },
{ key: 'pre_price', label: '定价成本' },
{ key: 'share_total_amount', label: '分摊总额' },
{ key: 'unit_amount', label: '单件分摊' },
{ key: 'share_time', label: '分摊时间' },
]
const NUM_KEYS = new Set([
'waste_count',
'rdc_in_count',
'ratio',
'pre_price',
'share_total_amount',
'unit_amount',
])
function colHeadClass(key) {
if (NUM_KEYS.has(key)) return 'wops-col--num'
if (key === 'share_time') return 'wops-col--time'
if (key === 'rdc_warehouse_name') return 'wops-col--wh'
if (key === 'goods_name') return 'wops-col--goods'
return 'wops-col--plain'
}
function colHeadColorClass(key) {
const map = {
goods_name: 'wsl-head-col--goods',
rdc_warehouse_name: 'wsl-head-col--rdc-wh',
waste_count: 'wsl-head-col--waste',
rdc_in_count: 'wsl-head-col--in',
ratio: 'wsl-head-col--ratio',
pre_price: 'wsl-head-col--price',
share_total_amount: 'wsl-head-col--share-total',
unit_amount: 'wsl-head-col--unit-amt',
share_time: 'wsl-head-col--time',
}
return map[key] || 'wsl-head-col--plain'
}
const loading = ref(false)
const loadingText = ref('加载中…')
const queried = ref(false)
const rows = ref([])
const total = ref(0)
const page = ref(1)
const limit = ref(20)
const filters = reactive({
goods_code: '',
rdc_warehouse_code: '',
})
function buildQueryBody() {
return {
page: page.value,
limit: limit.value,
goods_code: String(filters.goods_code || '').trim() || undefined,
rdc_warehouse_code: String(filters.rdc_warehouse_code || '').trim() || undefined,
}
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function fmtDate(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
function defaultBatchRange() {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 1)
return { start_time: fmtDate(start), end_time: fmtDate(now) }
}
const batchRange = reactive(defaultBatchRange())
function pickShareGoodsName(row) {
const label = String(row?.goods_display_name ?? '').trim()
if (label) return label
return pickGoodsNameFromListedRow(row) || ''
}
function fmtNum(v, digits = 2) {
if (v == null || v === '') return '—'
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(digits) : String(v)
}
function shareRowKey(row, idx) {
return [
row?.goods_code ?? '',
row?.rdc_warehouse_code ?? '',
row?.share_time ?? row?.ctime ?? '',
row?.day_id ?? '',
idx,
].join(':')
}
async function fetchShareList() {
loadingText.value = '加载分摊结果…'
const res = await wasteShareListed(buildQueryBody())
rows.value = res.list
total.value = res.total
queried.value = true
}
function loadPage() {
if (loading.value) return
search()
}
function onSizeChange() {
page.value = 1
search()
}
async function search() {
loading.value = true
loadingText.value = '加载中…'
try {
await fetchShareList()
} catch (e) {
ElMessage.error(pickApiErrorMessage(e, '查询失败'))
} finally {
loading.value = false
}
}
function doSearch() {
page.value = 1
search()
}
function resetFilters() {
Object.assign(batchRange, defaultBatchRange())
filters.goods_code = ''
filters.rdc_warehouse_code = ''
page.value = 1
search()
}
async function initPage() {
await search()
}
onMounted(initPage)
onActivated(initPage)
</script>
<style scoped>
.wsl-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px;
box-sizing: border-box;
}
.wsl-header {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
margin-bottom: 12px;
}
.wsl-header-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
}
.wsl-title {
font-size: calc(16px + var(--wh-fs-delta, 0px));
font-weight: 600;
color: #303133;
margin: 0;
}
.wsl-total {
font-size: 13px;
color: #909399;
min-width: 60px;
display: inline-block;
}
.wsl-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
margin-bottom: 12px;
}
.wsl-filter-item {
width: 180px;
}
.wsl-filter-date {
width: 150px;
}
.wsl-body {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wsl-body :deep(.wops-flex-head) {
border-bottom: none;
align-items: flex-end;
padding-bottom: 0;
}
.wsl-body :deep(.wsl-head-col) {
border-bottom: 2px solid;
padding-bottom: 7px;
box-sizing: border-box;
}
.wsl-body :deep(.wsl-head-col--goods) { border-bottom-color: #409eff; }
.wsl-body :deep(.wsl-head-col--rdc-wh) { border-bottom-color: #67c23a; }
.wsl-body :deep(.wsl-head-col--waste) { border-bottom-color: #e6a23c; }
.wsl-body :deep(.wsl-head-col--in) { border-bottom-color: #36cfc9; }
.wsl-body :deep(.wsl-head-col--ratio) { border-bottom-color: #6b8cba; }
.wsl-body :deep(.wsl-head-col--price) { border-bottom-color: #909399; }
.wsl-body :deep(.wsl-head-col--share-total) { border-bottom-color: #722ed1; }
.wsl-body :deep(.wsl-head-col--unit-amt) { border-bottom-color: #13c2c2; }
.wsl-body :deep(.wsl-head-col--time) { border-bottom-color: #606266; }
.wsl-body :deep(.wops-col--wh) {
justify-content: flex-start;
padding-left: 12px;
}
.wsl-body :deep(.wsl-head-col--time) {
text-align: center;
}
.wsl-body :deep(.wops-col--time) {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.wsl-body :deep(.wops-col--time .wops-ellipsis) {
display: block;
width: 100%;
text-align: center;
}
.wsl-footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding-top: 10px;
}
</style>
+17 -4
View File
@@ -481,8 +481,8 @@
<div v-for="(g, idx) in orderForm.goods" :key="g.goods_code + '-' + idx" class="whp-order-goods-row">
<span class="whp-og-seq">{{ idx + 1 }}</span>
<div class="whp-og-code">
<span class="whp-og-code-label" :title="g._name ? g._name + '' + g.goods_code + '' : g.goods_code">
{{ g._name ? g._name + '' + g.goods_code + '' : g.goods_code }}
<span class="whp-og-code-label" :title="formatOrderGoodsLabel(g)">
{{ formatOrderGoodsLabel(g) }}
</span>
</div>
<div class="whp-og-tax">
@@ -631,6 +631,7 @@ import {
expenseBillTabParts,
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import { pickGoodsNameFromListedRow } from '@/utils/goodsCategorys'
defineOptions({ name: 'WhPurchase' })
@@ -760,6 +761,13 @@ function fmtOrderAmount(v) {
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatOrderGoodsLabel(g) {
const code = String(g?.goods_code || '').trim()
const name = String(g?._name || g?.goods_name || '').trim()
if (name && name !== code) return `${name}${code}`
return code || '—'
}
function resolveBillTaxRate(billCode) {
const code = normBillCode(billCode)
if (!code) return 0
@@ -1181,13 +1189,18 @@ async function onOrderPurchaseOrgChange() {
await refreshOrderCollectionOptions()
}
function onOrderCatalogAdd({ goods_code, name, payment_price, bill_tax }) {
function onOrderCatalogAdd({ goods_code, name, payment_price, bill_tax, raw }) {
const code = String(goods_code || '').trim()
if (!code) return
if (orderForm.goods.some((g) => String(g.goods_code || '').trim() === code)) return
const row = emptyOrderGoodsRow()
row.goods_code = code
row._name = String(name || '').trim()
const source = raw?.raw || raw
let displayName = String(name || '').trim()
if (!displayName || displayName === code) {
displayName = pickGoodsNameFromListedRow(source) || displayName
}
row._name = displayName !== code ? displayName : ''
if (payment_price != null && Number.isFinite(payment_price)) {
row.purchase_amount = payment_price
}
+1 -1
View File
@@ -3,7 +3,7 @@
<header class="wh-srf-rdc-right-hd">
<el-radio-group v-model="activeTab" size="small" class="wh-srf-rdc-right-tabs">
<el-radio-button value="region">可售卖区域</el-radio-button>
<el-radio-button value="deploy">可调配仓</el-radio-button>
<el-radio-button value="deploy">源采购 RDC</el-radio-button>
</el-radio-group>
</header>
<div class="wh-srf-rdc-right-body">
+325 -25
View File
@@ -135,25 +135,18 @@
append-to-body
destroy-on-close
:close-on-click-modal="false"
@opened="loadAllocAttrOptions"
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="120px" size="small">
<el-form-item label="配货属性编码" prop="alloc_attr_code">
<el-select
v-model="addForm.alloc_attr_code"
placeholder="选择配货属性编码"
filterable
clearable
:loading="allocAttrLoading"
style="width:100%"
>
<el-option
v-for="opt in allocAttrOptions"
:key="opt.alloc_attr_code"
:label="opt.label"
:value="opt.alloc_attr_code"
<div class="whw-attr-code-row">
<el-input
v-model="addForm.alloc_attr_code"
placeholder="请点击搜索选择"
readonly
/>
</el-select>
<el-button type="warning" plain @click="openAllocAttrPickDlg">搜索</el-button>
</div>
<p v-if="addFormAllocAttrLabel" class="whw-attr-picked-label">{{ addFormAllocAttrLabel }}</p>
</el-form-item>
<el-form-item label="RDC损耗数量" prop="rdc_count">
<el-input-number v-model="addForm.rdc_count" :min="0" :controls="false" class="whw-form-num" />
@@ -170,6 +163,106 @@
<el-button type="primary" :loading="addSaving" @click="submitAdd">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="allocAttrPickVisible"
title="搜索配货属性"
width="760px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
class="whw-attr-pick-dlg"
@opened="onAllocAttrPickOpened"
>
<div class="whw-attr-pick-filters">
<el-input
v-model="allocAttrQuery.goods_code"
placeholder="物料编码"
clearable
size="small"
class="whw-attr-pick-filter"
@keyup.enter="searchAllocAttrPick(true)"
/>
<el-select
v-model="allocAttrQuery.rdc_warehouse_code"
placeholder="RDC仓"
filterable
clearable
size="small"
class="whw-attr-pick-filter"
>
<el-option
v-for="w in warehouseOptions"
:key="'pick-rdc-' + w.warehouse_code"
:label="w.label"
:value="w.warehouse_code"
/>
</el-select>
<el-select
v-model="allocAttrQuery.fdc_warehouse_code"
placeholder="FDC仓"
filterable
clearable
size="small"
class="whw-attr-pick-filter"
>
<el-option
v-for="w in warehouseOptions"
:key="'pick-fdc-' + w.warehouse_code"
:label="w.label"
:value="w.warehouse_code"
/>
</el-select>
<el-button type="primary" size="small" :loading="allocAttrLoading" @click="searchAllocAttrPick(true)">查询</el-button>
</div>
<div class="whw-attr-pick-panel">
<div v-loading="allocAttrLoading" element-loading-text="搜索中…" class="whw-attr-pick-body">
<div
v-if="!allocAttrLoading && allocAttrPickQueried && !allocAttrPickRows.length"
class="whw-attr-pick-empty-wrap"
>
<p class="whw-attr-pick-empty">未找到匹配的配货属性</p>
</div>
<div v-else class="whw-attr-pick-scroll">
<button
v-for="opt in allocAttrPickRows"
:key="opt.alloc_attr_code"
type="button"
class="whw-attr-pick-item"
:class="{ 'is-active': allocAttrPickSelected === opt.alloc_attr_code }"
@click="selectAllocAttrPick(opt)"
>
<span class="whw-attr-pick-code">{{ opt.alloc_attr_code }}</span>
<span class="whw-attr-pick-meta">
<span class="whw-attr-pick-tag">物料</span>
<span>{{ opt.goodsText }}</span>
<template v-if="opt.whText">
<span class="whw-attr-pick-sep">·</span>
<span>{{ opt.whText }}</span>
</template>
</span>
</button>
</div>
</div>
<div v-if="allocAttrPickQueried && allocAttrPickTotal > 0" class="whw-attr-pick-pager">
<el-pagination
v-model:current-page="allocAttrPickPage"
:page-size="ALLOC_ATTR_PICK_PAGE_SIZE"
:total="allocAttrPickTotal"
layout="total, prev, pager, next"
small
background
@current-change="loadAllocAttrPickPage"
/>
</div>
</div>
<template #footer>
<el-button @click="allocAttrPickVisible = false">取消</el-button>
<el-button type="primary" :disabled="!allocAttrPickSelected" @click="confirmAllocAttrPick">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -178,7 +271,7 @@ import { onMounted, reactive, ref } from 'vue'
import WarehouseOpsNameCode from '@/components/warehouse/WarehouseOpsNameCode.vue'
import { ElMessage } from 'element-plus'
import { warehouseListed } from '@/api/warehouse'
import { allocAttrListed, pickAllocAttrCode } from '@/api/warehouseAlloc'
import { allocAttrSearch, pickAllocAttrCode } from '@/api/warehouseAlloc'
import {
pickApiErrorMessage,
wasteCollectListed,
@@ -189,6 +282,7 @@ import {
enrichWarehouseOpsRows,
formatOpsLogTime,
} from '@/utils/warehouseOpsDisplay'
import { pickGoodsNameFromListedRow } from '@/utils/goodsCategorys'
defineOptions({ name: 'WhWaste' })
@@ -201,8 +295,21 @@ const page = ref(1)
const limit = ref(20)
const warehouseOptions = ref([])
const allocAttrOptions = ref([])
const allocAttrLoading = ref(false)
const allocAttrPickVisible = ref(false)
const allocAttrPickQueried = ref(false)
const allocAttrPickRows = ref([])
const allocAttrPickSelected = ref('')
const allocAttrPickSelectedOption = ref(null)
const allocAttrPickPage = ref(1)
const allocAttrPickTotal = ref(0)
const ALLOC_ATTR_PICK_PAGE_SIZE = 50
const addFormAllocAttrLabel = ref('')
const allocAttrQuery = reactive({
goods_code: '',
rdc_warehouse_code: '',
fdc_warehouse_code: '',
})
const filters = reactive({
alloc_attr_code: '',
@@ -284,27 +391,52 @@ function onViewModeChange() {
loadData()
}
function allocAttrHint(row) {
function formatPickGoodsText(row) {
const code = String(row?.goods_code || '').trim()
const name = String(
row?.goods_name
|| pickGoodsNameFromListedRow(row)
|| '',
).trim()
if (name && name !== code) return `${name}${code}`
return code || '—'
}
function formatPickWhText(row) {
const parts = [
row?.goods_name || row?.goods_code,
row?.rdc_warehouse_name || row?.rdc_warehouse_code,
row?.fdc_warehouse_name || row?.fdc_warehouse_code,
].filter(Boolean)
return parts.join(' · ')
}
function allocAttrHint(row) {
const parts = [formatPickGoodsText(row), formatPickWhText(row)].filter((v) => v && v !== '—')
return parts.join(' · ')
}
function mapAllocAttrOption(row) {
const alloc_attr_code = pickAllocAttrCode(row)
if (!alloc_attr_code) return null
const goodsText = formatPickGoodsText(row)
const whText = formatPickWhText(row)
const hint = allocAttrHint(row)
const label = hint ? `${alloc_attr_code}${hint}` : alloc_attr_code
return { alloc_attr_code, label }
return { alloc_attr_code, label, hint, goodsText, whText }
}
async function loadAllocAttrOptions() {
async function searchAllocAttrPick(resetPage = false) {
if (resetPage) allocAttrPickPage.value = 1
allocAttrLoading.value = true
allocAttrPickQueried.value = true
try {
const res = await allocAttrListed({ page: 1, limit: 50 })
const res = await allocAttrSearch({
goods_code: String(allocAttrQuery.goods_code || '').trim() || undefined,
rdc_warehouse_code: String(allocAttrQuery.rdc_warehouse_code || '').trim() || undefined,
fdc_warehouse_code: String(allocAttrQuery.fdc_warehouse_code || '').trim() || undefined,
page: allocAttrPickPage.value,
limit: ALLOC_ATTR_PICK_PAGE_SIZE,
})
const enriched = enrichWarehouseOpsRows(res.list || [], {
warehouseOptions: warehouseOptions.value,
})
@@ -314,14 +446,64 @@ async function loadAllocAttrOptions() {
if (!opt || map.has(opt.alloc_attr_code)) continue
map.set(opt.alloc_attr_code, opt)
}
allocAttrOptions.value = [...map.values()]
} catch {
allocAttrOptions.value = []
allocAttrPickRows.value = [...map.values()]
allocAttrPickTotal.value = res.total || allocAttrPickRows.value.length
} catch (e) {
allocAttrPickRows.value = []
allocAttrPickTotal.value = 0
ElMessage.error(pickApiErrorMessage(e, '搜索失败'))
} finally {
allocAttrLoading.value = false
}
}
function loadAllocAttrPickPage() {
void searchAllocAttrPick(false)
}
function selectAllocAttrPick(opt) {
allocAttrPickSelected.value = opt?.alloc_attr_code || ''
allocAttrPickSelectedOption.value = opt || null
}
function resetAllocAttrPick() {
allocAttrQuery.goods_code = ''
allocAttrQuery.rdc_warehouse_code = ''
allocAttrQuery.fdc_warehouse_code = ''
allocAttrPickRows.value = []
allocAttrPickSelected.value = ''
allocAttrPickSelectedOption.value = null
allocAttrPickQueried.value = false
allocAttrPickPage.value = 1
allocAttrPickTotal.value = 0
}
function openAllocAttrPickDlg() {
resetAllocAttrPick()
allocAttrPickSelected.value = String(addForm.alloc_attr_code || '').trim()
allocAttrPickVisible.value = true
}
function onAllocAttrPickOpened() {
void searchAllocAttrPick(true)
}
function confirmAllocAttrPick() {
const code = String(allocAttrPickSelected.value || '').trim()
if (!code) return
const hit = allocAttrPickSelectedOption.value
|| allocAttrPickRows.value.find((opt) => opt.alloc_attr_code === code)
addForm.alloc_attr_code = code
addFormAllocAttrLabel.value = hit?.label || code
allocAttrPickVisible.value = false
addFormRef.value?.validateField?.('alloc_attr_code')
}
function resetAllocAttrSearch() {
resetAllocAttrPick()
addFormAllocAttrLabel.value = ''
}
async function loadWarehouses() {
try {
const list = await warehouseListed({})
@@ -341,9 +523,13 @@ async function loadWarehouses() {
function openAddDlg() {
const preset = filters.alloc_attr_code || ''
resetAllocAttrSearch()
Object.assign(addForm, emptyAddForm(), {
alloc_attr_code: preset,
})
if (preset) {
addFormAllocAttrLabel.value = preset
}
addDlgVisible.value = true
}
@@ -494,6 +680,120 @@ onMounted(async () => {
.whw-form-num {
width: 100%;
}
.whw-attr-code-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.whw-attr-code-row :deep(.el-input) {
flex: 1;
min-width: 0;
}
.whw-attr-picked-label {
margin: 6px 0 0;
font-size: 12px;
color: #606266;
line-height: 1.4;
word-break: break-all;
}
.whw-attr-pick-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.whw-attr-pick-filter {
width: 180px;
}
.whw-attr-pick-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.whw-attr-pick-body {
height: 320px;
display: flex;
flex-direction: column;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.whw-attr-pick-scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-gutter: stable;
}
.whw-attr-pick-pager {
display: flex;
justify-content: flex-end;
}
.whw-attr-pick-empty-wrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.whw-attr-pick-empty {
margin: 0;
padding: 0 16px;
text-align: center;
color: #909399;
font-size: 13px;
}
.whw-attr-pick-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
padding: 10px 12px;
border: none;
border-bottom: 1px solid #f0f2f5;
background: #fff;
text-align: left;
cursor: pointer;
transition: background .12s;
}
.whw-attr-pick-item:last-child {
border-bottom: none;
}
.whw-attr-pick-item:hover {
background: #f5f7fa;
}
.whw-attr-pick-item.is-active {
background: #ecf5ff;
}
.whw-attr-pick-code {
font-size: 13px;
font-weight: 600;
color: #303133;
font-family: ui-monospace, monospace;
}
.whw-attr-pick-code::before {
content: '配货属性 ';
font-family: inherit;
font-weight: 500;
color: #909399;
}
.whw-attr-pick-meta {
font-size: 12px;
color: #606266;
line-height: 1.4;
}
.whw-attr-pick-tag {
color: #909399;
margin-right: 4px;
}
.whw-attr-pick-sep {
margin: 0 4px;
color: #c0c4cc;
}
.whw-form-num :deep(.el-input__inner) {
text-align: left;
}
+418 -113
View File
@@ -84,10 +84,10 @@
<div class="dpb-col dpb-col--goods dpb-head-col dpb-head-col--goods">物料</div>
<div class="dpb-col dpb-col--cats dpb-head-col dpb-head-col--cats">分类</div>
<div class="dpb-col dpb-col--flag dpb-head-col dpb-head-col--accept">承兑</div>
<div class="dpb-col dpb-col--num dpb-head-col dpb-head-col--rebate">返利月</div>
<div class="dpb-col dpb-col--num dpb-col--rebate dpb-head-col dpb-head-col--rebate">返利月</div>
<div class="dpb-col dpb-col--pair dpb-head-col dpb-head-col--perf">超额绩效<span class="dpb-head-sub">/</span></div>
<div class="dpb-col dpb-col--pair dpb-head-col dpb-head-col--profit">超额利润<span class="dpb-head-sub">/</span></div>
<div class="dpb-col dpb-col--num dpb-head-col dpb-head-col--bargain">价率</div>
<div class="dpb-col dpb-col--num dpb-col--bargain dpb-head-col dpb-head-col--bargain">客户砍价率</div>
<div class="dpb-col dpb-col--pair dpb-head-col dpb-head-col--benefit">好处费<span class="dpb-head-sub">/</span></div>
<div class="dpb-col dpb-col--flag dpb-head-col dpb-head-col--special">特价</div>
<div class="dpb-col dpb-col--flag dpb-head-col dpb-head-col--valid">状态</div>
@@ -115,7 +115,10 @@
/>
</div>
<div class="dpb-col dpb-col--goods">
<span v-if="row.goods_code" class="dpb-code dpb-code--inline">{{ row.goods_code }}</span>
<template v-if="row.goods_code">
<span class="dpb-goods-name">{{ pickGoodsDisplayName(row) }}</span>
<span v-if="pickGoodsDisplayCode(row)" class="dpb-code dpb-code--inline">{{ pickGoodsDisplayCode(row) }}</span>
</template>
<span v-else class="dpb-muted"></span>
</div>
<div class="dpb-col dpb-col--cats dpb-cats-cell">
@@ -133,14 +136,47 @@
<div class="dpb-col dpb-col--flag">
<span class="dpb-flag-text">{{ fmtBool(row.is_support_acceptance) }}</span>
</div>
<div class="dpb-col dpb-col--num">{{ fmtNum(row.rebate_delay_base_months) }}</div>
<div class="dpb-col dpb-col--pair">{{ fmtNum(row.excess_performance_rate) }}<span class="dpb-pair-sep"> / </span>{{ fmtNum(row.excess_performance_amount) }}</div>
<div class="dpb-col dpb-col--pair">{{ fmtNum(row.excess_profit_rate) }}<span class="dpb-pair-sep"> / </span>{{ fmtNum(row.excess_profit_amount) }}</div>
<div class="dpb-col dpb-col--num">{{ fmtNum(row.habitual_bargain_ratio) }}</div>
<div class="dpb-col dpb-col--pair">{{ fmtNum(row.benefit_rate) }}<span class="dpb-pair-sep"> / </span>{{ fmtNum(row.benefit_fee) }}</div>
<div class="dpb-col dpb-col--num dpb-col--rebate">{{ formatCustomerPriceMonths(row.rebate_delay_base_months) }}</div>
<div class="dpb-col dpb-col--pair dpb-metric-cell">
<el-tooltip
v-if="metricSummary(row, 'excess_performance').tooltip"
:content="metricSummary(row, 'excess_performance').tooltip"
placement="top"
:show-after="280"
popper-class="dpb-metric-tooltip"
:popper-options="{ strategy: 'fixed' }"
>
<span class="dpb-metric-line">
<span class="dpb-metric-text">{{ metricSummary(row, 'excess_performance').display }}</span>
<span class="dpb-metric-multi">{{ metricSummary(row, 'excess_performance').count }}</span>
</span>
</el-tooltip>
<span v-else class="dpb-metric-line">
<span class="dpb-metric-text">{{ metricSummary(row, 'excess_performance').display }}</span>
</span>
</div>
<div class="dpb-col dpb-col--pair">{{ formatCustomerPriceRate(row.excess_profit_rate) }}<span class="dpb-pair-sep"> / </span>{{ formatCustomerPriceAmount(row.excess_profit_amount) }}</div>
<div class="dpb-col dpb-col--num dpb-col--bargain">{{ formatCustomerPriceRate(row.habitual_bargain_ratio) }}</div>
<div class="dpb-col dpb-col--pair dpb-metric-cell">
<el-tooltip
v-if="metricSummary(row, 'benefit').tooltip"
:content="metricSummary(row, 'benefit').tooltip"
placement="top"
:show-after="280"
popper-class="dpb-metric-tooltip"
:popper-options="{ strategy: 'fixed' }"
>
<span class="dpb-metric-line">
<span class="dpb-metric-text">{{ metricSummary(row, 'benefit').display }}</span>
<span class="dpb-metric-multi">{{ metricSummary(row, 'benefit').count }}</span>
</span>
</el-tooltip>
<span v-else class="dpb-metric-line">
<span class="dpb-metric-text">{{ metricSummary(row, 'benefit').display }}</span>
</span>
</div>
<div class="dpb-col dpb-col--flag">
<el-tag v-if="isTruthy(row.is_special_price)" size="small" type="warning" effect="plain">特价</el-tag>
<span v-else class="dpb-muted"></span>
<span class="dpb-flag-text">{{ fmtBool(row.is_special_price) }}</span>
</div>
<div class="dpb-col dpb-col--flag">
<el-tag
@@ -152,6 +188,7 @@
</el-tag>
</div>
<div class="dpb-col dpb-col--op">
<span class="dpb-op-link dpb-op-link--special" @click="openSpecialPrice(row)">特殊定价</span>
<span class="dpb-op-link" @click="openEdit(row)">编辑</span>
<span class="dpb-op-link dpb-op-link--muted" @click="removeRow(row)">删除</span>
</div>
@@ -188,7 +225,7 @@
<p v-if="formMode === 'batch'" class="dpb-form-batch-tip">
将以下定价参数应用到已选 <strong>{{ batchEditRows.length }}</strong> 条价格本客户{{ activeCompanyLabel }}
</p>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="88px" size="small" class="dpb-form">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" size="small" class="dpb-form">
<template v-if="formMode !== 'batch'">
<el-row :gutter="16" class="dpb-form-meta-row">
<el-col :span="16">
@@ -234,31 +271,101 @@
<div class="dpb-form-block">
<div class="dpb-form-block-hd">定价参数</div>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="返利延迟月数">
<el-input-number
v-model="form.rebate_delay_base_months"
:min="0"
:controls="false"
class="dpb-form-num"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="习惯性议价比率">
<el-input-number
v-model="form.habitual_bargain_ratio"
:controls="false"
class="dpb-form-num"
/>
</el-form-item>
</el-col>
</el-row>
<div class="dpb-form-param-shell">
<div class="dpb-form-param-top">
<div class="dpb-form-param-pair">
<label class="dpb-form-param-label">返利延迟月数</label>
<div class="dpb-form-rate-field">
<el-input-number
v-model="form.rebate_delay_base_months"
:min="0"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit"></span>
</div>
</div>
<div class="dpb-form-param-pair">
<label class="dpb-form-param-label">客户砍价率</label>
<div class="dpb-form-rate-field">
<el-input-number
v-model="form.habitual_bargain_ratio"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit">%</span>
</div>
</div>
</div>
<div class="dpb-form-rate-grid">
<div v-for="block in formRateBlocks" :key="block.key" class="dpb-form-rate-row">
<span class="dpb-form-rate-name">{{ block.label }}</span>
<div v-if="editJsonBucketKeys.length" class="dpb-form-bill-tabs">
<button
v-for="code in editJsonBucketKeys"
:key="code"
type="button"
class="dpb-form-bill-tab"
:class="{ 'is-active': activeEditBillCode === code }"
@click="activeEditBillCode = code"
>
<span class="dpb-form-bill-tab-name">{{ editBillTabLabel(code) }}</span>
</button>
</div>
<template v-if="form.excess_performance_buckets[activeEditBucketKey]">
<div class="dpb-form-param-rate">
<label class="dpb-form-param-label">超额绩效</label>
<div class="dpb-form-rate-fields">
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
<el-input-number
v-model="form.excess_performance_buckets[activeEditBucketKey].rate"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit">%</span>
</div>
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
<el-input-number
v-model="form.excess_performance_buckets[activeEditBucketKey].amount"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit"></span>
</div>
</div>
</div>
<div class="dpb-form-param-rate">
<label class="dpb-form-param-label">好处费</label>
<div class="dpb-form-rate-fields">
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
<el-input-number
v-model="form.benefit_buckets[activeEditBucketKey].rate"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit">%</span>
</div>
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
<el-input-number
v-model="form.benefit_buckets[activeEditBucketKey].fee"
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit"></span>
</div>
</div>
</div>
</template>
<div
v-for="block in formRateBlocks"
:key="block.key"
class="dpb-form-param-rate"
>
<label class="dpb-form-param-label">{{ block.label }}</label>
<div class="dpb-form-rate-fields">
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
@@ -267,6 +374,7 @@
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit">%</span>
</div>
<div class="dpb-form-rate-field">
<span class="dpb-form-rate-field-label"></span>
@@ -275,6 +383,7 @@
:controls="false"
class="dpb-form-num"
/>
<span class="dpb-form-unit"></span>
</div>
</div>
</div>
@@ -287,6 +396,14 @@
</template>
</el-dialog>
<DealerPriceSpecialPriceDlg
v-model="specialPriceDlgVisible"
:company-label="activeCompanyLabel"
:customer="activeCustomer"
:price-row="specialPriceRow"
@saved="onSpecialPriceSaved"
/>
<DealerPriceSceneGoodsDlg
v-model="sceneGoodsDlgVisible"
:customer="activeCustomer"
@@ -310,23 +427,48 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { customerListed, pickCustomerDisplayName, pickOrgBlocCode, extractIgnoreSceneCodes } from '@/api/customer'
import { normalizeOrgSaleType } from '@/api/company'
import DealerPriceSceneGoodsDlg from './DealerPriceSceneGoodsDlg.vue'
import DealerPriceCategoryGoodsDlg from './DealerPriceCategoryGoodsDlg.vue'
import DealerPriceSpecialPriceDlg from './DealerPriceSpecialPriceDlg.vue'
import {
customerPriceAdd,
customerPriceListed,
customerPriceRemove,
buildCustomerPriceWriteBody,
createEmptyCustomerPriceForm,
customerPriceFormFromRow,
fetchCustomerPriceGoodsCodes,
formatCustomerPriceAmount,
formatCustomerPriceGoodsDisplay,
formatCustomerPriceMonths,
formatCustomerPriceRate,
listCustomerPriceJsonBucketKeys,
normalizeCustomerPriceRow,
persistCustomerPriceGoods,
persistCustomerPriceModify,
pickCustomerPriceCode,
summarizeCustomerPriceMetricGroups,
} from '@/api/customerPrice'
import {
FINANCE_BILL_TYPE_EXPENSE,
expenseBillTabParts,
normalizeFinanceBillType,
} from '@/utils/financeBillTypes'
import { pickDisplayCode, pickDisplayName } from '@/utils/warehouseOpsDisplay'
defineOptions({ name: 'DealerPriceBook' })
function pickGoodsDisplayName(row) {
return pickDisplayName(row?.goods_name, row?.goods_code)
}
function pickGoodsDisplayCode(row) {
return pickDisplayCode(row?.goods_name, row?.goods_code)
}
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
@@ -350,12 +492,18 @@ const activeCompanyLabel = computed(() => {
})
const existingPriceGoodsCodes = ref([])
/** @type {import('vue').Ref<Record<string, string>>} */
const expenseBillNameMap = ref({})
const activeEditBillCode = ref('')
const sceneGoodsDlgVisible = ref(false)
const categoryGoodsDlgVisible = ref(false)
const addingPriceBook = ref(false)
const dialogVisible = ref(false)
const specialPriceDlgVisible = ref(false)
/** @type {import('vue').Ref<object|null>} */
const specialPriceRow = ref(null)
const formMode = ref('add') // add | edit | batch
const editPriceCode = ref('')
const batchEditRows = ref([])
@@ -376,9 +524,7 @@ const formCategoryLabel = computed(() => {
})
const formRateBlocks = [
{ key: 'excess_performance', label: '超额绩效', rateKey: 'excess_performance_rate', amountKey: 'excess_performance_amount' },
{ key: 'excess_profit', label: '超额利润', rateKey: 'excess_profit_rate', amountKey: 'excess_profit_amount' },
{ key: 'benefit', label: '好处费', rateKey: 'benefit_rate', amountKey: 'benefit_fee' },
]
const selectableRows = computed(() =>
@@ -426,30 +572,47 @@ function clearSelection() {
selected.value = new Set()
}
const form = reactive(emptyForm())
const form = reactive(createEmptyCustomerPriceForm())
const editJsonBucketKeys = computed(() =>
listCustomerPriceJsonBucketKeys(form.excess_performance_buckets, form.benefit_buckets),
)
const activeEditBucketKey = computed(() => {
const keys = editJsonBucketKeys.value
if (keys.length) {
const cur = String(activeEditBillCode.value || '').trim()
if (cur && keys.includes(cur)) return cur
return keys[0]
}
return ''
})
const formRules = {
goods_code: [{ required: true, message: '请填写物料编码', trigger: 'blur' }],
}
function emptyForm() {
return {
company_code: '',
goods_code: '',
category_code: '',
goods_categorys_text: '',
is_support_acceptance: false,
rebate_delay_base_months: 0,
excess_performance_rate: 0,
excess_performance_amount: 0,
excess_profit_rate: 0,
excess_profit_amount: 0,
habitual_bargain_ratio: 0,
benefit_rate: 0,
benefit_fee: 0,
is_special_price: false,
is_valid: true,
function assignFormFromRow(row) {
Object.assign(form, customerPriceFormFromRow(row), {
company_code: row.company_code || activeCompanyCode.value,
})
pickInitialEditBill()
}
function editBillTabLabel(code) {
const c = String(code || '').trim()
return expenseBillNameMap.value[c] || c || '—'
}
function pickInitialEditBill() {
const keys = editJsonBucketKeys.value
if (!keys.length) {
activeEditBillCode.value = ''
return
}
const cur = String(activeEditBillCode.value || '').trim()
if (cur && keys.includes(cur)) return
activeEditBillCode.value = keys[0]
}
function flattenDealers(list, parentGroup = null, out = []) {
@@ -463,6 +626,7 @@ function flattenDealers(list, parentGroup = null, out = []) {
name: pickCustomerDisplayName(item),
parent_code: String(item?.parent_code ?? '').trim(),
bloc_code: pickOrgBlocCode(item, groupRow),
sale_type: normalizeOrgSaleType(item?.sale_type ?? item?.sales_type),
ignore_scenes: extractIgnoreSceneCodes(item),
raw: item,
groupRaw: groupRow,
@@ -498,41 +662,47 @@ function fmtNum(v) {
return Number.isFinite(n) ? String(n) : '—'
}
function buildFormPayload() {
return {
company_code: String(activeCompanyCode.value || '').trim(),
goods_code: String(form.goods_code || '').trim(),
is_support_acceptance: !!form.is_support_acceptance,
rebate_delay_base_months: Number(form.rebate_delay_base_months) || 0,
excess_performance_rate: Number(form.excess_performance_rate) || 0,
excess_performance_amount: Number(form.excess_performance_amount) || 0,
excess_profit_rate: Number(form.excess_profit_rate) || 0,
excess_profit_amount: Number(form.excess_profit_amount) || 0,
habitual_bargain_ratio: Number(form.habitual_bargain_ratio) || 0,
benefit_rate: Number(form.benefit_rate) || 0,
benefit_fee: Number(form.benefit_fee) || 0,
is_special_price: !!form.is_special_price,
is_valid: !!form.is_valid,
function metricSummary(row, kind) {
const billNameMap = expenseBillNameMap.value
if (kind === 'benefit') {
return summarizeCustomerPriceMetricGroups(row?.benefit_groups, {
amountKey: 'fee',
billNameMap,
})
}
return summarizeCustomerPriceMetricGroups(row?.excess_performance_groups, {
amountKey: 'amount',
billNameMap,
})
}
async function loadExpenseBillNames() {
try {
const res = await request.post('/api/bill/listed', {
page: 1,
limit: 500,
type: FINANCE_BILL_TYPE_EXPENSE,
})
const list = Array.isArray(res?.data)
? res.data
: (Array.isArray(res?.data?.list) ? res.data.list : [])
const map = {}
for (const bill of list) {
if (normalizeFinanceBillType(bill?.type) !== FINANCE_BILL_TYPE_EXPENSE) continue
const code = String(bill?.bill_code ?? '').trim()
if (!code) continue
map[code] = expenseBillTabParts(bill).name
}
expenseBillNameMap.value = map
} catch {
expenseBillNameMap.value = {}
}
}
function assignFormFromRow(row) {
Object.assign(form, emptyForm(), {
company_code: row.company_code || activeCompanyCode.value,
goods_code: row.goods_code || '',
category_code: row.category_code || '',
goods_categorys_text: row.goods_categorys_text || formatCustomerPriceGoodsDisplay(row) || '',
is_support_acceptance: isTruthy(row.is_support_acceptance),
rebate_delay_base_months: Number(row.rebate_delay_base_months) || 0,
excess_performance_rate: Number(row.excess_performance_rate) || 0,
excess_performance_amount: Number(row.excess_performance_amount) || 0,
excess_profit_rate: Number(row.excess_profit_rate) || 0,
excess_profit_amount: Number(row.excess_profit_amount) || 0,
habitual_bargain_ratio: Number(row.habitual_bargain_ratio) || 0,
benefit_rate: Number(row.benefit_rate) || 0,
benefit_fee: Number(row.benefit_fee) || 0,
is_special_price: isTruthy(row.is_special_price),
is_valid: row.is_valid == null ? true : isTruthy(row.is_valid),
function buildFormPayload() {
return buildCustomerPriceWriteBody(form, {
company_code: String(activeCompanyCode.value || '').trim(),
sales_policy_type: 0,
})
}
@@ -689,14 +859,17 @@ function openAdd() {
formMode.value = 'add'
editPriceCode.value = ''
batchEditRows.value = []
Object.assign(form, emptyForm(), { company_code: activeCompanyCode.value })
Object.assign(form, createEmptyCustomerPriceForm(), {
company_code: activeCompanyCode.value,
})
pickInitialEditBill()
dialogVisible.value = true
}
function openEdit(row) {
const price_code = pickCustomerPriceCode(row)
if (!price_code) {
ElMessage.warning('缺少 price_code,无法编辑(请确认列表接口返回 price_code')
ElMessage.warning('缺少 customer_price_code,无法编辑(请确认列表接口返回 customer_price_code')
return
}
formMode.value = 'edit'
@@ -706,6 +879,15 @@ function openEdit(row) {
dialogVisible.value = true
}
function openSpecialPrice(row) {
specialPriceRow.value = row
specialPriceDlgVisible.value = true
}
async function onSpecialPriceSaved() {
await loadData()
}
function openBatchEdit() {
const list = selectedEditableRows.value
if (!list.length) {
@@ -714,7 +896,7 @@ function openBatchEdit() {
}
const missing = list.filter((row) => !pickCustomerPriceCode(row))
if (missing.length) {
ElMessage.warning('部分记录缺少 price_code,无法批量修改')
ElMessage.warning('部分记录缺少 customer_price_code,无法批量修改')
return
}
formMode.value = 'batch'
@@ -755,7 +937,7 @@ async function saveForm() {
async function removeRow(row) {
const price_code = pickCustomerPriceCode(row)
if (!price_code) {
ElMessage.warning('缺少 price_code,无法删除')
ElMessage.warning('缺少 customer_price_code,无法删除')
return
}
const label = row.goods_categorys_text
@@ -771,7 +953,7 @@ async function removeRow(row) {
return
}
try {
await customerPriceRemove({ price_code })
await customerPriceRemove(row)
ElMessage.success('已删除')
await loadData()
} catch (e) {
@@ -781,7 +963,10 @@ async function removeRow(row) {
defineExpose({ openAdd })
onMounted(() => loadCustomerList())
onMounted(() => {
void loadExpenseBillNames()
loadCustomerList()
})
</script>
<style scoped>
@@ -940,7 +1125,7 @@ onMounted(() => loadCustomerList())
}
.dpb-flex-table {
--dpb-cols: 36px 7fr 10fr 4fr 5fr 7fr 7fr 5fr 7fr 4fr 4fr 88px;
--dpb-cols: 36px 12fr 7fr minmax(2.5em, 48px) minmax(3.5em, 68px) 7fr 7fr minmax(5em, 84px) 7fr minmax(2.5em, 48px) 4fr 196px;
flex: 1;
min-height: 0;
display: flex;
@@ -995,6 +1180,22 @@ onMounted(() => loadCustomerList())
.dpb-head-col--valid { border-bottom-color: #67c23a; }
.dpb-head-col--ops { border-bottom-color: #909399; }
.dpb-flex-head .dpb-col--num { text-align: right; padding-right: 20px; }
.dpb-flex-head .dpb-col--rebate,
.dpb-flex-data .dpb-col--rebate {
padding-right: 6px;
white-space: nowrap;
}
.dpb-flex-head .dpb-col--bargain,
.dpb-flex-data .dpb-col--bargain {
padding-right: 6px;
white-space: nowrap;
}
.dpb-flex-head .dpb-col--rebate {
justify-content: flex-end;
}
.dpb-flex-head .dpb-col--bargain {
justify-content: flex-end;
}
.dpb-flex-head .dpb-col--pair { text-align: right; padding-right: 20px; }
.dpb-flex-head .dpb-col--flag,
.dpb-flex-head .dpb-col--op,
@@ -1084,6 +1285,16 @@ onMounted(() => loadCustomerList())
overflow: hidden;
padding-left: 12px;
}
.dpb-goods-name {
font-family: 'DingTalk_JinBuTi_Regular', sans-serif;
font-size: calc(13px + var(--wh-fs-delta, 0px));
font-weight: 700;
color: #1d3461;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.dpb-col--cats {
display: flex;
align-items: center;
@@ -1135,6 +1346,29 @@ onMounted(() => loadCustomerList())
color: #606266;
}
.dpb-pair-sep { color: #c0c4cc; }
.dpb-metric-cell {
overflow: hidden;
min-width: 0;
}
.dpb-metric-line {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
min-width: 0;
}
.dpb-metric-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.dpb-metric-multi {
flex-shrink: 0;
font-size: calc(11px + var(--wh-fs-delta, 0px));
color: #e6a23c;
font-weight: 600;
}
.dpb-col--flag {
display: flex;
align-items: center;
@@ -1149,7 +1383,9 @@ onMounted(() => loadCustomerList())
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
gap: 8px 18px;
padding: 0 4px;
}
.dpb-name {
@@ -1191,6 +1427,11 @@ onMounted(() => loadCustomerList())
white-space: nowrap;
}
.dpb-op-link:hover { color: #66b1ff; }
.dpb-op-link--special {
color: #e6a23c;
font-weight: 600;
}
.dpb-op-link--special:hover { color: #f0b84d; }
.dpb-op-link--muted { color: #909399; }
.dpb-op-link--muted:hover { color: #606266; }
@@ -1203,11 +1444,15 @@ onMounted(() => loadCustomerList())
}
.dpb-form-num {
width: 100%;
width: 108px;
max-width: 100%;
}
.dpb-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.dpb-form :deep(.el-form-item__label) {
white-space: nowrap;
}
.dpb-form-meta-row :deep(.el-form-item) {
margin-bottom: 12px;
}
@@ -1239,37 +1484,84 @@ onMounted(() => loadCustomerList())
font-weight: 600;
color: #606266;
}
.dpb-form-rate-grid {
.dpb-form-bill-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.dpb-form-bill-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 12px;
color: #606266;
cursor: pointer;
}
.dpb-form-bill-tab.is-active {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
.dpb-form-bill-tab-tax {
font-size: 11px;
color: #e6a23c;
}
.dpb-form-bill-tab.is-active .dpb-form-bill-tab-tax {
color: #cf9236;
}
.dpb-form-param-shell {
--dpb-param-label-w: 7.5em;
--dpb-param-input-w: 108px;
--dpb-param-gap: 10px;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.dpb-form-rate-row {
display: grid;
grid-template-columns: 88px 1fr;
.dpb-form-param-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
gap: 8px 32px;
}
.dpb-form-rate-name {
.dpb-form-param-pair {
display: grid;
grid-template-columns: var(--dpb-param-label-w) var(--dpb-param-input-w);
align-items: center;
column-gap: var(--dpb-param-gap);
}
.dpb-form-param-label {
justify-self: start;
text-align: left;
font-size: 13px;
font-weight: 500;
color: #303133;
line-height: 1.4;
color: #606266;
white-space: nowrap;
cursor: default;
}
.dpb-form-param-rate {
display: grid;
grid-template-columns: var(--dpb-param-label-w) 1fr;
align-items: center;
column-gap: var(--dpb-param-gap);
}
.dpb-form-rate-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 20px;
}
.dpb-form-rate-field {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
gap: 6px;
flex: 0 0 auto;
}
.dpb-form-rate-field-label {
flex: 0 0 18px;
@@ -1277,6 +1569,11 @@ onMounted(() => loadCustomerList())
color: #909399;
text-align: center;
}
.dpb-form-unit {
flex-shrink: 0;
font-size: 12px;
color: #909399;
}
.dpb-form-num :deep(.el-input__inner) {
text-align: left;
}
@@ -1290,3 +1587,11 @@ onMounted(() => loadCustomerList())
line-height: 1.5;
}
</style>
<style>
.dpb-metric-tooltip {
white-space: pre-line;
max-width: 320px;
line-height: 1.55;
}
</style>
File diff suppressed because it is too large Load Diff
+42 -10
View File
@@ -414,7 +414,7 @@
<el-dialog
v-model="materialDialogVisible"
:title="batchSelMode ? `批量设置选配(${selectedRows.length} 条物料)` : (materialDialogMode === 'add' ? '新增物料' : String(materialEditingId || '修改物料'))"
width="72vw"
width="88vw"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@@ -500,11 +500,14 @@
/>
</div>
<div v-if="mlActiveNavKey === ML_NAV_COST" class="mf-nav-pane mf-nav-pane--cost-api">
<GlobalTabCostPanel :goods-code="materialEditingId || ''" />
<GlobalTabCostPanel
:goods-code="materialEditingId || ''"
:rdc-list="materialWarehouseRdcs"
/>
</div>
<template v-for="(cat, catIdx) in materialForm.categories" :key="`${cat.category_code}-${catIdx}`">
<div
v-show="activeCatCode === cat.category_code && mlActiveNavKey !== ML_NAV_WAREHOUSE"
v-show="activeCatCode === cat.category_code && mlActiveNavKey !== ML_NAV_WAREHOUSE && mlActiveNavKey !== ML_NAV_COST"
class="mf-cat-pane"
:class="{ 'mf-cat-pane--fill': mlActiveNavKey === ML_NAV_BASIC }"
>
@@ -2579,10 +2582,25 @@ const windowW = ref(window.innerWidth)
const isMobile = computed(() => windowW.value <= 600)
window.addEventListener('resize', () => { windowW.value = window.innerWidth })
// 72vw padding/ 80px 240px1920525607
/** 物料弹窗宽度(须与 template width 一致) */
const MF_DIALOG_VW = 0.88
/** 弹窗 inset + 左侧导航 + 页签区内边距 + 字段网格内边距 */
const MF_GRID_INSET = 244
/** 每列最小宽度(1920×88vw 右侧字段区 → 6 列) */
const MF_COL_MIN_PX = 215
const MF_GRID_GAP = 16
function mfGridAvailW() {
return Math.max(320, windowW.value * MF_DIALOG_VW - MF_GRID_INSET)
}
function mfGridColPx(cols = mfColCount.value) {
return Math.max(160, Math.floor((mfGridAvailW() - (cols - 1) * MF_GRID_GAP) / cols))
}
// 88vw 1920625608
const mfColCount = computed(() => {
const bodyW = windowW.value * 0.72 - 80
return Math.max(2, Math.min(8, Math.floor(bodyW / 240)))
return Math.max(2, Math.min(8, Math.floor(mfGridAvailW() / MF_COL_MIN_PX)))
})
// MaterialCategory.fieldGridSpan
@@ -2591,7 +2609,7 @@ function mfFieldSpan(field) {
if (field.type === 'table' || field.type === 'textarea') return 'mf-field-full'
if (field.type === 'attachment') return 'mf-field-span2'
const cols = mfColCount.value
const colPx = Math.max(160, Math.floor((windowW.value * 0.72 - 80 - (cols - 1) * 16) / cols))
const colPx = mfGridColPx(cols)
if (field.type === 'radio') {
const opts = field.options || []
if (opts.length <= 1) return ''
@@ -2616,7 +2634,7 @@ function mfFieldSpan(field) {
/** 适配项占列数:名称较长时占两列 */
function adpItemSpan(name) {
const cols = mfColCount.value
const colPx = Math.max(160, Math.floor((windowW.value * 0.72 - 80 - (cols - 1) * 16) / cols))
const colPx = mfGridColPx(cols)
const len = String(name || '').length
// 14px/ 8px/
const estPx = len * 12 + 36 // 36 = + gap
@@ -2650,7 +2668,7 @@ function _collectCategoryWarehouseFlatFields(categoryCode, warehouseCode) {
/** 选配查看态:每个控件在 mf-fields-grid 中的占列规则,与 mfFieldSpan 一致 */
function mfSelCompGridClass(comp) {
const cols = mfColCount.value
const colPx = Math.max(160, Math.floor((windowW.value * 0.72 - 80 - (cols - 1) * 16) / cols))
const colPx = mfGridColPx(cols)
const type = comp?.type
const opts = comp?.options || []
if (type === 'radio') {
@@ -10891,6 +10909,20 @@ provide('mlTabPaneCtx', {
padding: 8px 12px 12px;
box-sizing: border-box;
}
.mf-nav-pane--cost-api {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
padding: 8px 12px 12px;
box-sizing: border-box;
}
.mf-right-scroll:has(.mf-nav-pane--cost-api) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mf-nav-pane--basic {
padding-top: 4px;
}
@@ -11059,7 +11091,7 @@ provide('mlTabPaneCtx', {
.mf-fields-grid {
display: grid;
grid-template-columns: repeat(4, 1fr); /* 默认 4 列,JS 会覆盖 */
grid-template-columns: repeat(6, 1fr); /* 默认 6 列,JS 会覆盖 */
gap: 10px 16px;
align-items: start;
padding: 12px 14px;